diff --git a/docs/api/deploy.rst b/docs/api/deploy.rst new file mode 100644 index 00000000..cc1d109e --- /dev/null +++ b/docs/api/deploy.rst @@ -0,0 +1,7 @@ +dependency_injector.deploy +========================== + +.. automodule:: dependency_injector.deploy + :members: + +.. disqus:: diff --git a/docs/api/index.rst b/docs/api/index.rst index 7258f7de..15503601 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -8,5 +8,6 @@ API Documentation providers containers wiring + deploy errors asgi-lifespan diff --git a/src/dependency_injector/deploy.py b/src/dependency_injector/deploy.py new file mode 100644 index 00000000..1ad3d69b --- /dev/null +++ b/src/dependency_injector/deploy.py @@ -0,0 +1,115 @@ +"""Deploy module.""" + +import sys +from types import ModuleType +from typing import Any, Iterable, Optional, Union + +if sys.version_info >= (3, 11): # pragma: no cover + from typing import Self +else: # pragma: no cover + from typing_extensions import Self + +from .containers import Container +from .wiring import ProvidersMap, _patched_registry + + +class ContainerDeploy: + """Wire a container and initialize its resources for the duration of an async context. + + On enter, the container is wired and its resources are initialized. On exit, resources + are shut down and the container is unwired, even when startup or shutdown fails. + + Calling the instance with an application returns the instance itself, so it can be passed + to any framework that accepts a lifespan callable, e.g. Starlette or FastAPI. + + Modules to wire come from :py:attr:`wire_modules`, which returns :py:attr:`modules`. Set + ``modules`` as a class attribute, pass it as a parameter, or override ``wire_modules`` to + compute it. When it is ``None``, every imported module with an ``@inject`` callable whose + markers this container resolves is wired. + + Usage: + + .. code-block:: python + + from dependency_injector.containers import DeclarativeContainer + from dependency_injector.deploy import ContainerDeploy + from fastapi import FastAPI + + class AppDeploy(ContainerDeploy): + + modules = ["app.api.routes", "app.api.websockets"] + + class Container(DeclarativeContainer): + ... + + app = FastAPI(lifespan=AppDeploy(Container())) + + async def main(): + async with ContainerDeploy(Container(), modules=["app.cli"]): + ... + + :param container: container instance + :param modules: Modules to wire. Overrides the class attribute of the same name. + """ + + container: Container + modules: Optional[Iterable[Union[ModuleType, str]]] = None + + def __init__( + self, + container: Container, + modules: Optional[Iterable[Union[ModuleType, str]]] = None, + ) -> None: + self.container = container + if modules is not None: + self.modules = modules + + @property + def wire_modules(self) -> Optional[Iterable[Union[ModuleType, str]]]: + """Modules to wire, defaulting to modules with ``@inject`` markers this container resolves.""" + if self.modules is None: + providers_map = ProvidersMap(self.container) + self.modules = sorted( + { + sys.modules[patched.patched.__module__] + for patched in _patched_registry._callables.values() + if any( + providers_map.resolve_provider(marker.provider, marker.modifier) + for marker in patched.reference_injections.values() + ) + }, + key=lambda module: module.__name__, + ) + return self.modules + + def __call__(self, app: Any) -> Self: + return self + + async def __aenter__(self) -> None: + self.container.wire(modules=self.wire_modules) + + try: + await self.init_resources() + except BaseException: + self.container.unwire() + raise + + async def __aexit__(self, *exc_info: Any) -> None: + try: + await self.shutdown_resources() + finally: + self.container.unwire() + + async def init_resources(self) -> None: + """Initialize container resources.""" + result = self.container.init_resources() + + if result is not None: + await result + + async def shutdown_resources(self) -> None: + """Shutdown container resources.""" + result = self.container.shutdown_resources() + + if result is not None: + await result diff --git a/tests/unit/test_deploy.py b/tests/unit/test_deploy.py new file mode 100644 index 00000000..4e6c148c --- /dev/null +++ b/tests/unit/test_deploy.py @@ -0,0 +1,146 @@ +import sys +from typing import AsyncIterator, Iterator + +from fastapi import FastAPI +from pytest import mark, raises + +from dependency_injector.containers import DeclarativeContainer +from dependency_injector.deploy import ContainerDeploy +from dependency_injector.providers import Object, Resource +from dependency_injector.wiring import Provide, inject + + +@inject +def wired_value(value: str = Provide["deploy_value"]) -> object: + return value + + +class ModuleDeploy(ContainerDeploy): + @property + def wire_modules(self): + return [sys.modules[__name__]] + + +@mark.parametrize("sync", [False, True]) +async def test_wires_and_initializes_resources(sync: bool) -> None: + events = [] + + def sync_resource() -> Iterator[None]: + events.append("init") + yield + events.append("shutdown") + + async def async_resource() -> AsyncIterator[None]: + events.append("init") + yield + events.append("shutdown") + + class Container(DeclarativeContainer): + deploy_value = Object("wired") + resource = Resource(sync_resource if sync else async_resource) + + async with ModuleDeploy(Container()): + assert wired_value() == "wired" + assert events == ["init"] + + assert wired_value() != "wired" + assert events == ["init", "shutdown"] + + +async def test_discovers_modules_with_inject_callables() -> None: + class Container(DeclarativeContainer): + deploy_value = Object("wired") + + deploy = ContainerDeploy(Container()) + + assert deploy.wire_modules == [sys.modules[__name__]] + + async with deploy: + assert wired_value() == "wired" + + assert wired_value() != "wired" + + +async def test_wires_overridden_modules() -> None: + class Container(DeclarativeContainer): + deploy_value = Object("wired") + + async with ModuleDeploy(Container()): + assert wired_value() == "wired" + + assert wired_value() != "wired" + + +async def test_wires_class_attribute_modules() -> None: + class AttributeDeploy(ContainerDeploy): + modules = [sys.modules[__name__]] + + class Container(DeclarativeContainer): + deploy_value = Object("wired") + + async with AttributeDeploy(Container()): + assert wired_value() == "wired" + + assert wired_value() != "wired" + + +async def test_modules_parameter_overrides_class_attribute() -> None: + class AttributeDeploy(ContainerDeploy): + modules = ["nonexistent.module"] + + class Container(DeclarativeContainer): + deploy_value = Object("wired") + + deploy = AttributeDeploy(Container(), modules=[sys.modules[__name__]]) + + assert deploy.wire_modules == [sys.modules[__name__]] + assert AttributeDeploy.modules == ["nonexistent.module"] + + async with deploy: + assert wired_value() == "wired" + + assert wired_value() != "wired" + + +async def test_unwires_when_init_fails() -> None: + async def failing_resource() -> AsyncIterator[None]: + raise RuntimeError("init failed") + yield + + class Container(DeclarativeContainer): + deploy_value = Object("wired") + failing = Resource(failing_resource) + + with raises(RuntimeError, match="init failed"): + async with ModuleDeploy(Container()): + pass + + assert wired_value() != "wired" + + +async def test_unwires_when_shutdown_fails() -> None: + async def failing_resource() -> AsyncIterator[None]: + yield + raise RuntimeError("shutdown failed") + + class Container(DeclarativeContainer): + deploy_value = Object("wired") + failing = Resource(failing_resource) + + with raises(RuntimeError, match="shutdown failed"): + async with ModuleDeploy(Container()): + assert wired_value() == "wired" + + assert wired_value() != "wired" + + +async def test_lifespan() -> None: + class Container(DeclarativeContainer): + deploy_value = Object("wired") + + app = FastAPI(lifespan=ModuleDeploy(Container())) + + async with app.router.lifespan_context(app): + assert wired_value() == "wired" + + assert wired_value() != "wired"