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
66 changes: 43 additions & 23 deletions modern_di_pytest/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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)
46 changes: 46 additions & 0 deletions tests/test_collect_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
60 changes: 43 additions & 17 deletions tests/test_expose.py
Original file line number Diff line number Diff line change
@@ -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, "<string>", "exec"), {}) # noqa: S102
24 changes: 0 additions & 24 deletions tests/test_expose_explicit_module.py

This file was deleted.

13 changes: 0 additions & 13 deletions tests/test_expose_multiple_groups.py

This file was deleted.

4 changes: 3 additions & 1 deletion tests/test_expose_request_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down