Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7eaad2f
Accept async context managers for cleanup contexts (#11681)\n\nAdapt …
MannXo Oct 22, 2025
795bd0a
Add contributor: Parman Mohammadalizadeh
MannXo Oct 22, 2025
91e83fe
fixed typing and test coverage issues
MannXo Oct 22, 2025
4c6781d
- moved tests to test_web_app.
MannXo Oct 22, 2025
a3488da
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 22, 2025
dd1fe24
resolved mypy issues
MannXo Oct 22, 2025
32a64ba
- fixed doc spelling
MannXo Oct 22, 2025
57802d7
fixed linter
MannXo Oct 22, 2025
6bd76a2
ignore coverage for adapter assertion in
MannXo Oct 22, 2025
b830b7e
removed comment
MannXo Oct 22, 2025
fba2864
removed unused type ignore
MannXo Oct 22, 2025
e38685c
- updated change doc
MannXo Oct 23, 2025
8186737
Removes _AsyncCMAsIterator and simplifies cleanup context handling
MannXo Oct 24, 2025
8291fa9
improved coverage
MannXo Oct 24, 2025
b11f808
simplified the _on_startup
MannXo Oct 28, 2025
8b959a5
Merge branch 'master' into feature/accept-async-context-manager-for-c…
MannXo Oct 28, 2025
ff8b95a
removed redundant type_checking condition
MannXo Oct 28, 2025
56eb622
added tests to improve coverage
MannXo Oct 28, 2025
da51359
Accept async context manager in cleanup context; add tests; repair re…
MannXo Oct 28, 2025
af15644
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 28, 2025
160ab95
Revert "Accept async context manager in cleanup context; add tests; r…
MannXo Oct 28, 2025
5f5dd32
removed unused type ignores
MannXo Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGES/11681.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Add support for using async context managers as cleanup contexts
---------------------------------------------------------------

Previously, application cleanup contexts required a single-yielding
async generator. This change allows cleanup context callbacks to return
contextlib.asynccontextmanager instances as well. The async context
manager is adapted internally so it behaves like a single-yield async
iterator: its ``__aenter__`` is executed at startup and ``__aexit__`` at
cleanup. Existing async generator-based cleanup contexts remain
supported.
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ Pahaz Blinov
Panagiotis Kolokotronis
Pankaj Pandey
Parag Jain
Parman Mohammadalizadeh
Patrick Lee
Pau Freixes
Paul Colomiets
Expand Down
73 changes: 68 additions & 5 deletions aiohttp/web_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@
Sequence,
)
from functools import lru_cache, partial, update_wrapper
from typing import TYPE_CHECKING, Any, TypeVar, cast, final, overload
from typing import (
TYPE_CHECKING,
Any,
AsyncContextManager,
TypeVar,
cast,
final,
overload,
)

