From 00e1879652b07fa29c8ab791650a3c95b3744bad Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 12:56:02 +0300 Subject: [PATCH] fix: reject a provider named like the container fixture in expose() --- README.md | 5 +++-- modern_di_pytest/factory.py | 16 ++++++++++++++-- tests/test_expose.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index df46439..0f32be8 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,9 @@ assign it to a module-level name and pytest will collect it. Walk each ``Group`` subclass in ``groups`` and install one pytest fixture per Provider class attribute onto the caller's module. Fixture names equal the class-attribute names. Non-Provider class attributes are skipped. A duplicate -attribute name across groups raises ``ValueError``, and calling it with no -groups raises ``TypeError``. Pass ``module=`` explicitly when stack +attribute name across groups raises ``ValueError``, as does a provider named +the same as ``container_fixture``. Calling it with no groups raises +``TypeError``. Pass ``module=`` explicitly when stack introspection cannot identify the caller. ## 📦 [PyPI](https://pypi.org/project/modern-di-pytest) diff --git a/modern_di_pytest/factory.py b/modern_di_pytest/factory.py index cbaadc8..f6840fb 100644 --- a/modern_di_pytest/factory.py +++ b/modern_di_pytest/factory.py @@ -54,7 +54,10 @@ def _fixture(request: pytest.FixtureRequest) -> typing.Any: # noqa: ANN401 return _fixture -def _collect_fixtures(*groups: type[Group]) -> dict[str, AbstractProvider[typing.Any]]: +def _collect_fixtures( + *groups: type[Group], + container_fixture: str = "di_container", +) -> dict[str, AbstractProvider[typing.Any]]: """Decide which Providers to expose and under what names. Pure: asks each group for its named providers and returns a @@ -63,6 +66,7 @@ def _collect_fixtures(*groups: type[Group]) -> dict[str, AbstractProvider[typing - ``TypeError`` if no groups are given. - ``ValueError`` if a name is claimed by more than one group. + - ``ValueError`` if a name equals ``container_fixture``. """ if not groups: msg = "expose() requires at least one Group." @@ -72,6 +76,13 @@ def _collect_fixtures(*groups: type[Group]) -> dict[str, AbstractProvider[typing source: dict[str, type[Group]] = {} for group in groups: for attr_name, provider in group.get_named_providers().items(): + if attr_name == container_fixture: + msg = ( + f"expose() cannot install {attr_name!r} from {group.__name__}: " + f"it would replace the container fixture {container_fixture!r}. " + "Rename the provider or pass a different container_fixture." + ) + raise ValueError(msg) if attr_name in source: prior = source[attr_name] msg = ( @@ -99,6 +110,7 @@ def expose( Providers. Attribute names must be unique across all groups; a duplicate raises ``ValueError``. container_fixture: Name of the pytest fixture yielding the container. + A provider with this name raises ``ValueError``. pytest_scope: pytest fixture scope applied to every generated fixture. module: Module to install fixtures onto. Defaults to the caller's module (located via ``inspect.stack()``). @@ -116,7 +128,7 @@ def expose( """ # Resolve the full set of fixtures (and surface any error) before touching # the module, so a collision leaves the target untouched. - providers = _collect_fixtures(*groups) + providers = _collect_fixtures(*groups, container_fixture=container_fixture) if module is None: frame = inspect.stack()[1].frame diff --git a/tests/test_expose.py b/tests/test_expose.py index 135d05a..2fef6dc 100644 --- a/tests/test_expose.py +++ b/tests/test_expose.py @@ -67,3 +67,36 @@ def test_expose_raises_when_module_cannot_be_determined() -> None: # RuntimeError branch in expose(). with pytest.raises(RuntimeError, match="could not determine the caller module"): exec(compile(src, "", "exec"), {}) # noqa: S102 + + +def test_expose_rejects_provider_named_like_the_container_fixture() -> None: + class Shadowing(Group): + di_container = providers.Factory(scope=Scope.APP, creator=Repo) + + target = types.ModuleType("_target") + with pytest.raises(ValueError, match=r"Shadowing.*'di_container'.*container_fixture"): + expose(Dependencies, Shadowing, module=target) + + assert not hasattr(target, "di_container") + assert not hasattr(target, "repo") + + +def test_expose_rejects_provider_named_like_a_custom_container_fixture() -> None: + class Shadowing(Group): + my_container = providers.Factory(scope=Scope.APP, creator=Repo) + + target = types.ModuleType("_target") + with pytest.raises(ValueError, match=r"Shadowing.*'my_container'.*container_fixture"): + expose(Shadowing, container_fixture="my_container", module=target) + + assert not hasattr(target, "my_container") + + +def test_expose_allows_provider_named_like_the_default_when_container_fixture_differs() -> None: + class Named(Group): + di_container = providers.Factory(scope=Scope.APP, creator=Repo) + + target = types.ModuleType("_target") + expose(Named, container_fixture="my_container", module=target) + + assert hasattr(target, "di_container")