From 02839e4b7c85ed23c7d5da833a4788912a1e97f7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 1/6] docs(api): add Container/__init__, resolve, Scope, ContextProvider docstrings (DS-1..DS-4) Co-Authored-By: Claude Opus 4.8 (1M context) --- modern_di/container.py | 18 ++++++++++++++++++ modern_di/providers/context_provider.py | 9 +++++++++ modern_di/scope.py | 8 ++++++++ 3 files changed, 35 insertions(+) diff --git a/modern_di/container.py b/modern_di/container.py index 0cada6a..1200978 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -18,6 +18,14 @@ class Container: + """DI container — the central object that resolves providers within a scope. + + A root container is created with ``Container(scope=Scope.APP, groups=[...])``; + child containers come from :meth:`build_child_container`. A child shares the + parent's ``providers_registry`` and ``overrides_registry`` but owns its own + ``cache_registry`` and ``context_registry``. + """ + __slots__ = ( "cache_registry", "closed", @@ -39,6 +47,14 @@ def __init__( # noqa: PLR0913 use_lock: bool = True, validate: bool = False, ) -> None: + """Build a container at ``scope``. + + ``validate=True`` checks the provider graph (cycles plus scope ordering) + at construction time. ``context`` seeds this container's context registry. + A root container owns fresh registries; a child (with ``parent_container`` + set) shares the parent's providers/overrides registries and inherits its + scope map. + """ if not isinstance(scope, enum.IntEnum): raise exceptions.InvalidScopeTypeError(scope_value=scope) if parent_container is not None and scope <= parent_container.scope: @@ -105,6 +121,7 @@ def find_container(self, scope: enum.IntEnum) -> "typing_extensions.Self": return self.scope_map[scope] def resolve(self, dependency_type: type[types.T]) -> types.T: + """Resolve a dependency by its type.""" provider = self.providers_registry.find_provider(dependency_type) if not provider: raise exceptions.ProviderNotRegisteredError( @@ -115,6 +132,7 @@ def resolve(self, dependency_type: type[types.T]) -> types.T: return self.resolve_provider(provider) def resolve_provider(self, provider: "AbstractProvider[types.T]") -> types.T: + """Resolve a specific provider by reference (enforces closed-state and applies overrides).""" if self.closed: raise exceptions.ContainerClosedError(container_scope=self.scope) diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 569224a..8dd9cba 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -11,6 +11,15 @@ class ContextProvider(AbstractProvider[types.T_co]): + """Provider for a runtime value supplied at container-build time. + + The value is passed via ``build_child_container(context={SomeType: value})`` + and looked up from the context registry at this provider's bound scope. + Resolving it directly when no value is set returns ``None``; injecting it into + a non-nullable, no-default ``Factory`` parameter instead raises + ``ArgumentResolutionError``. + """ + __slots__ = ("_context_type",) def __init__( diff --git a/modern_di/scope.py b/modern_di/scope.py index 4186901..8914ccb 100644 --- a/modern_di/scope.py +++ b/modern_di/scope.py @@ -2,6 +2,14 @@ class Scope(enum.IntEnum): + """Lifetime bands, ordered shallow → deep by integer value. + + A provider bound to a scope resolves only from a container at the same or a + deeper scope (higher integer); resolving it from a shallower container raises + ``ScopeNotInitializedError``. The members below are the defaults — the + ordering rule is what matters, and custom ``IntEnum`` scopes are allowed. + """ + APP = 1 SESSION = 2 REQUEST = 3 From 11743ae39ef639cf9194fb45c53eb6331282a44c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 2/6] docs: onboarding + provider-page Low fixes (O-8/9/11/12/13, D-2/3/4/5/6/7/8/9/12/13, X-5/8/11/12/13/14/15, R-2) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/index.md | 14 +- docs/introduction/about-di.md | 25 +-- docs/introduction/design-decisions.md | 2 +- docs/introduction/resolving.md | 5 +- docs/providers/alias.md | 4 +- docs/providers/errors-and-exceptions.md | 15 +- docs/providers/factories.md | 244 ++++++++++++------------ docs/providers/lifecycle.md | 8 +- docs/providers/scopes.md | 18 +- 10 files changed, 185 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 733c106..f8b4188 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ `modern-di` is a python dependency injection framework which supports the following: -- Automatic dependencies graph based on type annotations +- Automatic dependency graph based on type annotations - Also, explicit dependencies are allowed where needed - Scopes and context management - Python 3.10+ support diff --git a/docs/index.md b/docs/index.md index fd03843..b5cea02 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Welcome to the `modern-di` documentation! `modern-di` is a Python dependency injection framework which supports the following: -- Automatic dependencies graph based on type annotations +- Automatic dependency graph based on type annotations - Also, explicit dependencies are allowed where needed - Scopes and context management - Python 3.10+ support @@ -86,7 +86,11 @@ class Dependencies(Group): ) ``` -## 4.1. Integrate with your framework +## 4. Wire it up + +Pick **one** of the two mutually-exclusive options below. + +### Option A — integrate with your framework Pick the integration you need: @@ -98,7 +102,7 @@ Pick the integration you need: The integration package builds the per-request child container automatically and closes the APP container at shutdown. -## 4.2. Or use `modern-di` directly +### Option B — use modern-di directly ```python from modern_di import Container, Scope @@ -114,7 +118,7 @@ with Container(groups=[Dependencies], validate=True) as container: repo = request.resolve(UserRepository) user = repo.find(42) - # Request-scope finalizers ran on `with` exit + # Request-scope finalizers (teardown hooks such as closing a DB connection) ran on `with` exit # App-scope finalizers ran on the outer `with` exit ``` @@ -123,6 +127,8 @@ provider registers an **async** finalizer — see [Lifecycle](providers/lifecycl ## Where to next +- [Resolving](introduction/resolving.md) — how type-based auto-injection works. +- [Factories](providers/factories.md) — the provider you just used. - [Scopes](providers/scopes.md) — the APP → REQUEST lifetime model in one page. - [Lifecycle](providers/lifecycle.md) — finalizers, `close_async()`, validation. - [Recipes](recipes/sqlalchemy.md) — async SQLAlchemy, lifespan-managed resources, testing with overrides. diff --git a/docs/introduction/about-di.md b/docs/introduction/about-di.md index 26b96ac..923883b 100644 --- a/docs/introduction/about-di.md +++ b/docs/introduction/about-di.md @@ -65,28 +65,31 @@ service = UserService(cache=MockCache()) # Testing ## Lifetime Management in DI -Objects can have different lifetime cycles (singleton, scoped, transient). `modern-di` expresses this with [Scopes](../providers/scopes.md) — APP for process-wide singletons, REQUEST for per-request resources, and so on. Here are examples: +Objects can have different lifetime cycles (singleton, scoped, transient). `modern-di` expresses this with [Scopes](../providers/scopes.md) — APP for process-wide singletons, REQUEST for per-request resources, and so on. In modern-di's own terms: a provider's scope (APP/SESSION/REQUEST/…) decides how long an instance lives, and the presence of `cache_settings` means one shared instance is reused while its absence means a fresh instance is created on each resolve. Here are examples: ```python +import uuid + from modern_di import Group, Scope, providers -class AppModule(Group): - # Singleton: one instance for entire app +class AppGroup(Group): + # Cached for the whole app: one shared instance. + # CacheSettings() makes the provider a cached singleton (see the Factories page). config = providers.Factory( scope=Scope.APP, creator=AppConfig, cache_settings=providers.CacheSettings() ) - # Scoped: one instance per request + # Cached per request: one shared instance per request db_session = providers.Factory( scope=Scope.REQUEST, creator=DatabaseSession, cache_settings=providers.CacheSettings() ) - # Transient: new instance each time + # No cache_settings: a fresh instance each resolve request_id = providers.Factory( scope=Scope.REQUEST, creator=lambda: str(uuid.uuid4()) @@ -130,7 +133,7 @@ class UserService: # Declare dependencies -class AppModule(Group): +class AppGroup(Group): config = providers.Factory( scope=Scope.APP, creator=AppConfig, @@ -141,7 +144,7 @@ class AppModule(Group): # Build the app-level container, then a request-scoped child for per-request providers -app_container = Container(scope=Scope.APP, groups=[AppModule]) +app_container = Container(scope=Scope.APP, groups=[AppGroup]) with app_container.build_child_container(scope=Scope.REQUEST) as request_container: user_service = request_container.resolve(UserService) ``` @@ -157,7 +160,7 @@ Type annotations auto-wire dependencies - no manual registration needed. Hierarchical containers with automatic inheritance: ```python -app_container = Container(groups=[AppModule], scope=Scope.APP) +app_container = Container(groups=[AppGroup], scope=Scope.APP) request_container = app_container.build_child_container(scope=Scope.REQUEST) # Resolves from correct scope automatically @@ -172,8 +175,8 @@ Override any dependency: ```python @pytest.fixture def test_container() -> Container: - container = Container(groups=[AppModule]) - container.override(AppModule.db, Mock(spec=DatabaseConnection)) + container = Container(groups=[AppGroup]) + container.override(AppGroup.db, Mock(spec=DatabaseConnection)) return container ``` @@ -182,7 +185,7 @@ def test_container() -> Container: Define finalizers for automatic cleanup: ```python -class AppModule(Group): +class AppGroup(Group): db_session = providers.Factory( scope=Scope.REQUEST, creator=DatabaseSession, diff --git a/docs/introduction/design-decisions.md b/docs/introduction/design-decisions.md index e837f50..0316519 100644 --- a/docs/introduction/design-decisions.md +++ b/docs/introduction/design-decisions.md @@ -29,7 +29,7 @@ The codebase is type-checked with `ty` and linted with ruff's full rule set (`se ## 5. Conservative feature set -New features get added only when existing primitives genuinely cannot solve the task. The core has five providers (`Factory`, `Alias`, `ContextProvider`, `container_provider`, `AbstractProvider`) — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct. +New features get added only when existing primitives genuinely cannot solve the task. The core has three concrete provider types (`Factory`, `Alias`, `ContextProvider`), plus the `AbstractProvider` base and the pre-built `container_provider` singleton — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct. ## See also diff --git a/docs/introduction/resolving.md b/docs/introduction/resolving.md index 3b11c2c..da84d8c 100644 --- a/docs/introduction/resolving.md +++ b/docs/introduction/resolving.md @@ -30,10 +30,11 @@ class DatabaseConnection: class Dependencies(Group): db_config = providers.Factory( + scope=Scope.APP, creator=DatabaseConfig, kwargs={"host": "localhost", "port": 5432}, ) - db_connection = providers.Factory(creator=DatabaseConnection) + db_connection = providers.Factory(scope=Scope.APP, creator=DatabaseConnection) container = Container(groups=[Dependencies], validate=True) @@ -43,7 +44,7 @@ assert connection.config.host == "localhost" assert connection.timeout == 30 ``` -For union-typed parameters (`dep: A | B`), the resolver picks the *first* type in the union that has a registered provider. If you need a specific one, use a concrete annotation or pass the value explicitly via `kwargs`. +For union-typed parameters (`dep: A | B`), the resolver picks the *first* type in the union that has a registered provider. If you need a specific one, use a concrete annotation or pass the value explicitly via `kwargs`. A parameter typed `X | None` with no matching provider and no default value receives `None` rather than raising (see [Factories: Optional parameters](../providers/factories.md)). ## See also diff --git a/docs/providers/alias.md b/docs/providers/alias.md index 1dda952..72f2f0f 100644 --- a/docs/providers/alias.md +++ b/docs/providers/alias.md @@ -16,7 +16,7 @@ The type the alias is registered under in the providers registry — i.e. the ty ### scope -**Deprecated and ignored.** An alias's effective scope is derived from its source provider — the alias itself holds no instance and applies no caching. Passing `scope=` to `Alias(...)` emits a `DeprecationWarning`; the parameter will be removed in 3.0. +**Deprecated and ignored.** An alias's effective scope is derived from its source provider — the alias itself holds no instance and applies no caching. Passing `scope=` to `Alias(...)` emits a `DeprecationWarning`; the parameter will be removed in a future release. ## Basic Usage @@ -68,7 +68,7 @@ With an uncached source `Factory`, each resolution still goes through the source ## Overrides -Overrides are keyed by `provider_id`, so the alias and its source can be overridden independently: +Overrides are keyed by `provider_id`, so the alias and its source can be overridden independently. See [Testing with overrides](../recipes/testing-overrides.md) for the `container.override` / `reset_override` primitives. ```python mock_for_alias = PostgresRepository(dsn="alias-mock") diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 38ef0d3..973c69e 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -54,9 +54,10 @@ Catch `ContainerError` for any container/scope failure. parent is already at the deepest scope (`STEP`), so there is no next level to advance to. - **`ScopeNotInitializedError`** — raised during resolution when a provider needs a scope *deeper* than the current container's, and no container at that scope exists in the chain (e.g. resolving a - `REQUEST`-scoped provider from the `APP` container). + `REQUEST`-scoped provider from the `APP` container). See [Troubleshooting: Scope chain](../troubleshooting/scope-chain.md). - **`ScopeSkippedError`** — raised during resolution when the target scope is *shallower* than the current container but is missing from the scope chain (a level was skipped when building children). + See [Troubleshooting: Scope chain](../troubleshooting/scope-chain.md). - **`InvalidScopeTypeError`** — raised by the `Container` constructor when `scope` is not an `enum.IntEnum`. - **`ContainerClosedError`** — raised when you resolve from, or build a child of, a container that @@ -74,13 +75,16 @@ accumulated as the error propagates, so the message shows the full chain from th down to the failing dependency. - **`ProviderNotRegisteredError`** — raised by `resolve(SomeType)` when no provider is registered for - the type. The message includes "did you mean…" suggestions when a close match exists. + the type. The message includes "did you mean…" suggestions when a close match exists. See + [Troubleshooting: Missing provider](../troubleshooting/missing-provider.md). - **`AliasSourceNotRegisteredError`** — raised when an `Alias` points at a `source_type` that has no registered provider (eagerly during `validate()`, or at resolution time). - **`ArgumentResolutionError`** — raised when a creator parameter cannot be resolved: no provider - matches its annotated type, or the parameter is unannotated. + matches its annotated type, or the parameter is unannotated. See + [Troubleshooting: Context not set](../troubleshooting/context-not-set.md). - **`CircularDependencyError`** — raised when the provider graph contains a cycle (A → B → A), - detected during `validate()` or at resolution; the message shows the cycle path. + detected during `validate()` or at resolution; the message shows the cycle path. See + [Troubleshooting: Circular dependency](../troubleshooting/circular-dependency.md). - **`CreatorCallError`** — raised when a creator's dependencies all resolved but the creator itself raised while being called. The original exception is preserved on `.original_error` (and as the `__cause__`). @@ -90,7 +94,8 @@ down to the failing dependency. Catch `RegistrationError` for declaration- and registration-time problems. - **`DuplicateProviderTypeError`** — raised when two providers are registered for the same bound type - (within one group, across groups passed together, or against an already-registered type). + (within one group, across groups passed together, or against an already-registered type). See + [Troubleshooting: Duplicate type](../troubleshooting/duplicate-type-error.md). - **`UnknownFactoryKwargError`** — raised when `Factory(kwargs={...})` contains a key that is not a parameter of the creator's signature; lists the known parameters and "did you mean" hints. - **`UnsupportedCreatorParameterError`** — raised when a creator's signature has a parameter diff --git a/docs/providers/factories.md b/docs/providers/factories.md index b4da4ca..6c6751b 100644 --- a/docs/providers/factories.md +++ b/docs/providers/factories.md @@ -2,13 +2,124 @@ Factories are providers that create instances of dependencies. +## Types of factories +There are two types of factories: + +1. **Regular Factories** - Create a new instance on every call +2. **Cached Factories** - Create an instance once and cache it for future calls + +### Regular Factories + +Regular factories are initialized on every call. + +```python +import dataclasses + +from modern_di import Group, Container, Scope, providers + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class IndependentFactory: + dep1: str + dep2: int + + +class Dependencies(Group): + independent_factory = providers.Factory( + scope=Scope.APP, + creator=IndependentFactory, + kwargs={"dep1": "text", "dep2": 123} + ) + + +container = Container(groups=[Dependencies]) +# Resolve by provider reference +instance = container.resolve_provider(Dependencies.independent_factory) +assert isinstance(instance, IndependentFactory) + +# Resolve by type (uses the return type of the creator function/class) +instance2 = container.resolve(IndependentFactory) +assert isinstance(instance2, IndependentFactory) +``` + +### Cached Factories + +Cached factories resolve the dependency only once and cache the resolved instance for future injections. + +The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created. + +If your application is single-threaded, you can disable the lock for a small performance gain: + +```python +container = Container(groups=[Dependencies], use_lock=False) +``` + +Do not set `use_lock=False` in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory. + +```python +import random + +from modern_di import Group, Container, Scope, providers + + +def generate_random_number() -> float: + return random.random() + + +class Dependencies(Group): + singleton = providers.Factory( + scope=Scope.APP, + creator=generate_random_number, + cache_settings=providers.CacheSettings() + ) + + +container = Container(groups=[Dependencies]) +singleton_instance1 = container.resolve_provider(Dependencies.singleton) +singleton_instance2 = container.resolve_provider(Dependencies.singleton) + +# If resolved in the same container, the instance will be the same +assert singleton_instance1 is singleton_instance2 +``` + +#### Cache Settings + +You can customize caching behavior with `CacheSettings`: + +```python +import contextlib + +from modern_di import Group, Scope, providers + + +class SomeResource: + def close(self) -> None: ... + + +def create_resource() -> SomeResource: + # Create and return resource + return SomeResource() + + +class Dependencies(Group): + # Cache with cleanup — clear_cache=True (the default) ensures the closed + # resource is evicted from cache so it cannot be returned again after close + resource = providers.Factory( + scope=Scope.APP, + creator=create_resource, + cache_settings=providers.CacheSettings( + finalizer=lambda res: res.close(), # Cleanup function + ) + ) +``` + ## Parameters When creating a Factory provider, you can configure several parameters: ### scope -Defines the lifecycle of the dependency. Defaults to `Scope.APP`. The available scopes are `APP → SESSION → REQUEST → ACTION → STEP`; see [Scopes](scopes.md) for the full mental model and the dependency rule. +Defines the lifetime (scope) of the dependency. Defaults to `Scope.APP`. The available scopes are `APP → SESSION → REQUEST → ACTION → STEP`; see [Scopes](scopes.md) for the full mental model and the dependency rule. ### creator @@ -31,6 +142,15 @@ Use this to provide specific values for parameters or override automatically res Configuration for caching instances. Only applicable for cached factories. Use `providers.CacheSettings()` to enable caching with optional cleanup configuration. See [Lifecycle](lifecycle.md) for how caching, finalizers, and `close_async()` fit together. +### skip_creator_parsing + +Disables automatic dependency resolution. When `True`: +- No automatic dependency resolution occurs +- All parameters must be provided via the `kwargs` parameter +- The `bound_type` will not be automatically inferred from the creator's return type; unless `bound_type` is explicitly provided, it defaults to `None` + +## Resolution behavior + ### Union type parameters When a parameter is annotated with a union type (e.g. `dep: A | B`), Modern-DI resolves the **first registered type** that matches. The order is determined by how types appear in the union left-to-right. If you rely on a specific type being injected, prefer a concrete type annotation over a union. @@ -70,13 +190,6 @@ service = container.resolve(Service) assert service.cache is None # no Cache provider registered -> None injected ``` -### skip_creator_parsing - -Disables automatic dependency resolution. When `True`: -- No automatic dependency resolution occurs -- All parameters must be provided via the `kwargs` parameter -- The `bound_type` will not be automatically inferred from the creator's return type; unless `bound_type` is explicitly provided, it defaults to `None` - ### Creator-signature support matrix The table below summarises how Modern-DI handles each parameter shape during **declaration** (when the `Factory` object is constructed) and **resolution** (when `container.resolve` is called). "Escapes" means the parameter is silently excluded from automatic wiring and must be covered by `kwargs` or a default. @@ -94,11 +207,11 @@ The table below summarises how Modern-DI handles each parameter shape during **d **Escaping problem shapes** — if a parameter shape would raise at declaration, there are three escape routes, in order of preference: -1. Give the parameter a default value (`def f(items: list[X] = None)`). +1. Give the parameter a default value (`def f(items: list[X] | None = None)`). 2. Supply the value via `kwargs={"items": []}` at `Factory` declaration time. 3. Pass `skip_creator_parsing=True` (and supply all required args via `kwargs`). -### Provider passed as a kwargs value (X-3) +### Provider passed as a kwargs value Passing an `AbstractProvider` instance directly as a value in the `kwargs` dict is treated as **explicit wiring**: Modern-DI resolves the provider and injects the resolved value — the provider object itself is never seen by the creator. @@ -174,114 +287,3 @@ except RuntimeError: result = container.resolve(object) # retry succeeds assert result is container.resolve(object) # now cached ``` - -## Types of factories -There are two types of factories: - -1. **Regular Factories** - Create a new instance on every call -2. **Cached Factories** - Create an instance once and cache it for future calls - -### Regular Factories - -Regular factories are initialized on every call. - -```python -import dataclasses - -from modern_di import Group, Container, Scope, providers - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class IndependentFactory: - dep1: str - dep2: int - - -class Dependencies(Group): - independent_factory = providers.Factory( - scope=Scope.APP, - creator=IndependentFactory, - kwargs={"dep1": "text", "dep2": 123} - ) - - -container = Container(groups=[Dependencies]) -# Resolve by provider reference -instance = container.resolve_provider(Dependencies.independent_factory) -assert isinstance(instance, IndependentFactory) - -# Resolve by type (uses the return type of the creator function/class) -instance2 = container.resolve(IndependentFactory) -assert isinstance(instance2, IndependentFactory) -``` - -### Cached Factories - -Cached factories resolve the dependency only once and cache the resolved instance for future injections. - -The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created. - -If your application is single-threaded, you can disable the lock for a small performance gain: - -```python -container = Container(groups=[Dependencies], use_lock=False) -``` - -Do not set `use_lock=False` in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory. - -```python -import random - -from modern_di import Group, Container, Scope, providers - - -def generate_random_number() -> float: - return random.random() - - -class Dependencies(Group): - singleton = providers.Factory( - scope=Scope.APP, - creator=generate_random_number, - cache_settings=providers.CacheSettings() - ) - - -container = Container(groups=[Dependencies]) -singleton_instance1 = container.resolve_provider(Dependencies.singleton) -singleton_instance2 = container.resolve_provider(Dependencies.singleton) - -# If resolved in the same container, the instance will be the same -assert singleton_instance1 is singleton_instance2 -``` - -#### Cache Settings - -You can customize caching behavior with `CacheSettings`: - -```python -import contextlib - -from modern_di import Group, Scope, providers - - -class SomeResource: - def close(self) -> None: ... - - -def create_resource() -> SomeResource: - # Create and return resource - return SomeResource() - - -class Dependencies(Group): - # Cache with cleanup — clear_cache=True (the default) ensures the closed - # resource is evicted from cache so it cannot be returned again after close - resource = providers.Factory( - scope=Scope.APP, - creator=create_resource, - cache_settings=providers.CacheSettings( - finalizer=lambda res: res.close(), # Cleanup function - ) - ) -``` diff --git a/docs/providers/lifecycle.md b/docs/providers/lifecycle.md index 623ca06..ba4e4a2 100644 --- a/docs/providers/lifecycle.md +++ b/docs/providers/lifecycle.md @@ -2,6 +2,12 @@ How instances are created, cached, and cleaned up. +The code blocks below assume the following import, and `Dependencies` is a user-defined `Group`: + +```python +from modern_di import Container, Scope, providers, exceptions +``` + ## Lazy initialization `modern-di` creates instances on first resolve. There is no `init_resources()` or "eager startup" call — if a provider is never resolved, its creator never runs. @@ -61,7 +67,7 @@ async with container: ... ``` -Closing a container runs its finalizers in reverse-resolve order, then clears the cache. +Closing a container runs its finalizers in reverse-creation order (creation order equals first-resolve order, since creation is lazy), then clears the cache. ## Close-failure semantics diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index b88e27e..01555f0 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -34,6 +34,8 @@ with app_container.build_child_container(scope=Scope.REQUEST) as request_contain ... ``` +`Dependencies` here is a `Group` subclass holding the provider definitions — see the [Quick Start](../index.md) or [Resolving dependencies](../introduction/resolving.md) for how it's declared. + Children share their parent's `providers_registry` (provider definitions) and `overrides_registry` (test overrides) but have their own `cache_registry` (resolved instances) and `context_registry` (runtime context values). That's why a REQUEST-scoped factory produces one instance per request — the cache lives on the request container, not the app container. ## The scope dependency rule @@ -68,6 +70,8 @@ async with app_container.build_child_container(scope=Scope.REQUEST) as request_c # async finalizers ran here ``` +Use `async with` only when the scope holds providers with async finalizers; otherwise plain `with` is enough. Resolution itself is always synchronous. + **Framework-managed.** Integration packages (`modern-di-fastapi`, `modern-di-litestar`, `modern-di-faststream`) build the REQUEST child container for each request and tear it down at the end. You only declare `scope=Scope.REQUEST` on the providers that need it. ## Resolving across scopes @@ -80,7 +84,7 @@ engine: AsyncEngine = request_container.resolve(AsyncEngine) # walks up to APP session: AsyncSession = request_container.resolve(AsyncSession) # local to REQUEST ``` -Trying to resolve a REQUEST-scoped provider from an APP container raises a clear error — the request container hasn't been built yet, so there's nothing to resolve into. +Trying to resolve a REQUEST-scoped provider from an APP container raises [`ScopeNotInitializedError`](errors-and-exceptions.md) — the request container hasn't been built yet, so there's nothing to resolve into. ## Custom scopes @@ -88,7 +92,7 @@ For non-standard lifecycles (per-tenant containers, background-job runs, anythin ```python from enum import IntEnum -from modern_di import Container, providers +from modern_di import Container, Group, providers class MyScope(IntEnum): @@ -96,11 +100,17 @@ class MyScope(IntEnum): BACKGROUND_JOB = 7 -tenant_provider = providers.Factory(scope=MyScope.TENANT, creator=...) +class TenantContext: + pass + + +class MyGroup(Group): + tenant_provider = providers.Factory(scope=MyScope.TENANT, creator=TenantContext) + container = Container(groups=[MyGroup]) with container.build_child_container(scope=MyScope.TENANT) as tenant_container: - ... + tenant = tenant_container.resolve(TenantContext) ``` The child scope's integer value must be strictly greater than its parent's. When `scope=` is omitted from `build_child_container`, the auto-derived next scope only advances within the parent's own enum class — to cross enum boundaries (e.g. jump from a built-in `Scope` to `MyScope.TENANT`), pass `scope=` explicitly. From 6ed5bb25b6caf0525004fb3a8366a686de6eb41a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 3/6] docs(integrations): API table, unused-import + placeholder + cross-link Low fixes (O-14/15/16/17, D-14/15/16/17/18) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrations/fastapi.md | 12 +++++++++++- docs/integrations/litestar.md | 1 - docs/integrations/pytest.md | 4 ++++ docs/integrations/typer.md | 9 +++++++-- docs/testing/fixtures.md | 2 +- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index 2f67180..652bc8a 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -54,7 +54,7 @@ class AppGroup(Group): ALL_GROUPS = [AppGroup] # Setup DI with your groups -modern_di_fastapi.setup_di(app, Container(groups=ALL_GROUPS)) +modern_di_fastapi.setup_di(app, Container(groups=ALL_GROUPS, validate=True)) @app.get("/") @@ -166,3 +166,13 @@ class AppGroup(Group): kwargs={"request": modern_di_fastapi.fastapi_request_provider} ) ``` + +## API + +| Symbol | Description | +|---|---| +| `setup_di(app, container)` | Registers the container on the FastAPI app and appends a lifespan that closes it on shutdown (merges with any existing `lifespan=`); returns the container. | +| `FromDI(dependency, *, use_cache=True)` | A `fastapi.Depends` wrapper that resolves a provider (or type) from the per-request child container. | +| `build_di_container(connection)` | A `fastapi.Depends` callable that yields the per-request child container — REQUEST scope for an HTTP request, SESSION scope for a WebSocket. | +| `fastapi_request_provider` | `ContextProvider` for `fastapi.Request` (REQUEST scope), auto-registered. | +| `fastapi_websocket_provider` | `ContextProvider` for `fastapi.WebSocket` (SESSION scope), auto-registered. | diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index e89f748..6cd85da 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -27,7 +27,6 @@ ### 2. Apply to your application ```python import datetime -import typing from litestar import Litestar, get import modern_di_litestar diff --git a/docs/integrations/pytest.md b/docs/integrations/pytest.md index 09c5731..27eb265 100644 --- a/docs/integrations/pytest.md +++ b/docs/integrations/pytest.md @@ -63,6 +63,8 @@ from app.services import EmailClient # duplicate names across groups raise ValueError. Non-Provider attributes # are skipped. expose(Dependencies, Auth, Billing) +# e.g. user_service is the attribute name on the Dependencies group, +# so it becomes the user_service fixture used in the tests below. # Manual: a single type or Provider as a named fixture. email_client = modern_di_fixture(EmailClient) @@ -159,3 +161,5 @@ def mock_user_repo(di_container: modern_di.Container) -> typing.Iterator[None]: yield di_container.reset_override(Dependencies.user_repo) ``` + +For deeper patterns (transactional DB sessions, resetting all overrides) see the [testing-with-overrides recipe](../recipes/testing-overrides.md). diff --git a/docs/integrations/typer.md b/docs/integrations/typer.md index 0ca9cad..9e7de59 100644 --- a/docs/integrations/typer.md +++ b/docs/integrations/typer.md @@ -28,7 +28,6 @@ import dataclasses import typing -import modern_di import modern_di_typer import typer from modern_di import Container, Group, Scope, providers @@ -88,6 +87,8 @@ To resolve `Scope.ACTION` dependencies, inject `modern_di.Container` — `@injec `REQUEST`-scoped container it creates per invocation. Call `build_child_container()` on it to enter `ACTION` scope: +Building on the first example's `app` and `container`: + ```python import modern_di import modern_di_typer @@ -95,8 +96,12 @@ import typing from modern_di import Group, Scope, providers +class Job: + def run(self) -> None: ... + + class AppGroup(Group): - job = providers.Factory(scope=Scope.ACTION, creator=..., bound_type=None) + job = providers.Factory(scope=Scope.ACTION, creator=Job, bound_type=None) @app.command() diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index af06f96..7d3c95b 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -4,7 +4,7 @@ Two ways to wire `modern-di` into tests: 1. **Recommended — `modern-di-pytest`.** Generates pytest fixtures from your providers. One line per dependency, or one line for the whole `Group`. See the [pytest integration page](../integrations/pytest.md) for the full setup including `expose(...)`, `modern_di_fixture(...)`, child-container fixtures, and overrides. -2. **Plain `modern-di` (without the helper package).** Define `di_container` as a session-scoped pytest fixture using `Container(...)` as a context manager; build a request-container fixture from it; resolve dependencies inside tests with `container.resolve(...)`. See the same pytest integration page for a worked example. +2. **Plain `modern-di` (without the helper package).** Define `di_container` as a session-scoped pytest fixture using `Container(...)` as a context manager; build a request-container fixture from it; resolve dependencies inside tests with `container.resolve(...)`. See the [testing-with-overrides recipe](../recipes/testing-overrides.md) for a worked example. For replacing dependencies in tests (mocks, transactional database sessions, etc.), see the [testing-with-overrides recipe](../recipes/testing-overrides.md). From 2c2aa1f7f1548089ffb24f876a993df92ab5e968 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 4/6] docs: troubleshooting/migration/contributing Low fixes (X-9/16/17, D-20/22/26) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/dev/contributing.md | 4 +++- docs/migration/to-2.x.md | 4 ++-- docs/recipes/testing-overrides.md | 2 +- docs/troubleshooting/circular-dependency.md | 5 +++++ docs/troubleshooting/context-not-set.md | 2 +- docs/troubleshooting/duplicate-type-error.md | 10 ++++++++++ 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index 77abda5..e0e783d 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -16,7 +16,9 @@ cd modern-di Run all checks by command `just lint` ## Running tests -Run all tests by command `just test` +Run all tests by command `just test`. Run a subset with `just test -k `. + +CI runs the coverage-enforcing recipe `just test-ci` along with `just lint-ci`. ## Submitting changes 1. Fork the repo and branch off `main`. diff --git a/docs/migration/to-2.x.md b/docs/migration/to-2.x.md index 9ddc9e0..0da8c00 100644 --- a/docs/migration/to-2.x.md +++ b/docs/migration/to-2.x.md @@ -209,10 +209,10 @@ with container.build_child_container(context=context, scope=Scope.REQUEST) as re ```python # Same context-manager form continues to work with container.build_child_container(context=context, scope=Scope.REQUEST) as request_container: - # Use request_container + service = request_container.resolve(MyService) async with container.build_child_container(context=context, scope=Scope.REQUEST) as request_container: - # Use request_container + service = request_container.resolve(MyService) # If you need manual lifecycle control, call close_sync() or await close_async() yourself request_container = container.build_child_container(context=context, scope=Scope.REQUEST) diff --git a/docs/recipes/testing-overrides.md b/docs/recipes/testing-overrides.md index c0076ef..9609649 100644 --- a/docs/recipes/testing-overrides.md +++ b/docs/recipes/testing-overrides.md @@ -63,7 +63,7 @@ async def db_connection(engine: sa_async.AsyncEngine) -> sa_async.AsyncConnectio await transaction.rollback() ``` -Tests that pull a session through DI (`container.resolve(AsyncSession)`) get one bound to the test connection, and everything they write rolls back at the end. +Tests that pull a session through DI (`container.resolve(sa_async.AsyncSession)`) get one bound to the test connection, and everything they write rolls back at the end. ## Pattern 3: `modern-di-pytest` fixtures diff --git a/docs/troubleshooting/circular-dependency.md b/docs/troubleshooting/circular-dependency.md index 4355dc4..a497996 100644 --- a/docs/troubleshooting/circular-dependency.md +++ b/docs/troubleshooting/circular-dependency.md @@ -33,3 +33,8 @@ container.validate() 1. **Break the cycle with an interface/protocol** - introduce an abstraction that one side depends on instead of the concrete type 2. **Use `kwargs` to inject one dependency manually** - pass a factory or value via `kwargs` instead of relying on automatic resolution 3. **Restructure your dependencies** - extract shared logic into a third provider that both can depend on without forming a cycle + +## See also + +- [Errors and exceptions](../providers/errors-and-exceptions.md) +- [Lifecycle](../providers/lifecycle.md) — the validation section. diff --git a/docs/troubleshooting/context-not-set.md b/docs/troubleshooting/context-not-set.md index 3e0a319..425b85c 100644 --- a/docs/troubleshooting/context-not-set.md +++ b/docs/troubleshooting/context-not-set.md @@ -1,6 +1,6 @@ # ContextProvider has no value -A `ContextProvider(context_type=SomeType)` resolves by looking up `SomeType` in the container's context registry. If no value was registered, the resolution fails (or returns `None`, depending on configuration). +A `ContextProvider(context_type=SomeType)` resolves by looking up `SomeType` in the container's context registry. If no value was registered, the outcome depends on how the provider is consumed: resolving it directly returns `None`, while injecting it into a `Factory` parameter that has no value raises `ArgumentResolutionError` — **unless** that parameter has a default (the default is used; `None` is not injected) or is nullable `X | None` (then `None` is injected). ## Understanding the error diff --git a/docs/troubleshooting/duplicate-type-error.md b/docs/troubleshooting/duplicate-type-error.md index b058d70..775f640 100644 --- a/docs/troubleshooting/duplicate-type-error.md +++ b/docs/troubleshooting/duplicate-type-error.md @@ -10,6 +10,8 @@ When you see this error: DuplicateProviderTypeError: Provider is duplicated by type . ``` +The full runtime message also embeds the numbered resolution steps (set `bound_type=None` on one of the providers, or pass dependencies via `kwargs`) and a `See https://...` backlink to this page. + It descends from `RegistrationError` → `ModernDIError` → `RuntimeError`, so `except DuplicateProviderTypeError`, `except RegistrationError`, and `except RuntimeError` all catch it. See [Errors and exceptions](../providers/errors-and-exceptions.md). It means you have multiple providers that can provide the same type. This typically happens when: @@ -70,3 +72,11 @@ class MyGroup(Group): kwargs={"db_config": secondary_db_config} # <-- Step 2: Explicit dependency ) ``` + +## See also + +- [Factories](../providers/factories.md#bound_type) — the `bound_type` section. +- [Errors and exceptions](../providers/errors-and-exceptions.md) +- [Missing provider](../troubleshooting/missing-provider.md) + +For binding an abstract type to a concrete implementation, `Alias` is preferred over duplicate factories. From 40c1c3b6d4377ba8011db371664fd9c5c84a34d0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 5/6] docs(architecture): happy-path lead, top-level imports, populated kwargs, bound_type notes, creator terminology (A-1/2/4/5/6, X-10) Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/containers.md | 14 ++++++++++++-- architecture/providers.md | 11 ++++++++--- architecture/testing-and-overrides.md | 8 ++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/architecture/containers.md b/architecture/containers.md index 5bf1932..536d4f2 100644 --- a/architecture/containers.md +++ b/architecture/containers.md @@ -6,8 +6,7 @@ providers — resolution, scoping, overriding — flows through a `Container`. ## Creating a root container ```python -from modern_di import Container, Scope -from modern_di.group import Group +from modern_di import Container, Scope, Group class MyGroup(Group): ... @@ -74,6 +73,17 @@ under `Container` rather than inferred from a type annotation). ## Lifecycle: close and reopen +The idiomatic happy path is the `with` statement: it builds the container, runs finalizers in +LIFO order on the way out, and guarantees close even if the body raises. Most code never needs to +call `close_sync()`/`close_async()` directly. + +```python +with Container(scope=Scope.APP, groups=[MyGroup]) as container: + ... # resolve providers here; finalizers run on exit +``` + +The rest of this section documents what that close performs and how reopen works. + ### Closing `close_sync()` and `close_async()` both do two things in order: diff --git a/architecture/providers.md b/architecture/providers.md index c26ef11..ff92702 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -26,7 +26,7 @@ respecting inheritance order and de-duplicating by name. ## `Factory` — the universal provider -`Factory` is the main building block. Every provider that calls a constructor or factory function is a `Factory`. +`Factory` is the main building block. Every provider that calls a creator callable (a constructor or factory function passed as `creator=`) is a `Factory`. ### Signature @@ -64,9 +64,10 @@ a matching provider by type in the registry and recurses into `container.resolve Resolution errors are annotated with a breadcrumb describing the current factory, so the full chain appears in the exception. -### Static kwargs — `kwargs={}` +### Static kwargs -Pass `kwargs` to supply static (non-DI-resolved) arguments that bypass type-based resolution. These are merged +Pass `kwargs` to supply static (non-DI-resolved) arguments that bypass type-based resolution — for +example `kwargs={"timeout": 30}` to pin a creator's `timeout` parameter. These are merged last, overriding any provider-resolved value for the same key. Supplying a key that does not appear in the creator's signature (and whose creator has no `**kwargs`) raises `UnknownFactoryKwargError` at declaration time. @@ -117,6 +118,8 @@ value was supplied (the key is absent), `resolve` returns `None`. `Factory._comp absent-context case explicitly: if the dependent parameter has a default or is nullable it is silently satisfied; otherwise an `ArgumentResolutionError` is raised. +`ContextProvider` also accepts an optional `bound_type` that overrides the inferred bound type. + --- ## `Alias` — re-exporting a type under a different name @@ -131,6 +134,8 @@ This lets code that depends on `DatabaseProtocol` receive the `ConcreteDatabase` needing a separate `Factory` for the protocol. `Alias.resolve` calls `container.resolve_provider(source_provider)`, so caching and lifecycle are fully governed by the source. +`Alias` also accepts an optional `bound_type` that overrides the inferred bound type. + `Alias.effective_scope` follows alias chains transitively to the terminal non-alias provider and returns that provider's scope. This is what `Container.validate()` and scope-error reporting use — the alias's own `scope` attribute is only a stored default. diff --git a/architecture/testing-and-overrides.md b/architecture/testing-and-overrides.md index ad5f549..e6f0fb6 100644 --- a/architecture/testing-and-overrides.md +++ b/architecture/testing-and-overrides.md @@ -61,12 +61,12 @@ cached instance. After `reset_override`, the original cache entry (if any) is st Define a `Group` subclass with providers as class-level attributes and pass it to `Container`: ```python -from modern_di import Container, Scope, providers -from modern_di.group import Group +from modern_di import Container, Scope, providers, Group class MyGroup(Group): - service = providers.Factory(scope=Scope.APP, creator=MyService) - repo = providers.Factory(scope=Scope.APP, creator=MyRepo) + service = providers.Factory(scope=Scope.APP, creator=MyService) + repo = providers.Factory(scope=Scope.APP, creator=MyRepo) + request_scoped_service = providers.Factory(scope=Scope.REQUEST, creator=RequestScopedService) container = Container(scope=Scope.APP, groups=[MyGroup]) ``` From 22f1dda954f8a51ed1b81be8bbce0f13c9756b6e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 14 Jun 2026 09:53:59 +0300 Subject: [PATCH 6/6] chore(planning): docs-ux-lows change bundle Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-14.01-docs-ux-lows/change.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 planning/changes/active/2026-06-14.01-docs-ux-lows/change.md diff --git a/planning/changes/active/2026-06-14.01-docs-ux-lows/change.md b/planning/changes/active/2026-06-14.01-docs-ux-lows/change.md new file mode 100644 index 0000000..ad8425b --- /dev/null +++ b/planning/changes/active/2026-06-14.01-docs-ux-lows/change.md @@ -0,0 +1,42 @@ +--- +status: active +date: 2026-06-14 +slug: docs-ux-lows +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Change: Docs UX audit — fix the 53 Low findings + +**Lane:** batch follow-up to the 2026-06-13 docs UX audit. Not a design change — +mechanical doc edits (cross-links, imports, terminology, glosses, four +docstrings) driven directly by the verified findings in +[`audits/2026-06-13-docs-ux-audit-report.md`](../../../audits/2026-06-13-docs-ux-audit-report.md). +The 16 Mediums shipped in #212; this clears the Lows. + +## Goal + +Apply every Low-severity finding (O-8…O-17, D-2…D-26 lows, R-2, A-1/A-2/A-4/A-5/A-6, +DS-1…DS-4, X-5…X-17). `D-10` is already resolved by the X-3 split shipped in #212. + +## Approach + +Grouped by file (the audit gives an exact location + suggested fix for each). +Most are one-liners: missing/unused imports, inline cross-links, terminology +normalization, short glosses, two small example rewrites. DS-1…DS-4 add four +well-formed docstrings to `modern_di/` (D1 is ignored but D211/D212/D415 are +active — summary line ends with a period, no blank line before class docstrings). + +## Verification + +- [ ] Every changed code example executes (`uv run python`). +- [ ] `uv run --with mkdocs-material mkdocs build --strict` — no broken-link/nav warnings. +- [ ] `just lint-ci` — clean (covers the docstring additions; `docs/` is ruff-excluded). +- [ ] `just test-ci` — full suite green (docstrings don't change behavior). + +## Out of scope + +- Larger restructures are kept minimal: D-6/D-9 (factories.md section reordering) + and A-1 (containers.md lead) are done as light reorderings, not rewrites.