From 783c9aebac4070999c6f4bd3d1cceca03327ded6 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Wed, 24 Dec 2025 16:16:12 +0100 Subject: [PATCH 1/4] Drop the unused state management in the pytest plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a draft for the case when the fixture's scope is stacked — in case the same-named fixture is initialised several times at different scopes (which is wrong by itself). This approach was never implemented, but the code leftovers sneaked into the codebase: the state of fixtures was persisted, but never used. Signed-off-by: Sergey Vasilyev --- Additionally, there is a bug in pytest since 2019 (6 years), which prevents using this approach: https://github.com/pytest-dev/pytest/issues/5848 — the post-finalizer hook is called multiple times, depending on the number of dependencies of the fixture itself. The fixture finalizer is executed only once, as expected. Either way, this prevents proper accumulation of the stack-like structures based on the number of calls. In practice, this led to errors like "RuntimeWarning: Fixture 'unused_tcp_port' not found in the cache of scopes" — for all the fixtures of pytest-asyncio specifically — because the stack item was added once, but then removed 2+ times from the stack. It is easier to drop the assumption that the same-named fixture can under some circumstances be initialized twice at the same time (overlapping). This makes the post-finalizer hook unneeded entirely. --- A reminder though: this does NOT help put the fixtures under any kind of automated time compaction mode: while the fixture setup can be wrapped properly (because there is a proper hook), the fixture teardown cannot (because there is no hook for the finalizer — only for the post-finalizer, which is too late). Signed-off-by: Sergey Vasilyev --- looptime/plugin.py | 54 +++++----------------------------------------- 1 file changed, 5 insertions(+), 49 deletions(-) diff --git a/looptime/plugin.py b/looptime/plugin.py index e654a61..02e736f 100644 --- a/looptime/plugin.py +++ b/looptime/plugin.py @@ -157,10 +157,6 @@ def pytest_addoption(parser: Any) -> None: help="Run unmarked tests with the fake loop time by default.") -EventLoopScopes = dict[str, list[str]] # {fixture_name -> [outer_scopes, …, innermost_scope]} -EVENT_LOOP_SCOPES = pytest.StashKey[EventLoopScopes]() - - @pytest.hookimpl(wrapper=True) def pytest_fixture_setup(fixturedef: pytest.FixtureDef[Any], request: pytest.FixtureRequest) -> Any: # Setup as usual. We do the magic only afterwards, when we have the event loop created. @@ -182,19 +178,12 @@ def pytest_fixture_setup(fixturedef: pytest.FixtureDef[Any], request: pytest.Fix else: is_bp_runner = isinstance(result, bp_Runner) + # Patch the event loop at creation — even if unused and not enabled. We cannot patch later + # in the middle of the run: e.g. for a session-scoped loop used in a few tests out of many. + # NB: For the lowest "function" scope, we still cannot decide which options to use, since + # we do not know yet if it will be the running loop or not — so we cannot optimize here + # in order to patch-and-configure only once; we must patch here & configure+activate later. if should_patch and (is_loop or is_runner or is_bp_runner): - - # Populate the helper mapper of names-to-scopes, as used in the test hook below. - if EVENT_LOOP_SCOPES not in request.session.stash: - request.session.stash[EVENT_LOOP_SCOPES] = {} - event_loop_scopes: EventLoopScopes = request.session.stash[EVENT_LOOP_SCOPES] - event_loop_scopes.setdefault(fixturedef.argname, []).append(fixturedef.scope) - - # Patch the event loop at creation — even if unused and not enabled. We cannot patch later - # in the middle of the run: e.g. for a session-scoped loop used in a few tests out of many. - # NB: For the lowest "function" scope, we still cannot decide which options to use, since - # we do not know yet if it will be the running loop or not — so we cannot optimize here - # in order to patch-and-configure only once; we must patch here & configure+activate later. if isinstance(result, asyncio.BaseEventLoop): patchers.patch_event_loop(result, _enabled=False) elif sys.version_info >= (3, 11) and isinstance(result, asyncio.Runner): @@ -212,39 +201,6 @@ def pytest_fixture_setup(fixturedef: pytest.FixtureDef[Any], request: pytest.Fix return result -@pytest.hookimpl(wrapper=True) -def pytest_fixture_post_finalizer(fixturedef: pytest.FixtureDef[Any], request: pytest.FixtureRequest) -> Any: - # Cleanup the helper mapper of the fixture's names-to-scopes, as used in the test-running hook. - # Internal consistency check: some cases should not happen, but we do not fail if they do. - should_patch = _should_patch(fixturedef, request) - if should_patch and EVENT_LOOP_SCOPES in request.session.stash: - event_loop_scopes: EventLoopScopes = request.session.stash[EVENT_LOOP_SCOPES] - if fixturedef.argname not in event_loop_scopes: - warnings.warn( - f"Fixture {fixturedef.argname!r} not found in the cache of scopes." - f" Report as a bug, please add a reproducible snippet.", - RuntimeWarning, - ) - elif not event_loop_scopes[fixturedef.argname]: - warnings.warn( - f"Fixture {fixturedef.argname!r} has the empty cache of scopes." - f" Report as a bug, please add a reproducible snippet.", - RuntimeWarning, - ) - elif event_loop_scopes[fixturedef.argname][-1] != fixturedef.scope: - warnings.warn( - f"Fixture {fixturedef.argname!r} has the broken cache of scopes:" - f" {event_loop_scopes[fixturedef.argname]!r}, expecting {fixturedef.scope!r}" - f" Report as a bug, please add a reproducible snippet.", - RuntimeWarning, - ) - else: - event_loop_scopes[fixturedef.argname][-1:] = [] - - # Go as usual. - return (yield) - - # This hook is the latest (deepest) possible entrypoint before diving into the test function itself, # with all the fixtures executed earlier, so that their setup time is not taken into account. # Here, we know the actual running loop (out of many) chosen by pytest-asyncio & its marks/configs. From 6a1ef9f4457d2cd2dcb99fa36d2760c7948e3427 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Wed, 24 Dec 2025 16:23:47 +0100 Subject: [PATCH 2/4] Clarify several fuzzy places Signed-off-by: Sergey Vasilyev --- looptime/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/looptime/plugin.py b/looptime/plugin.py index 02e736f..0bb8986 100644 --- a/looptime/plugin.py +++ b/looptime/plugin.py @@ -19,7 +19,7 @@ - A single event loop can be shared by multiple (but not all) tests. - A single test can be spread over multiple (but not all) event loops. -An classic example: +A classic example: - A session-scoped fixture ``server`` starts a port listener & an HTTP server. - A module-scoped fixture ``data`` populates the server via POST requests. @@ -52,7 +52,7 @@ Previously, the higher-scoped fixtures did not exist, so nothing breaks. 2. If the start time is explicitly defined and is in the future, move the time - forwards as specified — indistinguishable from the previous behaviour + forward as specified — indistinguishable from the previous behaviour (except there could be artifacts from the previous tests in the loop). 3. If the start time is explicitly defined and is in the past, issue a warning @@ -261,6 +261,9 @@ def _should_patch(fixturedef: pytest.FixtureDef[Any], request: pytest.FixtureReq return True # pytest-asyncio>=1.0.0 exposes several event loops, one per scope, all hidden in the module. + # We patch BOTH the default implementation, AND all those dirty hacks that users might make. + # NB: We also report True on unrelated fixtures, such as `unused_tcp_port_factory`, etc. + # This has no effect: they will not pass the extra test on being patchable loops & runners. asyncio_plugin = request.config.pluginmanager.getplugin("asyncio") # a module object asyncio_names: set[str] = { name for name in dir(asyncio_plugin) if _is_fixture(getattr(asyncio_plugin, name)) From 9e497fc3df781515016ea8d71c9b8ca092936ef4 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Wed, 24 Dec 2025 16:24:01 +0100 Subject: [PATCH 3/4] Mirate the readme to Python 3.14 compatible code Signed-off-by: Sergey Vasilyev --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96f6705..a097b46 100644 --- a/README.md +++ b/README.md @@ -180,12 +180,12 @@ async def test_me(): The markers can also be artificially injected by plugins/hooks if needed: ```python -import asyncio +import inspect import pytest @pytest.hookimpl(hookwrapper=True) def pytest_pycollect_makeitem(collector, name, obj): - if collector.funcnamefilter(name) and asyncio.iscoroutinefunction(obj): + if collector.funcnamefilter(name) and inspect.iscoroutinefunction(obj): pytest.mark.asyncio(obj) pytest.mark.looptime(end=60)(obj) yield From f292a60880f6a97c53c8704e06389f723b39877b Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Wed, 24 Dec 2025 16:24:50 +0100 Subject: [PATCH 4/4] Make dependency groups fully self-contained Signed-off-by: Sergey Vasilyev --- .github/workflows/ci.yaml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a78baf..e93e424 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.14" - - run: pip install --group dev --group lint -e . + - run: pip install --group lint -e . - run: pre-commit run --all-files - run: mypy looptime --strict diff --git a/pyproject.toml b/pyproject.toml index f2ff3fe..ce5b9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "pytest-mock", ] lint = [ + {include-group = "dev"}, "mypy==1.19.0", "pre-commit", "isort",