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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ dist/
.python-version
.venv
uv.lock
plan.md
/plan.md
site/
26 changes: 23 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ uv run pytest

## Architecture

> Quick orientation. The authoritative, code-current account of each capability lives in [`architecture/`](architecture/).

### Scope hierarchy

`Scope` is an `IntEnum` with five levels: `APP=1 → SESSION=2 → REQUEST=3 → ACTION=4 → STEP=5`. Providers are bound to a scope; a provider can only be resolved from a container of the same or deeper (higher int) scope. Trying to resolve a REQUEST-scoped provider from an APP container raises a clear error.
Expand All @@ -40,7 +42,7 @@ uv run pytest

`Container` is the central object. A root container is created with `Container(scope=Scope.APP, groups=[MyGroup])`. Child containers are created via `container.build_child_container(scope=Scope.REQUEST, context={...})`. Child containers share the parent's `providers_registry` and `overrides_registry` but have their own `cache_registry` and `context_registry`.

Pass `validate=True` to run cycle detection on the provider graph at container creation time (zero cost when disabled). Can also be called explicitly via `container.validate()`.
Pass `validate=True` to check the provider graph at container creation time — cycle detection plus transitive scope validation through aliases (via `effective_scope`); zero cost when disabled. Can also be called explicitly via `container.validate()`.

### Group and Provider declaration

Expand All @@ -59,7 +61,7 @@ class MyGroup(Group):

1. `container.resolve(SomeType)` → looks up type in `providers_registry` → calls `resolve_provider(provider)`
2. `resolve_provider` checks `overrides_registry` first (returns override immediately if found)
3. Finds the container at the correct scope via `find_container(scope)` walking the parent chain
3. Finds the container at the correct scope via `find_container(scope)`, an O(1) lookup in the precomputed `scope_map`
4. Checks `cache_registry`; if cached, returns immediately
5. Compiles kwargs: for each parsed parameter, finds a matching provider by type and resolves it recursively
6. Calls the creator, stores result in cache if `cache_settings` configured
Expand All @@ -82,7 +84,8 @@ class MyGroup(Group):
- `modern_di/types_parser.py` — Signature introspection engine (parses type hints for DI wiring)
- `modern_di/scope.py` — Scope enum
- `modern_di/group.py` — Group base class for provider namespaces
- `modern_di/errors.py` — Error message templates
- `modern_di/exceptions.py` — exception class hierarchy (`ModernDIError` → `ContainerError`/`ResolutionError`/`RegistrationError` subclasses)
- `modern_di/errors.py` — error message-template strings used by those exceptions

### Testing patterns

Expand All @@ -93,6 +96,23 @@ class MyGroup(Group):
- `asyncio_mode = "auto"` in pytest config — async test functions work without extra markers
- Downstream projects can install **`modern-di-pytest`** to expose DI dependencies as pytest fixtures. It ships two callables: `modern_di_fixture(type_or_provider)` for single fixtures and `expose(*groups)` to bulk-generate one fixture per provider across one or more `Group` subclasses (duplicate attribute names raise `ValueError`). The package itself does **not** depend on `modern-di-pytest`; the integration lives in a sibling repository (`modern-python/modern-di-pytest`).

## Workflow

Planning follows a portable two-axis convention (shared with
`faststream-outbox`); full details in [`planning/README.md`](planning/README.md).

- **`architecture/`** (repo root) is the **truth home** — living capability
prose, the promotion target on every ship. The `## Architecture` section
above is quick orientation; `architecture/` holds the authoritative,
up-to-date account.
- **`planning/changes/{active,archive}/<YYYY-MM-DD.NN-slug>/`** are change
bundles: `design.md` + `plan.md` (full lane), or `change.md` (lightweight).
Tiny changes (typo, dep bump, CI tweak) skip bundles entirely.
- Templates live in [`planning/_templates/`](planning/_templates/).
- **Shipping a change** hand-edits the affected `architecture/<capability>.md`,
then moves the bundle from `active/` to `archive/` with `status: shipped`,
`pr:`, and `outcome:` filled.

## Code Style

