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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ def di_container() -> typing.Iterator[modern_di.Container]:
yield container


# Bulk: every Provider on Dependencies becomes a pytest fixture
# named after the class attribute.
# Bulk: every Provider on each group becomes a pytest fixture
# named after the class attribute. Pass several groups in one call.
expose(Dependencies)

# Manual: turn a single type or Provider into a named fixture.
Expand Down Expand Up @@ -117,11 +117,12 @@ type (resolved via ``container.resolve``) or a Provider (resolved via
``container.resolve_provider``). The returned object is a real pytest fixture
— assign it to a module-level name and pytest will collect it.

### `expose(group, *, container_fixture="di_container", pytest_scope="function", module=None)`
### `expose(*groups, container_fixture="di_container", pytest_scope="function", module=None)`

Walk ``group`` (a ``Group`` subclass) and inject one pytest fixture per
Walk each ``Group`` subclass in ``groups`` and inject one pytest fixture per
Provider class attribute into the caller's module. Fixture names equal the
class-attribute names. Non-Provider class attributes are skipped. Pass
``module=`` explicitly when stack introspection cannot identify the caller.
class-attribute names. Non-Provider class attributes are skipped. A duplicate
attribute name across groups raises ``ValueError``. Pass ``module=``
explicitly when stack introspection cannot identify the caller.

## 📚 [Documentation](https://modern-di.readthedocs.io)
36 changes: 26 additions & 10 deletions modern_di_pytest/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,19 @@ def _fixture(request: pytest.FixtureRequest) -> typing.Any: # noqa: ANN401


def expose(
group: type[Group],
*,
*groups: type[Group],
container_fixture: str = "di_container",
pytest_scope: _PytestScope = "function",
module: types.ModuleType | None = None,
) -> None:
"""Register one pytest fixture per Provider in ``group``.
"""Register one pytest fixture per Provider across one or more groups.

Each generated fixture is named after the class attribute it came from.

Args:
group: A ``Group`` subclass whose class attributes are Providers.
*groups: One or more ``Group`` subclasses whose class attributes are
Providers. Attribute names must be unique across all groups; a
duplicate raises ``ValueError``.
container_fixture: Name of the pytest fixture yielding the container.
pytest_scope: pytest fixture scope applied to every generated fixture.
module: Module to inject fixtures into. Defaults to the caller's module
Expand All @@ -77,23 +78,38 @@ def expose(
Example (in ``conftest.py``)::

from modern_di_pytest import expose
from app.ioc import Dependencies
from app.ioc import Auth, Billing, Dependencies

expose(Dependencies)
expose(Dependencies, Auth, Billing)

Every ``Dependencies.<attr>`` that is a Provider becomes a pytest fixture
named ``<attr>``. Non-Provider class attributes are skipped.
Every Provider class attribute on each group becomes a pytest fixture
named after that attribute. Non-Provider class attributes are skipped.

"""
if not groups:
msg = "expose() requires at least one Group."
raise TypeError(msg)

if module is None:
frame = inspect.stack()[1].frame
module = inspect.getmodule(frame)
if module is None:
msg = "expose() could not determine the caller module; pass module=... explicitly."
raise RuntimeError(msg)

for attr_name, attr_value in vars(group).items():
if isinstance(attr_value, AbstractProvider):
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,
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import modern_di
import pytest

from tests.sample import Dependencies
from tests.sample import Dependencies, ExtraDependencies


@pytest.fixture
def di_container() -> typing.Iterator[modern_di.Container]:
with modern_di.Container(groups=[Dependencies]) as container:
with modern_di.Container(groups=[Dependencies, ExtraDependencies]) as container:
yield container


Expand Down
4 changes: 4 additions & 0 deletions tests/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ class Dependencies(Group):

not_a_provider = "string literal, not a provider"
_hidden_int = 7


class ExtraDependencies(Group):
extra_repo = providers.Factory(scope=Scope.APP, creator=Repo, bound_type=None)
19 changes: 19 additions & 0 deletions tests/test_expose.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import sys
import types

import pytest
from modern_di import Group, Scope, providers

from modern_di_pytest import expose
from tests.sample import Dependencies, Repo, Service
Expand All @@ -22,3 +26,18 @@ def test_expose_skips_non_provider_attributes() -> None:

assert not hasattr(this_module, "not_a_provider")
assert not hasattr(this_module, "_hidden_int")


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)


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)
13 changes: 13 additions & 0 deletions tests/test_expose_multiple_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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)
Loading