Skip to content

Commit ec75fa1

Browse files
committed
Use the dynamically chosen event loop of pytest-asyncio>=1.0.0
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent 867dc54 commit ec75fa1

1 file changed

Lines changed: 284 additions & 18 deletions

File tree

looptime/plugin.py

Lines changed: 284 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,146 @@
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+
"""
1108
from __future__ import annotations
2109

3110
import asyncio
4-
from typing import Any, cast
111+
import warnings
112+
from typing import Any
5113

114+
import _pytest.nodes
6115
import pytest
7116

8117
from 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

Comments
 (0)