Skip to content

Commit 111f873

Browse files
committed
Draft shared CI test case
1 parent 6e7bfdb commit 111f873

File tree

1 file changed

+108
-41
lines changed

1 file changed

+108
-41
lines changed

Lib/unittest/async_case.py

Lines changed: 108 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,42 @@
77

88
class _AsyncioMixin:
99

10+
# Names intentionally have a long prefix
11+
# to reduce a chance of clashing with user-defined attributes
12+
# from inherited test case
13+
#
14+
# The class doesn't call loop.run_until_complete(self.setUp()) and family
15+
# but uses a different approach:
16+
# 1. create a long-running task that reads self.setUp()
17+
# awaitable from queue along with a future
18+
# 2. await the awaitable object passing in and set the result
19+
# into the future object
20+
# 3. Outer code puts the awaitable and the future object into a queue
21+
# with waiting for the future
22+
# The trick is necessary because every run_until_complete() call
23+
# creates a new task with embedded ContextVar context.
24+
# To share contextvars between setUp(), test and tearDown() we need to execute
25+
# them inside the same task.
26+
27+
# Note: the test case modifies event loop policy if the policy was not instantiated
28+
# yet.
29+
# asyncio.get_event_loop_policy() creates a default policy on demand but never
30+
# returns None
31+
# I believe this is not an issue in user level tests but python itself for testing
32+
# should reset a policy in every test module
33+
# by calling asyncio.set_event_loop_policy(None) in tearDownModule()
34+
1035
def __init__(self, methodName='runTest'):
1136
super().__init__(methodName)
1237
self._asyncioTestLoop = None
1338
self._asyncioCallsQueue = None
1439

40+
def setUpAsyncioLoop(self):
41+
raise NotImplementedError
42+
43+
def tearDownAsyncioLoop(self, loop):
44+
raise NotImplementedError
45+
1546
async def asyncSetUp(self):
1647
pass
1748

@@ -87,10 +118,24 @@ async def _asyncioLoopRunner(self, fut):
87118
fut.set_exception(ex)
88119

89120
def _setupAsyncioLoop(self):
90-
raise NotImplementedError
121+
assert self._asyncioTestLoop is None, 'asyncio test loop already initialized'
122+
loop = self.setUpAsyncioLoop()
123+
if loop is None:
124+
raise AssertionError(
125+
"setUpAsyncioLoop() should return a loop instance, got None"
126+
)
127+
self._asyncioTestLoop = loop
128+
fut = loop.create_future()
129+
self._asyncioCallsTask = loop.create_task(self._asyncioLoopRunner(fut))
130+
loop.run_until_complete(fut)
91131

92132
def _tearDownAsyncioLoop(self):
93-
raise NotImplementedError
133+
assert self._asyncioTestLoop is not None, 'asyncio test loop is not initialized'
134+
loop = self._asyncioTestLoop
135+
self._asyncioCallsQueue.put_nowait(None)
136+
loop.run_until_complete(self._asyncioCallsQueue.join())
137+
self.tearDownAsyncioLoop(loop)
138+
self._asyncioTestLoop = None
94139

95140
def run(self, result=None):
96141
self._setupAsyncioLoop()
@@ -104,50 +149,65 @@ def debug(self):
104149
super().debug()
105150
self._tearDownAsyncioLoop()
106151

152+
def __del__(self):
153+
if self._asyncioTestLoop is not None:
154+
self._tearDownAsyncioLoop()
155+
107156

