Skip to content

Commit 828a109

Browse files
committed
Patch & tweak multiple simultaneous event loops of pytest-asyncio>=1.0.0
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent a7105f1 commit 828a109

1 file changed

Lines changed: 70 additions & 22 deletions

File tree

looptime/plugin.py

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,24 @@
8888
of whether they are supposed to be used or not. They are disabled (inactive)
8989
initally, i.e. their time flows normally, using the wall-clock (true) time.
9090
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).
91+
We then activate the looptime magic on demand for those tests & those scopes
92+
that need it, and only when needed (i.e. when requested/configured/marked).
9393
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.)
94+
Previously, the event loops remained unpatched if looptime was not enabled
95+
on a test.
9996
10097
Even for the lowest "function" scope, we cannot patch-and-activate it only once
10198
at creation, since at the time of the event loop setup (creation),
10299
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.
100+
This affects which options to apply:
101+
102+
- One of the named scoped (session-package-module-class-function);
103+
- ``None`` as the pseudo-scope for the running loop.
104+
105+
We only know this when we reach the test. We then combine the options, apply,
106+
and activate the patched event loop.
107+
104108
105-
We only know this when we reach the test.
106-
We then apply the options, and activate the pre-patched running event loop.
107109
"""
108110
from __future__ import annotations
109111

@@ -240,19 +242,43 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Any:
240242
else: # not pytest-asyncio? not our business!
241243
return (yield)
242244

245+
# TODO: take the global flags into account? do not activate with --no-looptime!
246+
# but do activate with --looptime or looptime=true.
247+
# in this code, we activate regardless of global options — not good.
248+
# For ALL involved fixtures (incl. hidden & auto-used), apply or re-apply their scoped options.
249+
# The scopes of fixtures are remembered in the session stash when the fixtures are set up.
250+
# (There is `pyfuncitem._fixtureinfo.name2fixturedefs`, but it holds no FixtureDefs or scopes.)
251+
# NB: function-scoped event loops will be set up twice; this is fine — to make the code generic:
252+
# - First, in the fixture hook — with no options, when patched at creation.
253+
# - Second, here, in the test hook – with specific options.
254+
# This might be the 2nd setup of a function-scoped fixture, now with specific options.
255+
# For higher-scoped fixtures, this step can be repeated for every test again and again.
256+
scoped_options: dict[str | None, dict[str, Any]] = _get_options(pyfuncitem)
257+
event_loop_fixture_scopes: EventLoopScopes = pyfuncitem.session.stash.get(EVENT_LOOP_SCOPES, {})
258+
for fixture_name, fixture_value in funcargs.items():
259+
if isinstance(fixture_value, loops.LoopTimeEventLoop):
260+
if fixture_name in event_loop_fixture_scopes:
261+
scope: str = event_loop_fixture_scopes[fixture_name][-1]
262+
options: dict[str, Any] = {}
263+
if scope in scoped_options:
264+
options.update(scoped_options[scope])
265+
if None in scoped_options and fixture_value is running_loop:
266+
options.update(scoped_options[None])
267+
fixture_value.setup_looptime(**options)
268+
243269
# The event loop is not patched? We are doomed to fail, so let it run somehow on its own.
244270
# This might happen if the custom event loop policy was set not by pytest-asyncio.
245271
if not isinstance(running_loop, loops.LoopTimeEventLoop):
246272
return (yield)
247273

248274
# 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:
275+
enabled = None in scoped_options
276+
if not enabled:
251277
return (yield)
252278

253279
# Finally, if enabled/enforced, activate the magic and run the test in the compacted time mode.
254280
# We only activate the running loop for the test, not the other event loops used in fixtures.
255-
running_loop.setup_looptime(**options)
281+
running_loop.setup_looptime(**scoped_options[None])
256282
with running_loop.looptime_enabled():
257283
return (yield)
258284

@@ -300,17 +326,39 @@ def _is_fixture(obj: Any) -> bool:
300326
return False
301327

302328

303-
def _get_options(node: _pytest.nodes.Node) -> dict[str, Any] | None:
304-
"""Combine all the declared looptime options; None for disabled."""
329+
def _get_options(node: _pytest.nodes.Node) -> dict[str | None, dict[str, Any]]:
330+
"""
331+
Combine all the declared looptime options, grouped by loop scope.
332+
333+
The loop scope ``None`` is used when the loop scope is not defined,
334+
and this means the running event loop — regardless of which scope it is
335+
(typically equal to pytest-asyncio's ``loop_scope`` of the test).
336+
"""
337+
markers = list(node.iter_markers('looptime'))
338+
enabled: dict[str | None, bool] = {}
339+
options: dict[str | None, dict[str, Any]] = {}
340+
for marker in reversed(markers):
341+
# Accumulate the scope-related options separately, override with the closest markers.
342+
# The loop scope None means the running loop, which can vary, and is interpreted separately.
343+
loop_scope: str | None = marker.kwargs.pop('loop_scope', None)
344+
if loop_scope not in options:
345+
options[loop_scope] = {}
346+
options[loop_scope].update(marker.kwargs)
347+
348+
# Positional args enable/disable that loop scope, but do not reset the accumulated options.
349+
if marker.args:
350+
enabled[loop_scope] = bool(marker.args[0])
305351

306352
# True means implicitly on; False means explicitly off; None means "only if marked".
307353
flag: bool | None = node.config.getoption('looptime')
308354

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
355+
# Drop the options for scopes that are disabled with the markers, as if there are no markers.
356+
# Ensure the scopes that are not marked if there is a global flag to auto-enable looptime.
357+
scopes = ['session', 'package', 'module', 'class', 'function', None]
358+
options = {
359+
scope: options.get(scope, {})
360+
for scope in scopes
361+
if enabled.get(scope, (scope in options or flag is True) and flag is not False)
362+
}
315363

316-
return options if enabled else None
364+
return options

0 commit comments

Comments
 (0)