Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions modern_di_pytest/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand All @@ -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 = (
Expand Down Expand Up @@ -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()``).
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/test_expose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<string>", "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")
Loading