diff --git a/modern_di_pytest/factory.py b/modern_di_pytest/factory.py index c329efa..6d53874 100644 --- a/modern_di_pytest/factory.py +++ b/modern_di_pytest/factory.py @@ -56,6 +56,38 @@ def _fixture(request: pytest.FixtureRequest) -> typing.Any: # noqa: ANN401 return _fixture +def _collect_fixtures(*groups: type[Group]) -> dict[str, AbstractProvider[typing.Any]]: + """Decide which Providers to expose and under what names. + + Pure: walks each group's attributes, keeps the ``AbstractProvider``s, skips + everything else, and returns a ``name -> provider`` mapping. Raises before + returning anything so callers never act on a partial result: + + - ``TypeError`` if no groups are given. + - ``ValueError`` if a name is claimed by more than one group. + """ + if not groups: + msg = "expose() requires at least one Group." + raise TypeError(msg) + + providers: dict[str, AbstractProvider[typing.Any]] = {} + source: dict[str, type[Group]] = {} + for group in groups: + for attr_name, attr_value in vars(group).items(): + if not isinstance(attr_value, AbstractProvider): + continue + if attr_name in source: + prior = source[attr_name] + msg = ( + f"expose() cannot register {attr_name!r} from " + f"{group.__name__}: already provided by {prior.__name__}." + ) + raise ValueError(msg) + source[attr_name] = group + providers[attr_name] = attr_value + return providers + + def expose( *groups: type[Group], container_fixture: str = "di_container", @@ -86,9 +118,9 @@ def expose( named after that attribute. Non-Provider class attributes are skipped. """ - if not groups: - msg = "expose() requires at least one Group." - raise TypeError(msg) + # 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) if module is None: frame = inspect.stack()[1].frame @@ -97,23 +129,11 @@ def expose( msg = "expose() could not determine the caller module; pass module=... explicitly." raise RuntimeError(msg) - registered: dict[str, type[Group]] = {} - for group in groups: - for attr_name, attr_value in vars(group).items(): - if not isinstance(attr_value, AbstractProvider): - continue - if attr_name in registered: - prior = registered[attr_name] - msg = ( - f"expose() cannot register {attr_name!r} from " - f"{group.__name__}: already provided by {prior.__name__}." - ) - raise ValueError(msg) - registered[attr_name] = group - fixture = modern_di_fixture( - attr_value, - container_fixture=container_fixture, - name=attr_name, - pytest_scope=pytest_scope, - ) - setattr(module, attr_name, fixture) + for attr_name, provider in providers.items(): + fixture = modern_di_fixture( + provider, + container_fixture=container_fixture, + name=attr_name, + pytest_scope=pytest_scope, + ) + setattr(module, attr_name, fixture) diff --git a/tests/test_collect_fixtures.py b/tests/test_collect_fixtures.py new file mode 100644 index 0000000..f82f51f --- /dev/null +++ b/tests/test_collect_fixtures.py @@ -0,0 +1,46 @@ +"""Tests for the pure decision behind ``expose``: ``_collect_fixtures``. + +These exercise the discovery, skip, and collision rules directly through the +seam's return value — no module installation, no throwaway modules. +""" + +import pytest +from modern_di import Group, Scope, providers + +from modern_di_pytest.factory import _collect_fixtures +from tests.sample import Dependencies, ExtraDependencies + + +def test_collects_providers_by_name() -> None: + collected = _collect_fixtures(Dependencies) + + assert set(collected) == {"repo", "service", "request_widget"} + assert collected["repo"] is Dependencies.repo + assert collected["service"] is Dependencies.service + + +def test_skips_non_provider_attributes() -> None: + collected = _collect_fixtures(Dependencies) + + assert "not_a_provider" not in collected + assert "_hidden_int" not in collected + + +def test_collects_across_multiple_groups() -> None: + collected = _collect_fixtures(Dependencies, ExtraDependencies) + + assert "extra_repo" in collected + assert collected["extra_repo"] is ExtraDependencies.extra_repo + + +def test_raises_on_duplicate_name_across_groups() -> None: + class Colliding(Group): + repo = providers.Factory(scope=Scope.APP, creator=object) + + with pytest.raises(ValueError, match=r"'repo'.*Colliding.*Dependencies"): + _collect_fixtures(Dependencies, Colliding) + + +def test_raises_when_called_with_no_groups() -> None: + with pytest.raises(TypeError, match="at least one Group"): + _collect_fixtures() diff --git a/tests/test_expose.py b/tests/test_expose.py index aa51b28..135d05a 100644 --- a/tests/test_expose.py +++ b/tests/test_expose.py @@ -1,43 +1,69 @@ -import sys +"""Integration tests for ``expose``: real fixtures resolved through a container. + +The pure discovery/collision rules live in ``test_collect_fixtures.py``; +request-scope lives in ``test_expose_request_scope.py`` (kept separate because +its ``request_widget`` fixture name would collide with the install below). +""" + import types import pytest from modern_di import Group, Scope, providers from modern_di_pytest import expose -from tests.sample import Dependencies, Repo, Service +from tests.sample import Dependencies, ExtraDependencies, Repo, Service -expose(Dependencies) +# Default usage: introspect the caller module and install every provider across +# both groups (no name collisions between them). +expose(Dependencies, ExtraDependencies) -def test_expose_generates_repo_fixture(repo: Repo) -> None: +def test_expose_resolves_repo(repo: Repo) -> None: assert isinstance(repo, Repo) assert repo.label == "real" -def test_expose_generates_service_fixture(service: Service) -> None: +def test_expose_resolves_service(service: Service) -> None: assert isinstance(service, Service) assert isinstance(service.repo, Repo) -def test_expose_skips_non_provider_attributes() -> None: - this_module = sys.modules[__name__] +def test_expose_resolves_fixture_from_second_group(extra_repo: Repo) -> None: + assert isinstance(extra_repo, Repo) + + +def test_expose_installs_into_explicit_module() -> None: + """``module=`` routes fixture installation to the given module.""" + target = types.ModuleType("_target") - assert not hasattr(this_module, "not_a_provider") - assert not hasattr(this_module, "_hidden_int") + expose(Dependencies, module=target) + assert hasattr(target, "repo") + assert hasattr(target, "service") + + +def test_expose_leaves_module_untouched_on_collision() -> None: + """A collision raises before any fixture is installed (atomicity).""" -def test_expose_raises_on_collision_between_groups() -> None: class Colliding(Group): repo = providers.Factory(scope=Scope.APP, creator=Repo) - throwaway = types.ModuleType("_throwaway") - with pytest.raises(ValueError, match=r"'repo'.*Colliding.*Dependencies"): - expose(Dependencies, Colliding, module=throwaway) + target = types.ModuleType("_target") + with pytest.raises(ValueError, match="'repo'"): + expose(Dependencies, Colliding, module=target) + + # None of Dependencies' providers should have been installed, even though + # Dependencies is processed before the colliding Colliding group. + assert not hasattr(target, "repo") + assert not hasattr(target, "service") + assert not hasattr(target, "request_widget") -def test_expose_raises_when_called_with_no_groups() -> None: - throwaway = types.ModuleType("_throwaway") - with pytest.raises(TypeError, match="at least one Group"): - expose(module=throwaway) +def test_expose_raises_when_module_cannot_be_determined() -> None: + """expose() called from exec() has no caller module; it should raise.""" + src = "from modern_di_pytest import expose\nfrom tests.sample import Dependencies\nexpose(Dependencies)\n" + # exec'd code has no module per inspect.getmodule, which forces the + # RuntimeError branch in expose(). + with pytest.raises(RuntimeError, match="could not determine the caller module"): + exec(compile(src, "", "exec"), {}) # noqa: S102 diff --git a/tests/test_expose_explicit_module.py b/tests/test_expose_explicit_module.py deleted file mode 100644 index 5c59511..0000000 --- a/tests/test_expose_explicit_module.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys - -import pytest - -from modern_di_pytest import expose -from tests.sample import Dependencies, Repo - - -# Use the explicit `module=` form rather than letting expose() inspect the -# call stack. This proves the introspection path is optional. -expose(Dependencies, module=sys.modules[__name__]) - - -def test_repo_via_explicit_module(repo: Repo) -> None: - assert isinstance(repo, Repo) - - -def test_expose_raises_when_module_cannot_be_determined() -> None: - """expose() called from exec() has no module; it should raise RuntimeError.""" - src = "from modern_di_pytest import expose\nfrom tests.sample import Dependencies\nexpose(Dependencies)\n" - # exec'd code has no module per inspect.getmodule, which forces the - # RuntimeError branch in expose(). - with pytest.raises(RuntimeError, match="could not determine the caller module"): - exec(compile(src, "", "exec"), {}) # noqa: S102 diff --git a/tests/test_expose_multiple_groups.py b/tests/test_expose_multiple_groups.py deleted file mode 100644 index bbff4dc..0000000 --- a/tests/test_expose_multiple_groups.py +++ /dev/null @@ -1,13 +0,0 @@ -from modern_di_pytest import expose -from tests.sample import Dependencies, ExtraDependencies, Repo - - -expose(Dependencies, ExtraDependencies) - - -def test_first_group_fixture(repo: Repo) -> None: - assert isinstance(repo, Repo) - - -def test_second_group_fixture(extra_repo: Repo) -> None: - assert isinstance(extra_repo, Repo) diff --git a/tests/test_expose_request_scope.py b/tests/test_expose_request_scope.py index 5f40673..6be87e7 100644 --- a/tests/test_expose_request_scope.py +++ b/tests/test_expose_request_scope.py @@ -2,7 +2,9 @@ from tests.sample import Dependencies, Widget -# Generate every provider as a fixture, but resolved from the request container. +# Kept as its own module: this installs a `request_widget` fixture whose name +# collides with the one in test_expose.py, so the two configurations cannot +# share a module. Also exercises the module-introspection path at REQUEST scope. expose(Dependencies, container_fixture="di_request_container")