Skip to content

Added context manager mix-in classes #905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dc683b0
Added context manager mix-in classes
agronholm Apr 2, 2025
5997105
Updated the documentation
agronholm Apr 2, 2025
664a19f
Merge branch 'master' into contextmanagermixin
agronholm Apr 2, 2025
3765c28
Fixed updated documentation section
agronholm Apr 2, 2025
7aaec76
Made the type variable covariant
agronholm Apr 2, 2025
84685d9
Addressed reentrancy by raising RuntimeError if attempting to enter a…
agronholm Apr 2, 2025
a8fbf36
Merge branch 'master' into contextmanagermixin
agronholm Apr 6, 2025
68884e3
Merge branch 'master' into contextmanagermixin
agronholm Apr 14, 2025
5254a11
Merge branch 'master' into contextmanagermixin
agronholm Apr 15, 2025
915b172
Merge branch 'master' into contextmanagermixin
agronholm Apr 15, 2025
7f5c4b5
Delete self.__cm instead of setting to None
agronholm Apr 6, 2025
921c880
Enforced context manager decorators on the dunder methods
agronholm Apr 16, 2025
b71f419
Fix contextmanager mixin typing (#912)
tapetersen Apr 18, 2025
dcba443
Merge branch 'master' into contextmanagermixin
agronholm Apr 18, 2025
562d21a
Added documentation section for context manager mix-ins
agronholm Apr 18, 2025
09a5a9e
Added documentation cross-references to the API docs
agronholm Apr 18, 2025
7334290
Added attribution
agronholm Apr 18, 2025
b3c38db
Merge branch 'master' into contextmanagermixin
agronholm Apr 18, 2025
b67e36c
Merge branch 'master' into contextmanagermixin
agronholm Apr 21, 2025
492120a
Improved the documentation
agronholm Apr 23, 2025
55a941a
Merge branch 'master' into contextmanagermixin
agronholm Apr 24, 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
9 changes: 9 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ Temporary files and directories
.. autoclass:: anyio.SpooledTemporaryFile
.. autoclass:: anyio.TemporaryDirectory

Context manager mix-in classes
------------------------------

.. autoclass:: anyio.ContextManagerMixin
:special-members: __contextmanager__

.. autoclass:: anyio.AsyncContextManagerMixin
:special-members: __asynccontextmanager__

Streams and stream wrappers
---------------------------

Expand Down
33 changes: 16 additions & 17 deletions docs/cancellation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,11 @@ Depending on how they are used, this pattern is, however, *usually* safe to use
asynchronous context managers, so long as you make sure that the same host task keeps
running throughout the entire enclosed code block::

from contextlib import asynccontextmanager


# Okay in most cases!
@async_context_manager
@asynccontextmanager
async def some_context_manager():
async with create_task_group() as tg:
tg.start_soon(foo)
Expand All @@ -209,24 +212,20 @@ start to end in the same task, making it possible to have task groups or cancel
safely straddle the ``yield``.

When you're implementing the async context manager protocol manually and your async
context manager needs to use other context managers, you may find it necessary to call
their ``__aenter__()`` and ``__aexit__()`` directly. In such cases, it is absolutely
vital to ensure that their ``__aexit__()`` methods are called in the exact reverse order
of the ``__aenter__()`` calls. To this end, you may find the
:class:`~contextlib.AsyncExitStack` class very useful::
context manager needs to use other context managers, you may find it convenient to use
:class:`AsyncContextManagerMixin` in order to avoid cumbersome code that calls
``__aenter__()`` and ``__aexit__()`` directly::

from contextlib import AsyncExitStack
from __future__ import annotations

from anyio import create_task_group
from collections.abc import AsyncGenerator

from anyio import AsyncContextManagerMixin, create_task_group

class MyAsyncContextManager:
async def __aenter__(self):
self._exitstack = AsyncExitStack()
await self._exitstack.__aenter__()
Copy link
Collaborator

@graingert graingert Apr 24, 2025

Choose a reason for hiding this comment

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

oh this was slightly wrong it should have used pop_all()

I think it's worth including an (Async)ExitStack instruction on how to do this

self._task_group = await self._exitstack.enter_async_context(
create_task_group()
)

async def __aexit__(self, exc_type, exc_val, exc_tb):
return await self._exitstack.__aexit__(exc_type, exc_val, exc_tb)
# AsyncContextManagerMixin is parametrized this way because it returns 'self'
class MyAsyncContextManager(AsyncContextManagerMixin["MyAsyncContextManager"]):
async def __asynccontextmanager__(self) -> AsyncGenerator[MyAsyncContextManager]:
async with create_task_group() as tg:
... # launch tasks
yield self
6 changes: 6 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ Version history

This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**UNRELEASED**

- Added context manager mix-in classes (``anyio.ContextManagerMixin`` and
``anyio.AsyncContextManagerMixin``) to help write classes that embed other context
managers (particularly cancel scopes or task groups)

**4.9.0**

- Added async support for temporary file handling
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ relative_files = true

[tool.coverage.report]
show_missing = true
exclude_also = [
"if TYPE_CHECKING:",
"@(abc\\.)?abstractmethod",
]

[tool.tox]
env_list = ["pre-commit", "py39", "py310", "py311", "py312", "py313", "py314", "pypy3"]
Expand Down
2 changes: 2 additions & 0 deletions src/anyio/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from ._core._contextmanagers import AsyncContextManagerMixin as AsyncContextManagerMixin
from ._core._contextmanagers import ContextManagerMixin as ContextManagerMixin
from ._core._eventloop import current_time as current_time
from ._core._eventloop import get_all_backends as get_all_backends
from ._core._eventloop import get_cancelled_exc_class as get_cancelled_exc_class
Expand Down
159 changes: 159 additions & 0 deletions src/anyio/_core/_contextmanagers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from __future__ import annotations

from abc import abstractmethod
from collections.abc import AsyncGenerator, Generator
from inspect import isasyncgen, iscoroutine
from types import TracebackType
from typing import Generic, TypeVar, final

_T_co = TypeVar("_T_co", covariant=True)


class ContextManagerMixin(Generic[_T_co]):
"""
Mixin class providing context manager functionality via a generator-based
implementation.

This class allows you to implement a context manager via :meth:`__contextmanager__`
which should return a generator. The mechanics are meant to mirror those of
:func:`@contextmanager <contextlib.contextmanager>`.
"""

@final
def __enter__(self) -> _T_co:
gen = self.__contextmanager__()
if not isinstance(gen, Generator):
raise TypeError(
f"__contextmanager__() did not return a generator object, "
f"but {gen.__class__!r}"
)

try:
value = gen.send(None)
except StopIteration:
raise RuntimeError(
"the __contextmanager__() generator returned without yielding a value"
) from None

self.__cm = gen
return value

@final
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool | None:
# Prevent circular references
cm = self.__cm
del self.__cm

if exc_val is not None:
try:
cm.throw(exc_val)
except StopIteration:
return True
else:
try:
cm.send(None)
except StopIteration:
return None

cm.close()
raise RuntimeError("the __contextmanager__() generator didn't stop")

@abstractmethod
def __contextmanager__(self) -> Generator[_T_co, None, None]:
"""
Implement your context manager logic here, as you would with
:func:`@contextmanager <contextlib.contextmanager>`.

Any code up to the ``yield`` will be run in ``__enter__()``, and any code after
it is run in ``__exit__()``.

.. note:: If an exception is raised in the context block, it is reraised from
the ``yield``, just like with
:func:`@contextmanager <contextlib.contextmanager>`.

:return: a generator that yields exactly once
"""


class AsyncContextManagerMixin(Generic[_T_co]):
"""
Mixin class providing async context manager functionality via a generator-based
implementation.

This class allows you to implement a context manager via
:meth:`__asynccontextmanager__`. The mechanics are meant to mirror those of
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.
"""

@final
async def __aenter__(self) -> _T_co:
gen = self.__asynccontextmanager__()
if not isasyncgen(gen):
if iscoroutine(gen):
gen.close()
raise TypeError(
"__asynccontextmanager__() returned a coroutine object instead of "
"an async generator. Did you forget to add 'yield'?"
)

raise TypeError(
f"__asynccontextmanager__() did not return an async generator object, "
f"but {gen.__class__!r}"
)

try:
value = await gen.asend(None)
except StopAsyncIteration:
raise RuntimeError(
"the __asynccontextmanager__() generator returned without yielding a "
"value"
) from None

self.__cm = gen
return value

@final
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool | None:
# Prevent circular references
cm = self.__cm
del self.__cm

if exc_val is not None:
try:
await cm.athrow(exc_val)
except StopAsyncIteration:
return True
else:
try:
await cm.asend(None)
except StopAsyncIteration:
return None

await cm.aclose()
raise RuntimeError("the __asynccontextmanager__() generator didn't stop")

@abstractmethod
def __asynccontextmanager__(self) -> AsyncGenerator[_T_co, None]:
"""
Implement your async context manager logic here, as you would with
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.

Any code up to the ``yield`` will be run in ``__aenter__()``, and any code after
it is run in ``__aexit__()``.

.. note:: If an exception is raised in the context block, it is reraised from
the ``yield``, just like with
:func:`@asynccontextmanager <contextlib.asynccontextmanager>`.

:return: an async generator that yields exactly once
"""
Loading
Loading