Skip to content

Fix stubgen output for async context managers - #21870

Open
nightcityblade wants to merge 1 commit into
python:masterfrom
nightcityblade:fix/issue-21869
Open

Fix stubgen output for async context managers#21870
nightcityblade wants to merge 1 commit into
python:masterfrom
nightcityblade:fix/issue-21869

Conversation

@nightcityblade

Copy link
Copy Markdown

Fixes #21869

Stubgen preserved both the asynccontextmanager decorator and the async keyword, producing a stub that mypy rejects because a stub body cannot establish an async generator.

This tracks when contextlib.asynccontextmanager is present and omits async only from the emitted stub signature. The decorator, return annotation, and source AST behavior remain otherwise unchanged. Regression coverage includes both a module function and a method.

Tests:

  • .venv/bin/python -m pytest mypy/test/teststubgen.py -q (374 passed, 1 skipped, 2 xfailed)
  • .venv/bin/pre-commit run --files mypy/stubgen.py test-data/unit/stubgen.test
  • .venv/bin/python -m mypy --config-file mypy_self_check.ini mypy/stubgen.py

@selsky

selsky commented Aug 19, 2026

Copy link
Copy Markdown

Thanks for picking this up — I filed #21869.

I applied this patch to a v2.3.0 checkout and tested it. It fixes the issue, and the output is not just error-free but type-equivalent to the source. Running the same consumer against the generated stub and against the original module:

via generated stub via original source
async with ctx() int int
async with a.ctx() str str
reveal_type(ctx) def () -> contextlib._AsyncGeneratorContextManager[int, None] (identical)

So dropping async from the emitted signature preserves the decorated type exactly, which I think settles that this spelling is the right one of the two the typing docs allow.

I also checked the failure mode this design could have — _has_async_contextmanager is instance state, so the risk is it leaking into a following function and stripping a legitimate async. It doesn't. Interleaving the cases:

@asynccontextmanager
async def ctx() -> AsyncIterator[int]: ...   # -> async dropped
async def plain_coro() -> int: ...           # -> async kept
@contextmanager
def sync_ctx() -> Iterator[int]: ...         # -> unchanged
class A:
    @asynccontextmanager
    async def actx(self) -> AsyncGenerator[str, None]: ...  # -> dropped
    async def method_coro(self) -> int: ...                 # -> kept
    @staticmethod
    @asynccontextmanager
    async def sctx() -> AsyncIterator[int]: ...             # -> dropped, both decorators kept

All correct. That holds because the flag is set in process_decorator and cleared in clear_decorators, i.e. exactly the lifecycle _decorators already has.

One suggestion: that non-interference isn't pinned by a test. Since it's the single property this approach could regress on, and the harness compares whole-file output, it costs four lines folded into the case you already added rather than a new one:

 [case testAsyncContextManager]
 from collections.abc import AsyncGenerator, AsyncIterator
 from contextlib import asynccontextmanager

 @asynccontextmanager
 async def ctx() -> AsyncIterator[int]:
     yield 1

+async def coro() -> int:
+    return 1
+
 class A:
     @asynccontextmanager
     async def ctx(self) -> AsyncGenerator[str, None]:
         yield "value"
+
+    async def coro(self) -> int:
+        return 1
 [out]
 from collections.abc import AsyncGenerator, AsyncIterator
 from contextlib import asynccontextmanager

 @asynccontextmanager
 def ctx() -> AsyncIterator[int]: ...
+async def coro() -> int: ...

 class A:
     @asynccontextmanager
     def ctx(self) -> AsyncGenerator[str, None]: ...
+    async def coro(self) -> int: ...

I generated that [out] block from the patched stubgen rather than writing it by hand, but worth re-running pytest mypy/test/teststubgen.py to confirm it matches under the harness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stubgen generates stubs for @asynccontextmanager functions that mypy itself rejects

2 participants