108157
class IsolatedAsyncioTestCase(_AsyncioMixin, TestCase):
109-
# Names intentionally have a long prefix
110-
# to reduce a chance of clashing with user-defined attributes
111-
# from inherited test case
112-
#
113-
# The class doesn't call loop.run_until_complete(self.setUp()) and family
114-
# but uses a different approach:
115-
# 1. create a long-running task that reads self.setUp()
116-
# awaitable from queue along with a future
117-
# 2. await the awaitable object passing in and set the result
118-
# into the future object
119-
# 3. Outer code puts the awaitable and the future object into a queue
120-
# with waiting for the future
121-
# The trick is necessary because every run_until_complete() call
122-
# creates a new task with embedded ContextVar context.
123-
# To share contextvars between setUp(), test and tearDown() we need to execute
124-
# them inside the same task.
158+
"""Isolated asyncio test case.
125159
126-
# Note: the test case modifies event loop policy if the policy was not instantiated
127-
# yet.
128-
# asyncio.get_event_loop_policy() creates a default policy on demand but never
129-
# returns None
130-
# I believe this is not an issue in user level tests but python itself for testing
131-
# should reset a policy in every test module
132-
# by calling asyncio.set_event_loop_policy(None) in tearDownModule()
160+
New event loop is created for each test.
161+
"""
133162

134-
def _setupAsyncioLoop(self):
135-
assert self._asyncioTestLoop is None, 'asyncio test loop already initialized'
163+
def setUpAsyncioLoop(self):
136164
loop = asyncio.new_event_loop()
137165
asyncio.set_event_loop(loop)
138166
loop.set_debug(True)
139-
self._asyncioTestLoop = loop
140-
fut = loop.create_future()
141-
self._asyncioCallsTask = loop.create_task(self._asyncioLoopRunner(fut))
142-
loop.run_until_complete(fut)
167+
return loop
143168

144-
def _tearDownAsyncioLoop(self):
145-
assert self._asyncioTestLoop is not None, 'asyncio test loop is not initialized'
146-
loop = self._asyncioTestLoop
147-
self._asyncioTestLoop = None
148-
self._asyncioCallsQueue.put_nowait(None)
149-
loop.run_until_complete(self._asyncioCallsQueue.join())
169+
def tearDownAsyncioLoop(self, loop):
170+
try:
171+
# cancel all tasks
172+
to_cancel = asyncio.all_tasks(loop)
173+
if not to_cancel:
174+
return
175+
176+
for task in to_cancel:
177+
task.cancel()
150178

179+
loop.run_until_complete(
180+
asyncio.gather(*to_cancel, return_exceptions=True))
181+
182+
for task in to_cancel:
183+
if task.cancelled():
184+
continue
185+
if task.exception() is not None:
186+
loop.call_exception_handler({
187+
'message': 'unhandled exception during test shutdown',
188+
'exception': task.exception(),
189+
'task': task,
190+
})
191+
loop.run_until_complete(loop.shutdown_asyncgens())
192+
loop.run_until_complete(loop.shutdown_default_executor())
193+
finally:
194+
asyncio.set_event_loop(None)
195+
loop.close()
196+
197+
198+
199+
class SharedAsyncioTestCase(_AsyncioMixin, TestCase):
200+
_globalAsyncioLoop = None
201+
202+
@classmethod
203+
def setUpGlobalAsyncioLoop(cls):
204+
loop = asyncio.new_event_loop()
205+
asyncio.set_event_loop(loop)
206+
loop.set_debug(True)
207+
return loop
208+
209+
@classmethod
210+
def tearDownGlobalAsyncioLoop(cls, loop):
151211
try:
152212
# cancel all tasks
153213
to_cancel = asyncio.all_tasks(loop)
@@ -169,12 +229,19 @@ def _tearDownAsyncioLoop(self):
169229
'exception': task.exception(),
170230
'task': task,
171231
})
172-
# shutdown asyncgens
173232
loop.run_until_complete(loop.shutdown_asyncgens())
233+
loop.run_until_complete(loop.shutdown_default_executor())
174234
finally:
175235
asyncio.set_event_loop(None)
176236
loop.close()
177237

178-
def __del__(self):
179-
if self._asyncioTestLoop is not None:
180-
self._tearDownAsyncioLoop()
238+
def setUpAsyncioLoop(self):
239+
loop = self.__class__._globalAsyncioLoop
240+
if loop is not None:
241+
loop = self.setUpGlobalAsyncioLoop()
242+
self.__class__._globalAsyncioLoop = loop
243+
return loop
244+
245+
def tearDownAsyncioLoop(self, loop):
246+
# The loop teardown is performed on the process teardown
247+
pass

0 commit comments

Comments
 (0)