This item is a cross-reference. The machinery lives elsewhere in the course:
-
5.24 —
asynccontextmanager,aclosing,AsyncExitStackowns the library side: how to build one from a generator, how to stack several, and why an abandoned async generator does not release its connection promptly. -
7.4 — Async iterators and async generators owns the iteration side:
__aiter__/__anext__,StopAsyncIteration, and what breaking out of anasync forleaves suspended.
What concurrency adds is two signatures that are easy to get subtly wrong and impossible to notice afterwards.
__aenter__ returns Self
from typing import Self
class Session:
async def __aenter__(self) -> Self:
await self._connect()
return self
Not -> "Session". Self (3.11, and in typing_extensions before that) is
what makes the annotation survive subclassing: with -> Session, a
class AuthedSession(Session) used as async with AuthedSession() as s: gives
you an s typed Session, and every subclass-specific attribute you reach for
afterwards is an attr-defined error you then “fix” with a cast.
This is not an async-specific rule — it applies equally to __enter__ — but
async context managers are where it bites most, because the whole point of the
object is usually to hold a resource whose subclass-specific API you are about
to use.
__aexit__ returns bool | None, and the difference matters
The full signature:
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool | None:
The return value is a suppression flag. A truthy return tells Python “I
have handled the exception; do not propagate it”. None and False both mean
“let it through”.
Now consider the annotation -> bool. It type-checks. It runs. And it invites
exactly one mistake:
async def __aexit__(self, exc_type, exc, tb) -> bool:
await self._close()
return True # <-- looks like "cleanup succeeded"
That True does not mean “cleanup succeeded”. It means every exception
raised inside the async with block is now silently swallowed. Not logged,
not re-raised, not visible: the block simply appears to have completed. It is
one of the quietest bugs you can write, and the -> bool annotation is what
makes it look natural — a function annotated -> bool wants to return True
or False, and neither is the answer you want.
Annotate it -> bool | None and end the method with no return at all. The
type then reads as “usually nothing, occasionally a deliberate suppression”,
which is the truth.
💡contextlib.asynccontextmanager turns a generator into an async context manager. How does suppression work there, and can you make the same mistake?
click to reveal
Differently, and yes — with a different spelling.
In the generator form there is no __aexit__ to annotate. The exception is
thrown into the generator at its yield, so you handle it with an ordinary
try/except around the yield:
@asynccontextmanager
async def session() -> AsyncIterator[Session]:
s = await connect()
try:
yield s
finally:
await s.close()
Suppression happens if your except around the yield catches and does not
re-raise — which reads much more obviously as “I am swallowing this” than
return True does. That is a real argument for the generator form.
The generator form has its own trap, though, and it is the mirror image: if the
generator does not yield again after the exception, __aexit__ returns falsey
and the exception propagates, but if you write except Exception: yield
you get RuntimeError: generator didn't stop after throw(). And the annotation
AsyncIterator[Session] — not AsyncGenerator[Session, None] — is what the
decorator’s own typeshed signature expects.
The one concurrency-specific rule
__aexit__ runs during cancellation too. If the enclosing task is cancelled
inside the block, exc_type is asyncio.CancelledError, and everything from
item 9.10 applies: your cleanup may await, it must be bounded, and it must
not suppress. Returning truthy from __aexit__ when exc_type is
CancelledError is how a task refuses to be cancelled without anyone writing
the words except CancelledError.