- Line length: 120 characters
Expand Down
26 changes: 26 additions & 0 deletions architecture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Architecture

The living truth about what `modern-di` does **now** — one file per capability,
updated by hand whenever a change ships. The *why* and *how it got here* live in
[`../planning/changes/`](../planning/changes/); this directory is the present.

These files carry **no frontmatter** — they are prose, dated by git.

## Capabilities

- [scopes.md](scopes.md) — the `Scope` hierarchy and the resolution rule.
- [containers.md](containers.md) — the `Container`, its registries, child
containers, and lifecycle.
- [providers.md](providers.md) — `Group`, `Factory`/caching, `ContextProvider`,
`Alias`.
- [resolution.md](resolution.md) — how `resolve()` wires dependencies from type
hints.
- [validation.md](validation.md) — `validate()` cycle and scope checks.
- [testing-and-overrides.md](testing-and-overrides.md) — overrides and the
`modern-di-pytest` integration.

## Promotion rule

Shipping a change hand-edits the affected capability file(s) here to match the
new reality, then archives the change bundle under
[`../planning/changes/archive/`](../planning/changes/archive/).
136 changes: 136 additions & 0 deletions architecture/containers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Containers

`Container` is the central entry point for the dependency injection system. Every interaction with
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

class MyGroup(Group):
...

