Skip to content

Commit 6e8dcda

Browse files
authored
bpo-41229: Update docs for explicit aclose()-required cases and add contextlib.aclosing() method (GH-21545)
This is a PR to: * Add `contextlib.aclosing` which ia analogous to `contextlib.closing` but for async-generators with an explicit test case for [bpo-41229]() * Update the docs to describe when we need explicit `aclose()` invocation. which are motivated by the following issues, articles, and examples: * [bpo-41229]() * https://github.com/njsmith/async_generator * https://vorpus.org/blog/some-thoughts-on-asynchronous-api-design-in-a-post-asyncawait-world/#cleanup-in-generators-and-async-generators * https://www.python.org/dev/peps/pep-0533/ * https://github.com/achimnol/aiotools/blob/ef7bf0cea7af/src/aiotools/context.py#L152 Particuarly regarding [PEP-533](https://www.python.org/dev/peps/pep-0533/), its acceptance (`__aiterclose__()`) would make this little addition of `contextlib.aclosing()` unnecessary for most use cases, but until then this could serve as a good counterpart and analogy to `contextlib.closing()`. The same applies for `contextlib.closing` with `__iterclose__()`. Also, still there are other use cases, e.g., when working with non-generator objects with `aclose()` methods.
1 parent e9208f0 commit 6e8dcda

File tree

5 files changed

+133
-4
lines changed

5 files changed

+133
-4
lines changed

Doc/library/contextlib.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,39 @@ Functions and classes provided:
154154
``page.close()`` will be called when the :keyword:`with` block is exited.
155155

156156

157+
.. class:: aclosing(thing)
158+
159+
Return an async context manager that calls the ``aclose()`` method of *thing*
160+
upon completion of the block. This is basically equivalent to::
161+
162+
from contextlib import asynccontextmanager
163+
164+
@asynccontextmanager
165+
async def aclosing(thing):
166+
try:
167+
yield thing
168+
finally:
169+
await thing.aclose()
170+
171+
Significantly, ``aclosing()`` supports deterministic cleanup of async
172+
generators when they happen to exit early by :keyword:`break` or an
173+
exception. For example::
174+
175+
from contextlib import aclosing
176+
177+
async with aclosing(my_generator()) as values:
178+
async for value in values:
179+
if value == 42:
180+
break
181+
182+
This pattern ensures that the generator's async exit code is executed in
183+
the same context as its iterations (so that exceptions and context
184+
variables work as expected, and the exit code isn't run after the
185+
lifetime of some task it depends on).
186+
187+
.. versionadded:: 3.10
188+
189+
157190
.. _simplifying-support-for-single-optional-context-managers:
158191

159192
.. function:: nullcontext(enter_result=None)

Doc/reference/expressions.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,16 @@ after resuming depends on the method which resumed the execution. If
643643
:meth:`~agen.asend` is used, then the result will be the value passed in to
644644
that method.
645645

