Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/api/deploy.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
dependency_injector.deploy
==========================

.. automodule:: dependency_injector.deploy
:members:

.. disqus::
1 change: 1 addition & 0 deletions docs/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ API Documentation
providers
containers
wiring
deploy
errors
asgi-lifespan
115 changes: 115 additions & 0 deletions src/dependency_injector/deploy.py
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions tests/unit/test_deploy.py
Original file line number Diff line number Diff line change
@@ -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"