container = Container(scope=Scope.APP, groups=[MyGroup])
```

Constructor parameters:

| Parameter | Default | Effect |
|---|---|---|
| `scope` | `Scope.APP` | The scope level this container occupies. Must be an `IntEnum`. |
| `groups` | `None` | One or more `Group` subclasses whose providers are registered into `providers_registry`. |
| `context` | `None` | Mapping of `type → object` pre-populated into `context_registry`. |
| `use_lock` | `True` | Wraps resolution in a `threading.RLock`; set `False` for single-threaded use. |
| `validate` | `False` | If `True`, runs cycle and scope-ordering checks immediately after construction. |

A root container (no `parent_container`) creates fresh `ProvidersRegistry` and `OverridesRegistry`
instances. It also auto-registers `container_provider` under the `Container` type so that any
provider declaring `Container` as a dependency receives the resolving container instance directly.

## Child containers

```python
child = container.build_child_container(scope=Scope.REQUEST, context={MyRequest: request_obj})
```

`build_child_container` creates a new `Container` whose `parent_container` is the current one.
Rules:

- The child's scope must be strictly greater (deeper) than the parent's scope. Passing `scope=None`
auto-increments to the next `IntEnum` value; if the parent is already at the maximum scope,
`MaxScopeReachedError` is raised.
- Building a child from a closed container raises `ContainerClosedError`.

The child gets its own, independent `scope_map` dict that includes all ancestors plus itself,
enabling `find_container(scope)` to walk up to any ancestor scope in O(1).

## Registry sharing

The four registries split into two categories:

| Registry | Shared across container tree? | Purpose |
|---|---|---|
| `ProvidersRegistry` | Yes — all containers share one instance | Maps `type → AbstractProvider`; populated once at root construction time from `groups`. |
| `OverridesRegistry` | Yes — all containers share one instance | Maps `provider_id → override object`; used by tests to substitute real instances. |
| `CacheRegistry` | No — each container has its own | Maps `provider_id → CacheItem`; stores resolved singleton instances and compiled kwargs for this scope level. |
| `ContextRegistry` | No — each container has its own | Maps `type → runtime object`; populated via `context=` at construction or `container.set_context()` after the fact. |

Because `ProvidersRegistry` and `OverridesRegistry` are shared, registering a group or setting an
override on any container in the tree is immediately visible to all other containers in the same
tree.

## `container_provider`

A singleton instance of `_ContainerProvider` is registered under the `Container` type in the
`ProvidersRegistry` of every root container. Its `resolve` method returns the container passed to
it, so resolving `Container` from any child yields that child container — not the root. This lets
providers at any scope depth receive the container they are resolved from as a plain constructor
dependency.

`_ContainerProvider` has `scope=Scope.APP` and `bound_type=None` (it is registered explicitly
under `Container` rather than inferred from a type annotation).

## Lifecycle: close and reopen

### Closing

`close_sync()` and `close_async()` both do two things in order:

1. **Finalizers** — iterate over the container's `CacheRegistry._creation_order` list in **reverse
(LIFO)** order and call each `CacheItem`'s finalizer if one is configured and the item has not
already been finalized. On `close_sync()`, any item whose finalizer is async raises
`AsyncFinalizerInSyncCloseError`; those items are left in `_creation_order` so a subsequent
`close_async()` can clean them up.

2. **`closed = True`** — always set in a `finally` block, even if finalizers raised. Subsequent
calls to `build_child_container` or `resolve_provider` raise `ContainerClosedError`.

Additionally, when `close_sync()` or `close_async()` is called on a **root** container (one with
no `parent_container`), all overrides are cleared from the shared `OverridesRegistry` before the
cache is finalized.

Child containers only finalize their own `CacheRegistry`; the shared `OverridesRegistry` is left
alone.

### `clear_cache` per `CacheItem`

After running a finalizer, each `CacheItem` calls `_clear()`. This checks the item's
`CacheSettings.clear_cache` flag. If `True`, the cached instance is removed (`cache` is reset to
`UNSET`) and `finalized` is reset to `False`, leaving the slot ready to be re-populated on the
next resolution. If `False` (or no `CacheSettings`), the cached value is retained after
finalization; the item is simply marked finalized.

### Reopen (context-manager protocol)

`Container` implements both sync (`__enter__` / `__exit__`) and async (`__aenter__` / `__aexit__`)
context managers.

- `__enter__` / `__aenter__` set `self.closed = False` and return `self`. No other state is reset;
`cache_registry` and `context_registry` are left as-is.
- `__exit__` calls `close_sync()`; `__aexit__` calls `close_async()`.

Concretely: using the same container object as a context manager a second time reopens it (clears
`closed`), resolves providers fresh if `clear_cache=True` was set on their `CacheSettings` (since
close removed those cached values), and then closes it again on exit. Providers whose
`CacheSettings.clear_cache` is `False` retain their cached instances across reopen cycles.

## `validate()`

`container.validate()` runs a depth-first traversal of all providers in `ProvidersRegistry`,
detecting circular dependencies and scope-ordering violations (a provider at a wider scope
depending on one at a narrower scope). Pass `validate=True` to the constructor to run this at
creation time, or call `container.validate()` explicitly at any point. It raises
`ValidationFailedError` with all collected errors if any are found.

## `set_context()`

```python
container.set_context(MyRequest, request_obj)
```

Registers a runtime value directly into the container's `ContextRegistry`. Also invalidates all
compiled kwargs in the container's `CacheRegistry` (resets `kwargs_compiled`, `provider_kwargs`,
and `static_kwargs` on every `CacheItem`) so that subsequent resolutions pick up the new context
value rather than using a stale compiled-kwargs snapshot.
166 changes: 166 additions & 0 deletions architecture/providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Provider Catalog

This document describes every provider type in `modern-di`. It is the authoritative reference; if anything here
conflicts with other documentation, the code governs.

---

## `Group` — provider namespace

`Group` is a non-instantiable base class. Attempting to instantiate it (or any subclass) raises
`GroupInstantiationError`. Its sole purpose is to act as a namespace for declaring providers as class-level
attributes:

```python
from modern_di import providers, Group, Scope

class AppProviders(Group):
db_pool = providers.Factory(scope=Scope.APP, creator=create_pool)
user_repo = providers.Factory(scope=Scope.REQUEST, creator=UserRepository)
```

`Group.get_providers()` walks the MRO and collects every class attribute that is an `AbstractProvider` instance,
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`.

### Signature

```python
Factory(
*,
scope: IntEnum = Scope.APP,
creator: Callable[..., T],
bound_type: type | None = UNSET,
kwargs: dict[str, Any] | None = None,
cache_settings: CacheSettings[T] | None = None,
skip_creator_parsing: bool = False,
)
```

### Declaration-time signature parsing