646+
If an asynchronous generator happens to exit early by :keyword:`break`, the caller
647+
task being cancelled, or other exceptions, the generator's async cleanup code
648+
will run and possibly raise exceptions or access context variables in an
649+
unexpected context--perhaps after the lifetime of tasks it depends, or
650+
during the event loop shutdown when the async-generator garbage collection hook
651+
is called.
652+
To prevent this, the caller must explicitly close the async generator by calling
653+
:meth:`~agen.aclose` method to finalize the generator and ultimately detach it
654+
from the event loop.
655+
646656
In an asynchronous generator function, yield expressions are allowed anywhere
647657
in a :keyword:`try` construct. However, if an asynchronous generator is not
648658
resumed before it is finalized (by reaching a zero reference count or by
@@ -654,9 +664,9 @@ generator-iterator's :meth:`~agen.aclose` method and run the resulting
654664
coroutine object, thus allowing any pending :keyword:`!finally` clauses
655665
to execute.
656666

657-
To take care of finalization, an event loop should define
658-
a *finalizer* function which takes an asynchronous generator-iterator
659-
and presumably calls :meth:`~agen.aclose` and executes the coroutine.
667+
To take care of finalization upon event loop termination, an event loop should
668+
define a *finalizer* function which takes an asynchronous generator-iterator and
669+
presumably calls :meth:`~agen.aclose` and executes the coroutine.
660670
This *finalizer* may be registered by calling :func:`sys.set_asyncgen_hooks`.
661671
When first iterated over, an asynchronous generator-iterator will store the
662672
registered *finalizer* to be called upon finalization. For a reference example

Lib/contextlib.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,32 @@ def __exit__(self, *exc_info):
303303
self.thing.close()
304304

305305

306+
class aclosing(AbstractAsyncContextManager):
307+
"""Async context manager for safely finalizing an asynchronously cleaned-up
308+
resource such as an async generator, calling its ``aclose()`` method.
309+
310+
Code like this:
311+
312+
async with aclosing(<module>.fetch(<arguments>)) as agen:
313+
<block>
314+
315+
is equivalent to this:
316+
317+
agen = <module>.fetch(<arguments>)
318+
try:
319+
<block>
320+
finally:
321+
await agen.aclose()
322+
323+
"""
324+
def __init__(self, thing):
325+
self.thing = thing
326+
async def __aenter__(self):
327+
return self.thing
328+
async def __aexit__(self, *exc_info):
329+
await self.thing.aclose()
330+
331+
306332
class _RedirectStream(AbstractContextManager):
307333

308334
_stream = None

Lib/test/test_contextlib_async.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from contextlib import asynccontextmanager, AbstractAsyncContextManager, AsyncExitStack
2+
from contextlib import aclosing, asynccontextmanager, AbstractAsyncContextManager, AsyncExitStack
33
import functools
44
from test import support
55
import unittest
@@ -279,6 +279,63 @@ async def woohoo(self, func, args, kwds):
279279
self.assertEqual(target, (11, 22, 33, 44))
280280

281281

282+
class AclosingTestCase(unittest.TestCase):
283+
284+
@support.requires_docstrings
285+
def test_instance_docs(self):
286+
cm_docstring = aclosing.__doc__
287+
obj = aclosing(None)
288+
self.assertEqual(obj.__doc__, cm_docstring)
289+
290+
@_async_test
291+
async def test_aclosing(self):
292+
state = []
293+
class C:
294+
async def aclose(self):
295+
state.append(1)
296+
x = C()
297+
self.assertEqual(state, [])
298+
async with aclosing(x) as y:
299+
self.assertEqual(x, y)
300+
self.assertEqual(state, [1])
301+
302+
@_async_test
303+
async def test_aclosing_error(self):
304+
state = []
305+
class C:
306+
async def aclose(self):
307+
state.append(1)
308+
x = C()
309+
self.assertEqual(state, [])
310+
with self.assertRaises(ZeroDivisionError):
311+
async with aclosing(x) as y:
312+
self.assertEqual(x, y)
313+
1 / 0
314+
self.assertEqual(state, [1])
315+
316+
@_async_test
317+
async def test_aclosing_bpo41229(self):
318+
state = []
319+
320+
class Resource:
321+
def __del__(self):
322+
state.append(1)
323+
324+
async def agenfunc():
325+
r = Resource()
326+
yield -1
327+
yield -2
328+
329+
x = agenfunc()
330+
self.assertEqual(state, [])
331+
with self.assertRaises(ZeroDivisionError):
332+
async with aclosing(x) as y:
333+
self.assertEqual(x, y)
334+
self.assertEqual(-1, await x.__anext__())
335+
1 / 0
336+
self.assertEqual(state, [1])
337+
338+
282339
class TestAsyncExitStack(TestBaseExitStack, unittest.TestCase):
283340
class SyncAsyncExitStack(AsyncExitStack):
284341
@staticmethod
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add ``contextlib.aclosing`` for deterministic cleanup of async generators
2+
which is analogous to ``contextlib.closing`` for non-async generators.
3+
Patch by Joongi Kim and John Belmonte.

0 commit comments

Comments
 (0)