1+ """
2+ Integrations with pytest & pytest-asyncio (irrelevant for other frameworks).
3+
4+
5+ IMPLEMENTATION DETAIL - reverse time movement
6+ =============================================
7+
8+ Problem
9+ -------
10+
11+ Pytest-asyncio>=1.0.0 has removed the ``event_loop`` fixture and fully switched
12+ to the ``event_loop_policy`` (session-scoped) plus several independent fixtures:
13+ session-, package-, module-, class-, function-scoped. It means that a test
14+ might use any of these fixtures or several or all of them at the same time.
15+
16+ As a result, our previous assumption that every test & all its fixtures run
17+ in its own isolated short-lived event loop, is now broken:
18+
19+ - A single event loop can be shared by multiple (but not all) tests.
20+ - A single test can be spread over multiple (but not all) event loops.
21+
22+ An classic example:
23+
24+ - A session-scoped fixture ``server`` starts a port listener & an HTTP server.
25+ - A module-scoped fixture ``data`` populates the server via POST requests.
26+ - A few function-scoped tests access & assert these data via GET requests.
27+ - Other tests verify the database and do not touch the event loops.
28+
29+ Looptime suggests setting the start time of the event loop or expect it to be 0.
30+ This simplifies assertions, scheduling of events, callbacks, and other triggers.
31+
32+ As a result, a long-living event loop might see the time set/reset by tests,
33+ and in most cases, it will be moving the time backwards.
34+
35+ Time, by its nature, is supposed to be monotonic (but can be non-linear),
36+ specifically positively monotonic — always growing, never going backwards.
37+
38+
39+ Solution
40+ --------
41+
42+ We sacrifice this core property of time for the sake of simplicity of tests.
43+
44+ So we should be prepared for the consequences when all hell breaks loose. E.g.:
45+ the callbacks and other events triggering before they were set up (clock-wise);
46+ the durations of activities being negative; so on.
47+
48+ Either way, we set the loop time as requested, but with a few nuances:
49+
50+ 1. If the start time is NOT explicitly defined, for higher-scoped event loops,
51+ we keep the time as is for every test and let it flow monotonically.
52+ Previously, the higher-scoped fixtures did not exist, so nothing breaks.
53+
54+ 2. If the start time is explicitly defined and is in the future, move the time
55+ forwards as specified — indistinguishable from the previous behaviour
56+ (except there could be artifacts from the previous tests in the loop).
57+
58+ 3. If the start time is explicitly defined and is in the past, issue a warning
59+ of class ``looptime.TimeWarning``, which inherits from ``UserWarning``,
60+ indicating a user-side misbehaviour & broken test-suite design.
61+ It can be configured to raise an error (strict mode), or be ignored.
62+
63+ This ensures the most possible backwards compatibility with the old behavior
64+ with a few truthworthy assumptions in mind:
65+
66+ - Fixtures do not measure time and do not rely on time. Their purpose should be
67+ preparing the environment, filling the data. Only the tests can move the time.
68+ As such, they will not suffer much from the backward time movements.
69+
70+ - Old-style tests typically use the function scope & the function-scoped loop,
71+ which has the time set at 0 by default. No changes to the previous behaviour.
72+
73+ - New-style tests that run in higher-scoped loops (a new pytest-asyncio feature)
74+ should not rely on an isolated event loop and the time starting with 0,
75+ and should be clearly prepared for the backward time movements
76+ if they express the intention to reset the start time of the event loop.
77+ Such tests should measure the "since" and "till" and assert on the difference.
78+
79+
80+ IMPLEMENTATION DETAIL — patching always, activating on-demand
81+ =============================================================
82+
83+ In order to make event loops compatible with looptime, they (the event loops)
84+ MUST be patched at creation, not in the middle of a runtime when it reaches
85+ the looptime-enabled tests (consider a global session-scoped event loop here).
86+
87+ Therefore, we patch ALL the implicit event loops of pytest-asyncio, regardless
88+ of whether they are supposed to be used or not. They are disabled (inactive)
89+ initally, i.e. their time flows normally, using the wall-clock (true) time.
90+
91+ We then activate the looptime magic on demand for those tests that need it,
92+ and only when needed (i.e. when requested/configured/marked).
93+
94+ We only activate the time magic on the running loop of the test, and only
95+ during the test execution. We do not compact the time of the event loops
96+ used in fixtures, even when the fixtures use the same-scoped event loop.
97+
98+ (This might be a breaking change. See the assumptions above for the rationale.)
99+
100+ Even for the lowest "function" scope, we cannot patch-and-activate it only once
101+ at creation, since at the time of the event loop setup (creation),
102+ we do not know which event loop will be the running loop of the test.
103+ This affects to which loop the configured options should be applied.
104+
105+ We only know this when we reach the test.
106+ We then apply the options, and activate the pre-patched running event loop.
107+ """
1108from __future__ import annotations
2109
3110import asyncio
4- from typing import Any , cast
111+ import warnings
112+ from typing import Any
5113
114+ import _pytest .nodes
6115import pytest
7116
8117from looptime import loops , patchers , timeproxies
9118
10119
11- @pytest .fixture ()
12- def looptime () -> timeproxies .LoopTimeProxy :
120+ # Critical implementation details: It MUST be sync! It CANNOT be async!
121+ # It might seem that the easiest way to implement the ``looptime`` fixture is
122+ # to make it ``async def`` and get the running loop inside. This does NOT work.
123+ # When a function-scoped fixture is used in any higher-scoped test, it degrades
124+ # the test from its scope to the function scope and breaks the test design.
125+ # See an example at https://github.com/pytest-dev/pytest-asyncio/issues/1142.
126+ # As such, the fixture MUST be synchronous (simple ``def``). As a result,
127+ # the fixture CANNOT get a running loop, because there is no running loop.
128+ # We take the running loop inside the time-measuring proxy at runtime.
129+ @pytest .fixture
130+ def looptime (request : pytest .FixtureRequest ) -> timeproxies .LoopTimeProxy :
131+ """
132+ The event loop time for assertions.
133+
134+ The fixture's numeric value is the loop time as a number of seconds
135+ since the "time zero", which is usuaully the creation time
136+ of the event loop, but can be adjusted by the ``start=…`` option.
137+
138+ - It can be used in assertions & comparisons (``==``, ``<=``, etc).
139+ - It can also be used in simple math (additions, substractions, etc).
140+ - It can be converted to ``int()`` or ``float()``.
141+
142+ It is an equivalent of a more wordy ``asyncio.get_running_loop().time()``.
143+ """
13144 return timeproxies .LoopTimeProxy ()
14145
15146
@@ -25,26 +156,161 @@ def pytest_addoption(parser: Any) -> None:
25156 help = "Run unmarked tests with the fake loop time by default." )
26157
27158
159+ EventLoopScopes = dict [str , list [str ]] # {fixture_name -> [outer_scopes, …, innermost_scope]}
160+ EVENT_LOOP_SCOPES = pytest .StashKey [EventLoopScopes ]()
161+
162+
28163@pytest .hookimpl (wrapper = True )
29- def pytest_fixture_setup (fixturedef : Any , request : Any ) -> Any :
164+ def pytest_fixture_setup (fixturedef : pytest .FixtureDef [Any ], request : pytest .FixtureRequest ) -> Any :
165+ # Setup as usual. We do the magic only afterwards, when we have the event loop created.
30166 result = yield
31167
168+ # Only do the magic if in the area of our interest & only for fixtures making the event loops.
169+ if _should_patch (fixturedef , request ) and isinstance (result , asyncio .BaseEventLoop ):
170+
171+ # Populate the helper mapper of names-to-scopes, as used in the test hook below.
172+ if EVENT_LOOP_SCOPES not in request .session .stash :
173+ request .session .stash [EVENT_LOOP_SCOPES ] = {}
174+ event_loop_scopes : EventLoopScopes = request .session .stash [EVENT_LOOP_SCOPES ]
175+ event_loop_scopes .setdefault (fixturedef .argname , []).append (fixturedef .scope )
176+
177+ # Patch the event loop at creation — even if unused and not enabled. We cannot patch later
178+ # in the middle of the run: e.g. for a session-scoped loop used in a few tests out of many.
179+ # NB: For the lowest "function" scope, we still cannot decide which options to use, since
180+ # we do not know yet if it will be the running loop or not — so we cannot optimize here
181+ # in order to patch-and-configure only once; we must patch here & configure+activate later.
182+ result = patchers .patch_event_loop (result , _enabled = False )
183+
184+ return result
185+
186+
187+ @pytest .hookimpl (wrapper = True )
188+ def pytest_fixture_post_finalizer (fixturedef : pytest .FixtureDef [Any ], request : pytest .FixtureRequest ) -> Any :
189+ # Cleanup the helper mapper of the fixture's names-to-scopes, as used in the test-running hook.
190+ # Internal consistency check: some cases should not happen, but we do not fail if they do.
191+ if EVENT_LOOP_SCOPES in request .session .stash :
192+ event_loop_scopes : EventLoopScopes = request .session .stash [EVENT_LOOP_SCOPES ]
193+ if fixturedef .argname not in event_loop_scopes :
194+ warnings .warn (
195+ f"Fixture { fixturedef .argname !r} not found in the cache of scopes."
196+ f" Report as a bug, please add a reproducible snippet." ,
197+ RuntimeWarning ,
198+ )
199+ elif not event_loop_scopes [fixturedef .argname ]:
200+ warnings .warn (
201+ f"Fixture { fixturedef .argname !r} has the empty cache of scopes."
202+ f" Report as a bug, please add a reproducible snippet." ,
203+ RuntimeWarning ,
204+ )
205+ elif event_loop_scopes [fixturedef .argname ][- 1 ] != fixturedef .scope :
206+ warnings .warn (
207+ f"Fixture { fixturedef .argname !r} has the broken cache of scopes:"
208+ f" { event_loop_scopes [fixturedef .argname ]!r} , expecting { fixturedef .scope !r} "
209+ f" Report as a bug, please add a reproducible snippet." ,
210+ RuntimeWarning ,
211+ )
212+ else :
213+ event_loop_scopes [fixturedef .argname ][- 1 :] = []
214+
215+ # Go as usual.
216+ return (yield )
217+
218+
219+ # This hook is the latest (deepest) possible entrypoint before diving into the test function itself,
220+ # with all the fixtures executed earlier, so that their setup time is not taken into account.
221+ # Here, we know the actual running loop (out of many) chosen by pytest-asyncio & its marks/configs.
222+ # The alternatives to consider — the subtle differences are unclear to me for now:
223+ # - pytest_pyfunc_call(pyfuncitem)
224+ # - pytest_runtest_call(item)
225+ # - pytest_runtest_setup(item), wrapped as @hookimpl(trylast=True)
226+ @pytest .hookimpl (wrapper = True )
227+ def pytest_pyfunc_call (pyfuncitem : pytest .Function ) -> Any :
228+
229+ # Get the running loop from the pre-populated & pre-resolved fixtures (done in the setup stage).
230+ # This includes all the auto-used fixtures, but NOT the dynamic `getfixturevalue(…)` ones.
231+ # Alternatively, use the private `pyfuncitem._request.getfixturevalue(…)`, though this is hacky.
232+ funcargs : dict [str , Any ] = pyfuncitem .funcargs
233+ if 'event_loop_policy' in funcargs : # pytest-asyncio>=1.0.0
234+ # This can be ANY event loop of ANY declared scope of pytest-asyncio.
235+ policy : asyncio .AbstractEventLoopPolicy = funcargs ['event_loop_policy' ]
236+ running_loop = policy .get_event_loop ()
237+ elif 'event_loop' in funcargs : # pytest-asyncio<1.0.0
238+ # The hook itself has NO "running" loop — because it is sync, not async.
239+ running_loop = funcargs ['event_loop' ]
240+ else : # not pytest-asyncio? not our business!
241+ return (yield )
242+
243+ # The event loop is not patched? We are doomed to fail, so let it run somehow on its own.
244+ # This might happen if the custom event loop policy was set not by pytest-asyncio.
245+ if not isinstance (running_loop , loops .LoopTimeEventLoop ):
246+ return (yield )
247+
248+ # If not enabled/enforced for this test, even if the event loop is patched, let it run as usual.
249+ options : dict [str , Any ] | None = _get_options (pyfuncitem )
250+ if options is None :
251+ return (yield )
252+
253+ # Finally, if enabled/enforced, activate the magic and run the test in the compacted time mode.
254+ # We only activate the running loop for the test, not the other event loops used in fixtures.
255+ running_loop .setup_looptime (** options )
256+ with running_loop .looptime_enabled ():
257+ return (yield )
258+
259+
260+ def _should_patch (fixturedef : pytest .FixtureDef [Any ], request : pytest .FixtureRequest ) -> bool :
261+ """
262+ Check if the fixture should be patched (in case it is an event loop).
263+
264+ Only patch the implicit (hidden) event loops and their user-side overrides.
265+ They are declared as internal with underscored names, but nevertheless.
266+ Example implicit names: ``_session_event_loop`` … ``_function_event_loop``.
267+
268+ We do not intercept arbitrary fixtures or event loops of unknown plugins.
269+ Custom event loops can be patched explicitly if needed.
270+ """
271+ # pytest-asyncio<1.0.0 exposed the specific fixture; deprecated since >=0.23.0, removed >=1.0.0.
32272 if fixturedef .argname == "event_loop" :
33- loop = cast (asyncio .BaseEventLoop , result )
34- if not isinstance (loop , loops .LoopTimeEventLoop ):
273+ return True
35274
36- # True means implicitly on; False means explicitly off; None means "only if marked".
37- option : bool | None = request .config .getoption ('looptime' )
275+ # pytest-asyncio>=1.0.0 exposes several event loops, one per scope, all hidden in the module.
276+ asyncio_plugin = request .config .pluginmanager .getplugin ("asyncio" ) # a module object
277+ asyncio_names : set [str ] = {
278+ name for name in dir (asyncio_plugin ) if _is_fixture (getattr (asyncio_plugin , name ))
279+ }
280+ asyncio_module = asyncio_plugin .__name__
281+ fixture_module = fixturedef .func .__module__
282+ should_patch = fixture_module == asyncio_module or fixturedef .argname in asyncio_names
283+ return should_patch
38284
39- markers = list (request .node .iter_markers ('looptime' ))
40- enabled = bool ((markers or option is True ) and option is not False ) # but not None!
41- options = {}
42- for marker in reversed (markers ):
43- options .update (marker .kwargs )
44- enabled = bool (marker .args [0 ]) if marker .args else enabled
45285
46- result = patched_loop = patchers . patch_event_loop ( loop , _enabled = False )
47- if enabled :
48- patched_loop . setup_looptime ( ** options , _enabled = True )
286+ def _is_fixture ( obj : Any ) -> bool :
287+ # Any of these internal names can be moved or renamed any time. Do our best to guess.
288+ import _pytest . fixtures
49289
50- return result
290+ try :
291+ if isinstance (obj , _pytest .fixtures .FixtureFunctionDefinition ):
292+ return True
293+ except AttributeError :
294+ pass
295+ try :
296+ if isinstance (obj , _pytest .fixtures .FixtureFunctionMarker ):
297+ return True
298+ except AttributeError :
299+ pass
300+ return False
301+
302+
303+ def _get_options (node : _pytest .nodes .Node ) -> dict [str , Any ] | None :
304+ """Combine all the declared looptime options; None for disabled."""
305+
306+ # True means implicitly on; False means explicitly off; None means "only if marked".
307+ flag : bool | None = node .config .getoption ('looptime' )
308+
309+ markers = list (node .iter_markers ('looptime' ))
310+ enabled : bool = bool ((markers or flag is True ) and not flag is False )
311+ options : dict [str , Any ] = {}
312+ for marker in reversed (markers ):
313+ options .update (marker .kwargs )
314+ enabled = bool (marker .args [0 ]) if marker .args else enabled
315+
316+ return options if enabled else None
0 commit comments