When `skip_creator_parsing=False` (the default), `Factory.__init__` calls `types_parser.parse_creator(creator)`
immediately. This extracts the return type (used as the provider's `bound_type` unless overridden) and a mapping
of parameter names to `SignatureItem` descriptors. Dependency resolution is therefore type-driven: at resolution
time each parameter is matched against the container's `providers_registry` by its annotated type.

If `bound_type` is supplied explicitly it overrides the inferred return type (useful when the creator returns a
protocol or base class narrower than the concrete type).

### Recursive resolution

When a `Factory` is resolved, `_compile_kwargs` iterates the parsed parameter map. For each parameter it looks up
a matching provider by type in the registry and recurses into `container.resolve_provider(dep_provider)`.
Resolution errors are annotated with a breadcrumb describing the current factory, so the full chain appears in the
exception.

### Static kwargs — `kwargs={}`

Pass `kwargs` to supply static (non-DI-resolved) arguments that bypass type-based resolution. 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.

### `skip_creator_parsing=True`

Disables signature introspection entirely — useful for callables whose signatures cannot be reflected (built-in C
extensions, `functools.partial`, etc.). When set without an explicit `bound_type`, a `UserWarning` is emitted
because the provider cannot be resolved by type.

---

## `CacheSettings` — singleton behavior

There is **no separate `Singleton` class**. Singleton behavior is opted into by passing a `CacheSettings` instance
to `Factory(cache_settings=...)`:

```python
providers.Factory(scope=Scope.APP, creator=Database, cache_settings=providers.CacheSettings())
```

`CacheSettings` is a `dataclass` with the following fields:

| Field | Type | Default | Purpose |
|---|---|---|---|
| `clear_cache` | `bool` | `True` | Whether the cached instance is evicted when the container closes. |
| `finalizer` | `Callable[[T], None \| Awaitable[None]] \| None` | `None` | Optional teardown called on container close, before cache eviction. |
| `is_async_finalizer` | `bool` | *(computed)* | Set automatically in `__post_init__`; `True` when `finalizer` is a coroutine function. |

`is_async_finalizer` is not an init parameter — it is derived by `inspect.iscoroutinefunction(finalizer)` in
`__post_init__`. The container uses it to decide whether to `await` the finalizer.

Without `cache_settings`, `Factory.resolve` calls the creator on every resolution and returns a fresh instance
each time.

---

## `ContextProvider` — runtime-injected values

`ContextProvider` holds a value that is supplied at container-creation time via the `context` mapping rather than
being constructed by a factory:

```python
providers.ContextProvider(scope=Scope.REQUEST, context_type=HttpRequest)
```

At resolution time it looks the value up in the container's `context_registry` for the matching scope. If no
value was supplied (the key is absent), `resolve` returns `None`. `Factory._compile_kwargs` handles the
absent-context case explicitly: if the dependent parameter has a default or is nullable it is silently satisfied;
otherwise an `ArgumentResolutionError` is raised.

---

## `Alias` — re-exporting a type under a different name

`Alias` delegates resolution to another registered provider, located by the source type:

```python
providers.Alias(source_type=ConcreteDatabase, bound_type=DatabaseProtocol)
```

This lets code that depends on `DatabaseProtocol` receive the `ConcreteDatabase` instance without the registry
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.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.

### Deprecated `scope=` parameter

Passing `scope=` to `Alias.__init__` emits a `DeprecationWarning`:

> "The `scope` parameter of Alias is deprecated and ignored: an alias's effective scope is derived from its
> source. It will be removed in a future release."

The parameter is accepted for backwards compatibility but has no effect on resolution. It will be removed in a
future release.

---

## `container_provider` — the container itself

`container_provider` is a pre-built singleton exported from `modern_di.providers`. It is automatically registered
in every container and resolves to the `Container` instance at the appropriate scope. Use it when a class needs to
accept the container as a dependency.

---

## Public exports

All provider types and `CacheSettings` are re-exported from `modern_di.providers`:

```python
from modern_di import providers

providers.Factory
providers.CacheSettings
providers.ContextProvider
providers.Alias
providers.AbstractProvider
providers.container_provider
```
Loading