from aiosignal import Signal
from frozenlist import FrozenList
Expand Down Expand Up @@ -140,7 +148,7 @@ def __init__(

def __init_subclass__(cls: type["Application"]) -> None:
raise TypeError(
f"Inheritance class {cls.__name__} from web.Application " "is forbidden"
f"Inheritance class {cls.__name__} from web.Application is forbidden"
)

# MutableMapping API
Expand Down Expand Up @@ -416,7 +424,12 @@ def exceptions(self) -> list[BaseException]:


if TYPE_CHECKING:
_CleanupContextBase = FrozenList[Callable[[Application], AsyncIterator[None]]]
# cleanup contexts may be either async generators (async iterator)
# or async context managers (contextlib.asynccontextmanager). Accept
# both for type checking clarity.
_CleanupContextBase = FrozenList[
Callable[[Application], AsyncIterator[None] | AsyncContextManager[None]]
]
else:
_CleanupContextBase = FrozenList

Expand All @@ -427,13 +440,38 @@ def __init__(self) -> None:
self._exits: list[AsyncIterator[None]] = []

async def _on_startup(self, app: Application) -> None:
"""Run registered cleanup context callbacks at startup.

Each callback may return either an async iterator (an async
generator that yields exactly once) or an async context manager
(from contextlib.asynccontextmanager). If a context manager is
returned we wrap it in `_AsyncCMAsIterator` so it behaves like an
async iterator that yields once.

"""
for cb in self:
it = cb(app).__aiter__()
ctx = cb(app)
# If the callback returned an async context manager (has
# __aenter__ and __aexit__), wrap it so it behaves like an
# async iterator that yields once.
Copy link
Member

@Dreamsorcerer Dreamsorcerer Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to make context managers our de facto default now? Surely then all we need to do for backwards compatibility is:

if not isinstance(ctx, AbstractAsyncContextManager):
    ctx = asynccontextmanager(ctx)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed a fix making it the default.

Copy link
Member

@Dreamsorcerer Dreamsorcerer Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I mean that the code should should be changed to use aenter/aexit instead of anext. Then we don't need the awkward _AsyncCMAsIterator class and much of the other code becomes much simpler.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • removed the _AsyncCMAsIterator
  • replaced the adapter-based approach with direct use of aenter/aexit for async context managers.
    CleanupContext now:
  • If cb returns an AsyncIterator: advances it once on startup and finishes it on cleanup (legacy).
  • If cb returns an AbstractAsyncContextManager: calls aenter on startup and aexit on cleanup.
  • If cb returns something else: adapts it via contextlib.asynccontextmanager, calls aenter/aexit.

if hasattr(ctx, "__aenter__") and hasattr(ctx, "__aexit__"):
# Narrow type for mypy: ctx is an AsyncContextManager here.
it = _AsyncCMAsIterator(cast(AsyncContextManager[None], ctx))
else:
# Assume it's an async iterator / async generator
it = ctx.__aiter__()

await it.__anext__()
self._exits.append(it)

async def _on_cleanup(self, app: Application) -> None:
errors = []
"""Run cleanup for all registered contexts in reverse order.

Collects and re-raises exceptions similarly to previous
implementation: a single exception is propagated as-is, multiple
exceptions are wrapped into CleanupError.
"""
errors: list[BaseException] = []
for it in reversed(self._exits):
try:
await it.__anext__()
Expand All @@ -448,3 +486,28 @@ async def _on_cleanup(self, app: Application) -> None:
raise errors[0]
else:
raise CleanupError("Multiple errors on cleanup stage", errors)


class _AsyncCMAsIterator:
"""Wrap an async context manager instance to expose async iterator protocol used by CleanupContext.

The iterator will perform ``__aenter__`` on the first ``__anext__`` call
and ``__aexit__`` on the second one (which then raises StopAsyncIteration).
"""

def __init__(self, cm: AsyncContextManager[None]) -> None:
self._cm = cm
self._entered = False

def __aiter__(self) -> "_AsyncCMAsIterator":
return self

async def __anext__(self) -> None:
if not self._entered:
# enter once and return control (equivalent to yielding once)
await self._cm.__aenter__()
self._entered = True
return None
# second call -> exit and stop iteration
await self._cm.__aexit__(None, None, None)
raise StopAsyncIteration
45 changes: 45 additions & 0 deletions tests/test_cleanup_ctx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator

from aiohttp.web_app import Application


async def test_cleanup_ctx_with_async_generator() -> None:
entered = []

async def ctx(app) -> AsyncIterator[None]:
entered.append("enter")
try:
yield
finally:
entered.append("exit")

app = Application()
app.cleanup_ctx.append(ctx)

await app._cleanup_ctx._on_startup(app)
assert entered == ["enter"]

await app._cleanup_ctx._on_cleanup(app)
assert entered == ["enter", "exit"]


async def test_cleanup_ctx_with_asynccontextmanager() -> None:
entered = []

@asynccontextmanager
async def ctx(app) -> AsyncIterator[None]:
entered.append("enter")
try:
yield
finally:
entered.append("exit")

app = Application()
app.cleanup_ctx.append(ctx)

await app._cleanup_ctx._on_startup(app)
assert entered == ["enter"]

await app._cleanup_ctx._on_cleanup(app)
assert entered == ["enter", "exit"]
Loading