|
88 | 88 | of whether they are supposed to be used or not. They are disabled (inactive) |
89 | 89 | initally, i.e. their time flows normally, using the wall-clock (true) time. |
90 | 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). |
| 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). |
93 | 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.) |
| 94 | +Previously, the event loops remained unpatched if looptime was not enabled |
| 95 | +on a test. |
99 | 96 |
|
100 | 97 | Even for the lowest "function" scope, we cannot patch-and-activate it only once |
101 | 98 | at creation, since at the time of the event loop setup (creation), |
102 | 99 | 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 | +
|
104 | 108 |
|
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 | 109 | """ |
108 | 110 | from __future__ import annotations |
109 | 111 |
|
@@ -240,19 +242,43 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Any: |
240 | 242 | else: # not pytest-asyncio? not our business! |
241 | 243 | return (yield) |
242 | 244 |
|
| 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 | + |
243 | 269 | # The event loop is not patched? We are doomed to fail, so let it run somehow on its own. |
244 | 270 | # This might happen if the custom event loop policy was set not by pytest-asyncio. |
245 | 271 | if not isinstance(running_loop, loops.LoopTimeEventLoop): |
246 | 272 | return (yield) |
247 | 273 |
|
248 | 274 | # 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: |
251 | 277 | return (yield) |
252 | 278 |
|
253 | 279 | # Finally, if enabled/enforced, activate the magic and run the test in the compacted time mode. |
254 | 280 | # 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]) |
256 | 282 | with running_loop.looptime_enabled(): |
257 | 283 | return (yield) |
258 | 284 |
|
@@ -300,17 +326,39 @@ def _is_fixture(obj: Any) -> bool: |
300 | 326 | return False |
301 | 327 |
|
302 | 328 |
|
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]) |
305 | 351 |
|
306 | 352 | # True means implicitly on; False means explicitly off; None means "only if marked". |
307 | 353 | flag: bool | None = node.config.getoption('looptime') |
308 | 354 |
|
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 | + } |
315 | 363 |
|
316 | | - return options if enabled else None |
| 364 | + return options |
0 commit comments