From 3b9094463c5da6523ac3b4285ab177ffe5a62a2c Mon Sep 17 00:00:00 2001 From: antonrh Date: Sun, 4 Oct 2026 13:04:38 +0300 Subject: [PATCH] Put the pytest plugin's container in test mode from the start Test mode was switched on only when the `container` fixture ran, so a conftest overriding through the imported container warned on every override until some test happened to request the fixture, and the number of warnings depended on test order. The container named by `anydi_container` now enters test mode in `pytest_configure`, and a global container created while the tests are collected enters it before the first fixture runs. Test mode is turned off and the replaced global container restored at session end. A container from a project's own `container` fixture still enters test mode when the fixture first runs. A new `anydi` ini flag, on by default, turns the plugin off when set to false: no `container` fixture, no injection, the container left alone. --- .github/workflows/ci.yml | 4 +- Makefile | 2 +- anydi/ext/pytest_plugin.py | 173 +++++++++++++++++++++++------- docs/usage/testing.md | 34 ++++++ tests/ext/test_pytest_plugin.py | 184 +++++++++++++++++++++++++++++++- 5 files changed, 357 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e16f7803..6a0283e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Run tests run: | uv run pytest -vv tests --cov=anydi - uv run pytest -vv tests/ext/test_pytest_plugin.py --cov=anydi --cov-append --cov-report=xml -p anydi + uv run pytest -vv tests/ext/test_pytest_plugin.py --cov=anydi --cov-append --cov-report=xml -p anydi -p pytester - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 if: matrix.python == '3.10' @@ -105,7 +105,7 @@ jobs: - name: Run tests run: | uv run --no-sync pytest tests - uv run --no-sync pytest tests/ext/test_pytest_plugin.py -p anydi + uv run --no-sync pytest tests/ext/test_pytest_plugin.py -p anydi -p pytester benchmarks: name: Run benchmarks diff --git a/Makefile b/Makefile index db9196b9..d31b05b0 100644 --- a/Makefile +++ b/Makefile @@ -15,4 +15,4 @@ fmt: ## Run code formatters test: ## Run unit tests uv run pytest -vv tests --cov=anydi - uv run pytest -vv tests/ext/test_pytest_plugin.py --cov=anydi --cov-append -p anydi + uv run pytest -vv tests/ext/test_pytest_plugin.py --cov=anydi --cov-append -p anydi -p pytester diff --git a/anydi/ext/pytest_plugin.py b/anydi/ext/pytest_plugin.py index 5744e5da..0a547a4b 100644 --- a/anydi/ext/pytest_plugin.py +++ b/anydi/ext/pytest_plugin.py @@ -24,7 +24,23 @@ logger = logging.getLogger(__name__) +CONTAINER = pytest.StashKey[Container]() +# Containers this plugin put into test mode, switched back off at session end +_ACTIVATED = pytest.StashKey[list[Container]]() +# The global container the plugin replaced, restored at session end +_REPLACED_GLOBAL = pytest.StashKey[Container | None]() + + def pytest_addoption(parser: pytest.Parser) -> None: + parser.addini( + "anydi", + help=( + "Wire the tests through the configured container: test mode, " + "the `container` fixture, injection" + ), + type="bool", + default=True, + ) parser.addini( "anydi_container", help=( @@ -42,31 +58,101 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) -@pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup( - fixturedef: pytest.FixtureDef[Any], request: SubRequest -) -> Generator[None]: - """Automatically enable test mode on the container fixture.""" - yield - if fixturedef.argname == "container" and fixturedef.cached_result is not None: +@pytest.hookimpl(trylast=True) +def pytest_configure(config: pytest.Config) -> None: + if not config.getini("anydi"): + return + config.stash[_ACTIVATED] = [] + container = _import_configured_container(config) + if container is not None: + _activate(config, container) + config.pluginmanager.register(_AnydiPlugin(), "anydi-fixtures") + + +def pytest_unconfigure(config: pytest.Config) -> None: + for container in config.stash.get(_ACTIVATED, []): + container.disable_test_mode() + if _REPLACED_GLOBAL in config.stash: + reset_global_container() + previous = config.stash[_REPLACED_GLOBAL] + if previous is not None: + set_global_container(previous) + + +class _AnydiPlugin: + """Hooks and fixtures registered only when `anydi` is enabled.""" + + @pytest.hookimpl(trylast=True) + def pytest_collection_finish(self, session: pytest.Session) -> None: + # Collection has imported the application, so a global container + # created there exists now, before any fixture can override it + config = session.config + container = config.stash.get(CONTAINER, None) or get_global_container_or_none() + if container is not None: + _activate(config, container) + _use_as_global(config, container) + + @pytest.hookimpl(hookwrapper=True) + def pytest_fixture_setup( + self, fixturedef: pytest.FixtureDef[Any], request: SubRequest + ) -> Generator[None]: + """Enable test mode on a container provided by a `container` fixture.""" + yield + if fixturedef.argname != "container" or fixturedef.cached_result is None: + return container = fixturedef.cached_result[0] - if isinstance(container, Container): - container.enable_test_mode() - # Let global references resolve against the container under test, - # without introducing a global container the application never used - if uses_global_container(): - reset_global_container() - set_global_container(container) - - -@pytest.fixture(scope="session") -def container(request: pytest.FixtureRequest) -> Container: - """Container fixture.""" - return _find_container(request) + if not isinstance(container, Container): + return + if container is request.config.stash.get(CONTAINER, None): + return + container.enable_test_mode() + # Let global references resolve against the container under test, + # without introducing a global container the application never used + if uses_global_container(): + reset_global_container() + set_global_container(container) + + @pytest.fixture(scope="session") + def container(self, request: pytest.FixtureRequest) -> Container: + """Container fixture.""" + container = request.config.stash.get(CONTAINER, None) + if container is not None: + return container + return _find_container(request) + + @pytest.fixture(autouse=True) + def _anydi_inject(self, request: pytest.FixtureRequest) -> None: + """Inject dependencies into sync test functions.""" + _inject(request) + + @pytest.fixture(autouse=True) + def _anydi_ainject(self, request: pytest.FixtureRequest) -> None: + """Inject dependencies into async test functions.""" + _ainject(request) + + +def _activate(config: pytest.Config, container: Container) -> None: + """Put the container into test mode for the rest of the session.""" + config.stash[CONTAINER] = container + # A container already in test mode belongs to whoever enabled it + if not container._test_mode: + container.enable_test_mode() + config.stash[_ACTIVATED].append(container) + + +def _use_as_global(config: pytest.Config, container: Container) -> None: + """Point global references at the container under test.""" + if not uses_global_container(): + return + previous = get_global_container_or_none() + if previous is container: + return + reset_global_container() + set_global_container(container) + config.stash.setdefault(_REPLACED_GLOBAL, previous) -@pytest.fixture(autouse=True) -def _anydi_inject(request: pytest.FixtureRequest) -> None: +def _inject(request: pytest.FixtureRequest) -> None: """Inject dependencies into sync test functions.""" if inspect.iscoroutinefunction(request.function): return @@ -86,8 +172,7 @@ def _anydi_inject(request: pytest.FixtureRequest) -> None: logger.warning("Failed to resolve '%s' for %s", name, request.node.nodeid) -@pytest.fixture(autouse=True) -def _anydi_ainject(request: pytest.FixtureRequest) -> None: +def _ainject(request: pytest.FixtureRequest) -> None: """Inject dependencies into async test functions.""" if not inspect.iscoroutinefunction( request.function @@ -190,25 +275,41 @@ def _extract_dependency_type(annotation: Any) -> tuple[Any, bool]: return annotation, False -def _find_container(request: pytest.FixtureRequest) -> Container: - """Find container from config or auto-detection.""" - container_path = cast(str | None, request.config.getini("anydi_container")) - if container_path: - try: - return import_container(container_path) - except ImportError as exc: - raise RuntimeError( - f"Failed to load container from config " - f"'anydi_container={container_path}': {exc}" - ) from exc +def _import_configured_container(config: pytest.Config) -> Container | None: + """Import the container named by `anydi_container`, if set.""" + container_path = cast(str | None, config.getini("anydi_container")) + if not container_path: + return None + try: + return import_container(container_path) + except ImportError as exc: + raise pytest.UsageError( + f"Failed to load container from config " + f"'anydi_container={container_path}': {exc}" + ) from exc + + +def _detect_container(config: pytest.Config) -> Container | None: + """Find the container from config, the global one or `anydi_django`.""" + container = _import_configured_container(config) + if container is not None: + return container global_container = get_global_container_or_none() if global_container is not None: return global_container - pluginmanager = request.config.pluginmanager + pluginmanager = config.pluginmanager if pluginmanager.hasplugin("django") and importlib.util.find_spec("anydi_django"): return import_container("anydi_django.container") + return None + + +def _find_container(request: pytest.FixtureRequest) -> Container: + """Find container from config or auto-detection.""" + container = _detect_container(request.config) + if container is not None: + return container raise pytest.FixtureLookupError( None, diff --git a/docs/usage/testing.md b/docs/usage/testing.md index 43872b3a..e7eab243 100644 --- a/docs/usage/testing.md +++ b/docs/usage/testing.md @@ -229,6 +229,40 @@ def container() -> Container: **Note:** The fixture takes priority over the configuration if both are defined. +#### Test mode + +The plugin puts the container in test mode before the first fixture runs, so a fixture can override through the container your application imports, without requesting the `container` fixture: + +```python +# conftest.py +from collections.abc import Iterator +from unittest import mock + +import pytest + +from myapp.container import container +from myapp.payments import PaymentClient + + +@pytest.fixture(scope="session", autouse=True) +def payment_client_mock() -> Iterator[mock.MagicMock]: + payment_client_mock = mock.MagicMock(spec=PaymentClient) + with container.override(PaymentClient, payment_client_mock): + yield payment_client_mock +``` + +This works for the container named by `anydi_container` and for a global container that exists once the tests are collected. The plugin turns test mode off again when the session ends. A container from your own `container` fixture enters test mode when the fixture first runs. + +#### Turning the plugin off + +The plugin is on wherever `AnyDI` is installed. To turn it off in a project, set `anydi = false`. The plugin then adds no `container` fixture, injects nothing and leaves the container untouched: + +```toml +# pyproject.toml +[tool.pytest.ini_options] +anydi = false +``` + ### Usage #### Explicit injection with `Provide[T]` diff --git a/tests/ext/test_pytest_plugin.py b/tests/ext/test_pytest_plugin.py index b1bdbb34..ece40b8c 100644 --- a/tests/ext/test_pytest_plugin.py +++ b/tests/ext/test_pytest_plugin.py @@ -4,7 +4,14 @@ import pytest -from anydi import Container, Inject, Provide, get_global_container, global_ref +from anydi import ( + Container, + Inject, + Provide, + get_global_container, + get_global_container_or_none, + global_ref, +) from anydi.ext import pytest_plugin if "-p" not in sys.argv or "anydi" not in sys.argv: @@ -250,3 +257,178 @@ def test_global_ref_follows_override(container: Container) -> None: assert global_service.name == "overridden" assert global_service.name == "service" + + +_APP = """ +from anydi import Container + + +class Repository: + pass + + +container = Container() +container.register(Repository) +""" + +_OVERRIDING_CONFTEST = """ +from unittest import mock + +import pytest + +from app import Repository, container + + +@pytest.fixture(scope="session", autouse=True) +def session_repo(): + with container.override(Repository, mock.sentinel.session_repo): + yield + + +@pytest.fixture(autouse=True) +def function_repo(): + with container.override(Repository, mock.sentinel.function_repo): + yield +""" + + +def _write_ini(pytester: pytest.Pytester, **options: str) -> None: + lines = "\n".join(f"{key} = {value}" for key, value in options.items()) + pytester.makeini(f"[pytest]\n{lines}\n") + + +def test_disabled_plugin_adds_nothing(pytester: pytest.Pytester) -> None: + pytester.makepyfile(app=_APP) + _write_ini(pytester, anydi="false", anydi_container="app:container") + pytester.makepyfile( + test_disabled=""" + from anydi import get_global_container_or_none + from app import Repository, container + + + def test_untouched(): + assert container._test_mode is False + assert get_global_container_or_none() is None + + + def test_no_injection(repo: Repository): + pass + + + def test_no_fixture(container): + pass + """ + ) + + result = pytester.runpytest_subprocess() + + result.assert_outcomes(passed=1, errors=2) + result.stdout.fnmatch_lines(["*fixture 'repo' not found*"]) + result.stdout.fnmatch_lines(["*fixture 'container' not found*"]) + + +@pytest.mark.parametrize( + "order", + [ + ["test_a.py", "test_b.py"], + ["test_b.py", "test_a.py"], + ], +) +def test_overrides_in_test_mode_whatever_runs_first( + pytester: pytest.Pytester, order: list[str] +) -> None: + pytester.makepyfile(app=_APP, conftest=_OVERRIDING_CONFTEST) + _write_ini(pytester, anydi_container="app:container") + pytester.makepyfile( + test_a=""" + from unittest import mock + + from app import Repository + + + def test_without_fixture(repo: Repository): + assert repo is mock.sentinel.function_repo + """, + test_b=""" + from app import container as app_container + + + def test_with_fixture(container): + assert container is app_container + assert container._test_mode is True + """, + ) + + result = pytester.runpytest_subprocess("-W", "error::UserWarning", *order) + + result.assert_outcomes(passed=2) + + +def test_global_container_created_at_collection(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + app=""" + from anydi import create_global_container + + + class Repository: + pass + + + container = create_global_container() + container.register(Repository) + """ + ) + _write_ini(pytester) + pytester.makepyfile( + test_global=""" + from unittest import mock + + from app import Repository, container + + + def test_override(): + assert container._test_mode is True + with container.override(Repository, mock.sentinel.repo): + assert container.resolve(Repository) is mock.sentinel.repo + """ + ) + + result = pytester.runpytest_subprocess("-W", "error::UserWarning") + + result.assert_outcomes(passed=1) + + +def test_invalid_container_path(pytester: pytest.Pytester) -> None: + _write_ini(pytester, anydi_container="missing.module:container") + pytester.makepyfile(test_any="def test_any(): pass") + + result = pytester.runpytest_subprocess() + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines(["*anydi_container=missing.module:container*"]) + + +def test_test_mode_off_after_session( + pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + probe = Container() + module = type(sys)("anydi_probe") + module.__dict__["container"] = probe + monkeypatch.setitem(sys.modules, "anydi_probe", module) + _write_ini(pytester, anydi_container="anydi_probe:container") + pytester.makepyfile( + test_probe=""" + from anydi_probe import container + + + def test_on(): + assert container._test_mode is True + """ + ) + outer_global = get_global_container_or_none() + + result = pytester.runpytest() + + result.assert_outcomes(passed=1) + assert probe._test_mode is False + assert get_global_container_or_none() is outer_global