Skip to content

Commit b1862d4

Browse files
committed
asyncio: #10 shutdown_asyncgens
Ensure any async generators that are left running past run_until_complete (essentially only if we get a KeyboardInterrupt that stops the run_until_complete) then those are finalized properly Note I don't use loop.shutdown_asyncgens sothat I can make the generator handle an asyncio.CancelledError rather than a GeneratorExit
1 parent cdbec4e commit b1862d4

File tree

4 files changed

+109
-1
lines changed

4 files changed

+109
-1
lines changed

README.rst

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Changelog
2525
* Fix bug where it was possible for an async generator fixture to
2626
be cleaned up even if it was never started.
2727
* This library is now 3.7+ only
28+
* Added an equivalent ``shutdown_asyncgen`` to the OverrideLoop helper
2829

2930
0.5.4 - 26 January 2021
3031
* Added a ``--default-async-timeout`` option from the CLI. With many thanks

alt_pytest_asyncio/plugin.py

+35
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,46 @@ def __exit__(self, exc_typ, exc, tb):
199199
try:
200200
if getattr(self, "loop", None):
201201
cancel_all_tasks(self.loop, ignore_errors_from_tasks=self.tasks)
202+
self.loop.run_until_complete(self.shutdown_asyncgens())
202203
self.loop.close()
203204
finally:
204205
if hasattr(self, "_original_loop"):
205206
asyncio.set_event_loop(self._original_loop)
206207

208+
async def shutdown_asyncgens(self):
209+
"""
210+
A version of loop.shutdown_asyncgens that tries to cancel the generators
211+
before closing them.
212+
"""
213+
if not len(self.loop._asyncgens):
214+
return
215+
216+
closing_agens = list(self.loop._asyncgens)
217+
self.loop._asyncgens.clear()
218+
219+
# I would do an asyncio.tasks.gather but it would appear that just causes
220+
# the asyncio loop to think it's shutdown, so I have to do them one at a time
221+
for ag in closing_agens:
222+
try:
223+
try:
224+
try:
225+
await ag.athrow(asyncio.CancelledError())
226+
except StopAsyncIteration:
227+
pass
228+
finally:
229+
await ag.aclose()
230+
except asyncio.CancelledError:
231+
pass
232+
except:
233+
exc = sys.exc_info()[1]
234+
self.loop.call_exception_handler(
235+
{
236+
"message": "an error occurred during closing of asynchronous generator",
237+
"exception": exc,
238+
"asyncgen": ag,
239+
}
240+
)
241+
207242
def run_until_complete(self, coro):
208243
if not hasattr(self, "loop"):
209244
raise Exception(

pylama.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
skip = */setup.py
33

44
[pylama:tests/*]
5-
ignore = E225,E202,E211,E231,E226,W292,W291,E251,E122,E501,E701,E227,E305,E128,W391
5+
ignore = E225,E202,E211,E231,E226,W292,W291,E251,E122,E501,E701,E227,E305,E128,W391,C901
66

77
[pylama:pyflakes]
88
builtins = _

tests/test_override_loop.py

+72
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,78 @@ async def blah():
137137
get_event_loop().run_until_complete(info["coro"])
138138

139139

140+
it "can shutdown async gens":
141+
info1 = []
142+
info2 = []
143+
info3 = []
144+
145+
original = asyncio.get_event_loop()
146+
147+
async def my_generator(info):
148+
try:
149+
info.append(1)
150+
yield
151+
info.append(2)
152+
yield
153+
info.append(3)
154+
except asyncio.CancelledError:
155+
info.append("cancelled")
156+
raise
157+
finally:
158+
info.append(("done", __import__("sys").exc_info()[0]))
159+
160+
# Test that the outside loop isn't affected by the inside loop
161+
outside_gen = my_generator(info1)
162+
163+
async def outside1():
164+
await outside_gen.__anext__()
165+
await outside_gen.__anext__()
166+
167+
original.run_until_complete(outside1())
168+
assert info1 == [1, 2]
169+
170+
# The way python asyncio works
171+
# Means that by defining this outside our OverrideLoop
172+
# The weakref held against it in the _asyncgens set on the loop
173+
# Will remain so that our shutdown_asyncgens function may work
174+
ag = my_generator(info2)
175+
176+
with OverrideLoop(new_loop=True) as custom_loop:
177+
assert info2 == []
178+
assert info3 == []
179+
180+
async def doit():
181+
ag2 = my_generator(info3)
182+
assert set(asyncio.get_event_loop()._asyncgens) == set()
183+
await ag2.__anext__()
184+
assert set(asyncio.get_event_loop()._asyncgens) == set([ag2])
185+
await ag.__anext__()
186+
assert set(asyncio.get_event_loop()._asyncgens) == set([ag2, ag])
187+
await ag.__anext__()
188+
assert info3 == [1]
189+
190+
custom_loop.run_until_complete(doit())
191+
assert list(custom_loop.loop._asyncgens) == [ag]
192+
assert info3 == [1]
193+
assert info2 == [1, 2]
194+
195+
assert asyncio.get_event_loop() is original
196+
assert not original.is_closed()
197+
198+
assert info3 == [1, "cancelled", ("done", asyncio.CancelledError)]
199+
assert info2 == [1, 2, "cancelled", ("done", asyncio.CancelledError)]
200+
assert info1 == [1, 2]
201+
202+
async def outside2():
203+
try:
204+
await outside_gen.__anext__()
205+
except StopAsyncIteration:
206+
pass
207+
208+
# Test that the outside loop isn't affected by the inside loop
209+
original.run_until_complete(outside2())
210+
assert info1 == [1, 2, 3, ("done", None)]
211+
140212
describe "testing autouse":
141213

142214
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)