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
27 changes: 25 additions & 2 deletions architecture/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,33 @@ Constructor parameters:
| `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. |
| `validate` | `None` | Tri-state, see below. |

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.

### `validate`'s three states

- `True` — runs `container.validate()` immediately after construction (see [validation](validation.md)).
- `False` — explicit opt-out: never validates, never warns. This is the only way to stay silent
once modern-di 3.0 flips the default to `True`-like behavior (see below), so it remains a
supported, permanent choice, not just a 2.x shim.
- `None` (the default) — behaves like `False` today (no validation), but on a **root** container
(`parent_container is None`) it also emits `UnvalidatedContainerWarning`
(a `FutureWarning`) pointing at the [migration guide](../docs/migration/to-3.x.md): modern-di 3.0
runs `validate()` at root construction by default, so leaving `validate` unset now is a
transitional state, not a permanent one. Child containers built via `build_child_container` never
pass `validate` explicitly, so they also reach the unset (`None`) branch — but the warning itself
additionally requires `parent_container is None`, which is false for every child, so children
never warn regardless of `validate`.

Escalate the warning to catch it in CI ahead of the 3.0 upgrade:

```python
warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning)
```

## Child containers

```python
Expand Down Expand Up @@ -144,7 +165,9 @@ exactly this: `app.on_startup(container.open)` paired with `app.after_shutdown(c
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.
`ValidationFailedError` with all collected errors if any are found — see
[validation](validation.md) for the DFS algorithm, the grouped error rendering, and the pending
3.0 default flip.

## `set_context()`

Expand Down
19 changes: 15 additions & 4 deletions architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,21 @@ being constructed by a factory:
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._resolve_context_value` handles the
absent-context case live via the shared `absent_disposition` helper: if the dependent parameter has a default or is
nullable it is silently satisfied; otherwise an `ArgumentResolutionError` is raised.
At resolution time it looks the value up in the container's `context_registry` for the matching scope. What
happens next depends on **how** the value is fetched — direct resolve vs. as another provider's dependency —
and the two paths are independent:

- **Direct resolve** (`container.resolve(HttpRequest)` / `container.resolve_provider(the_provider)` →
`ContextProvider.resolve`): if no value was supplied (the key is absent), it emits
`ContextValueNoneWarning` (a `DeprecationWarning` naming the context type and scope) and
returns `None`. modern-di 3.0 raises `ContextValueNotSetError` here instead of warning —
see the [migration guide](../docs/migration/to-3.x.md). Escalate the warning now to catch unset-context bugs
ahead of the 3.0 upgrade: `warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning)`.
- **As a dependent parameter** of another provider (e.g. a `Factory` constructor argument typed as the context
type): unchanged by the above — `Factory` reads the value via `fetch_context_value` (not `resolve`), so no
warning fires on this path. `Factory._resolve_context_value` handles the absent-context case live via the
shared `absent_disposition` helper: if the dependent parameter has a default or is nullable it is silently
satisfied; otherwise an `ArgumentResolutionError` is raised, exactly as before this warning was added.

`ContextProvider` also accepts an optional `bound_type` that overrides the inferred bound type.

Expand Down
56 changes: 51 additions & 5 deletions architecture/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ container = Container(scope=Scope.APP, groups=[MyGroup])
container.validate() # raises ValidationFailedError if any issue found
```

When `validate=False` (the default), no graph traversal occurs and there is zero runtime cost. The `validate=True`
path is equivalent to `Container(...); container.validate()` — the call is made inside `__init__` only if the
flag is set.
`validate` is tri-state (`bool | None`, default `None`) — see the [constructor table](containers.md#creating-a-root-container)
for the full parameter reference. `validate=True` runs validation inside `__init__` (equivalent to
`Container(...); container.validate()`); `validate=False` skips it silently with zero runtime cost;
leaving it unset (`None`) also skips it but emits `UnvalidatedContainerWarning` on a **root**
container, because **modern-di 3.0 runs `validate()` at root construction by default** — the
current opt-in becomes opt-out. Pass `validate=True` now to adopt the 3.0 behavior early, or
`validate=False` to keep it off permanently (that stays a supported no-warning choice after 3.0).
See the [migration guide](../docs/migration/to-3.x.md) for the full readiness recipe.

## What validate() checks

Expand All @@ -30,8 +35,9 @@ errors** across the entire walk before raising, so a single call surfaces all wi
stopping at the first one.

If any errors are found, `validate()` raises `exceptions.ValidationFailedError`, whose `.errors` attribute is a
list of all individual exceptions encountered. `ValidationFailedError.__str__` renders a human-readable count
plus per-error detail.
list of all individual exceptions encountered. `ValidationFailedError.__str__` groups `.errors` by exception
class name (sorted alphabetically) so a report mixing several error kinds reads as one section per kind rather
than an undifferentiated flat list — see [Report rendering](#report-rendering) below for the exact shape.

### Circular dependencies

Expand All @@ -40,6 +46,17 @@ During the DFS, a provider encountered a second time while it is still on the ac
type names showing the loop (e.g., `["A", "B", "A"]`). The recursive walk does **not** continue into the cycle,
but the rest of the graph continues to be checked.

`CircularDependencyError.__str__` renders `.cycle_path` as a multi-line arrow chain (the same `└─>` continuation
glyph as the `ResolutionError` dependency-chain breadcrumb), not an inline `A -> B -> A` string:

```
Circular dependency detected:
A
└─> B
└─> A
Check your provider graph for unintended cycles.
```

> **Runtime resolution has no cycle guard.** Cycle detection lives only here. To keep the resolve hot path free of
> per-resolve bookkeeping, `resolve()` does not track in-flight providers — a circular graph that is never validated
> raises a raw `RecursionError` on first resolve. Run with `validate=True` (or call `container.validate()`) in
Expand Down Expand Up @@ -68,6 +85,35 @@ and appends any returned exceptions to the error list. `Factory` implements this
`ArgumentResolutionError` for each constructor parameter that has no matching provider in `providers_registry`,
no default value, and no static `kwargs` entry.

### Report rendering

`ValidationFailedError.__str__` starts with the one-line summary (`Container.validate() found N issue(s): ...`,
used by `repr`/logging), then one section per exception class, sorted by class name, each headed
`{ClassName} ({count}):` and listing its errors as ` - ` bullets. An error's own multi-line message (e.g. a
`CircularDependencyError` arrow chain, or a `ProviderNotRegisteredError`'s "Did you mean" suggestion block) has
its first line placed after the bullet and every continuation line indented four spaces so multi-line
sub-errors read as a block rather than mangling into the bullet list. A trailing blank message line is stripped
(the `rstrip`/empty-message guard), so an error with no extra content renders as a bare ` - ` without a dangling
space.

A container with both a circular dependency and a missing provider renders as:

```
Container.validate() found 2 issue(s): CircularDependencyError, ProviderNotRegisteredError

CircularDependencyError (1):
- Circular dependency detected:
A
└─> B
└─> A
Check your provider graph for unintended cycles.

ProviderNotRegisteredError (1):
- Provider of type <class 'str'> is not registered in providers registry.
Did you mean:
Str2
```

## Effective scope and alias transparency

`validate()`'s scope-ordering check uses `provider.effective_scope(container)` on both sides of every dependency
Expand Down
209 changes: 209 additions & 0 deletions docs/migration/to-3.x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Migration Guide: Upgrading to modern-di 3.x

This document describes the changes required to migrate from modern-di 2.x to modern-di 3.0.

## Overview

modern-di 3.0 flips five switches from warn-then-continue to raise/validate-by-default. Every one
of them already has a 2.x signal — a warning that fires today wherever the 3.0 behavior would
differ. If your 2.x test suite is green with the [readiness recipe](#readiness-recipe) below
escalating those warnings to errors, the upgrade to 3.0 is a no-op for you.

## The five switches

| 3.0 change | 2.x signal |
|---|---|
| Reusing a closed container raises `ContainerClosedError` | `ContainerClosedWarning` |
| `Alias(scope=)` parameter removed | `DeprecationWarning` |
| `Factory(cache_settings=)` removed | `DeprecationWarning` |
| `validate()` runs by default at root construction | `UnvalidatedContainerWarning` |
| Direct resolve of an unset `ContextProvider` raises `ContextValueNotSetError` | `ContextValueNoneWarning` |

## Key Changes

### 1. Closed containers raise instead of self-healing

In 2.x, resolving from (or building a child of) a closed container emits `ContainerClosedWarning`
and transparently reopens the container so the call still succeeds. In 3.0 the same call raises
`ContainerClosedError` instead.

**Before (2.x):**
```python
container = Container(scope=Scope.APP, groups=[MyGroup], validate=True)
container.close_sync()

# ContainerClosedWarning: Container (scope APP) is closed; resolving from it or
# building a child is deprecated and will raise ContainerClosedError in modern-di
# 3.0. Re-enter the container with `with`/`async with`, or call `open()`, before
# reusing it.
service = container.resolve(MyService) # succeeds — container self-reopens
```

**After (3.0):**
```python
container = Container(scope=Scope.APP, groups=[MyGroup], validate=True)
container.close_sync()

service = container.resolve(MyService) # raises ContainerClosedError
```

Re-enter the container with `with`/`async with`, or call `container.open()`, before reusing it.

### 2. `Alias(scope=)` parameter removed

`Alias`'s effective scope has always been derived from its source provider; the `scope` argument
never affected resolution. In 2.x, passing it emits a `DeprecationWarning`; in 3.0 the parameter is
gone.

**Before (2.x):**
```python
from modern_di import Scope, providers

# 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.
alias = providers.Alias(source_type=DatabaseProtocol, scope=Scope.APP)
```

**After (3.0):**
```python
from modern_di import providers

alias = providers.Alias(source_type=DatabaseProtocol)
```

### 3. `Factory(cache_settings=)` removed

`cache_settings=` was the pre-`cache=` spelling for tuning a `Factory`'s cache. In 2.x it still
works but warns; in 3.0 only `cache=` is accepted.

**Before (2.x):**
```python
# DeprecationWarning: `cache_settings=` is deprecated; use `cache=` (pass
# cache=True for defaults, or cache=CacheSettings(...) to tune). It will be
# removed in a future release.
factory = providers.Factory(
scope=Scope.REQUEST,
creator=create_resource,
cache_settings=providers.CacheSettings(finalizer=lambda resource: resource.close()),
)
```

**After (3.0):**
```python
factory = providers.Factory(
scope=Scope.REQUEST,
creator=create_resource,
cache=providers.CacheSettings(finalizer=lambda resource: resource.close()),
)
```

### 4. `validate()` runs by default at root construction

In 2.x, leaving the `Container` constructor's `validate` argument unset skips validation (as
before) but emits `UnvalidatedContainerWarning`. In 3.0, leaving it unset runs `validate()`
automatically, so an invalid graph raises `ValidationFailedError` at construction time instead of
failing lazily on first resolve.

**Before (2.x):**
```python
# UnvalidatedContainerWarning: This root container was created without an
# explicit `validate` argument. modern-di 3.0 runs validate() at root
# construction by default. Pass validate=True to adopt the 3.0 behavior now,
# or validate=False to keep validation off.
container = Container(scope=Scope.APP, groups=[MyGroup])
```

**After (3.0):**
```python
# validate() runs automatically; raises ValidationFailedError if the graph
# has cycles or scope-ordering problems.
container = Container(scope=Scope.APP, groups=[MyGroup])

# Opt out of validation — this spelling works identically before and after 3.0.
container = Container(scope=Scope.APP, groups=[MyGroup], validate=False)
```

Child containers (built via `build_child_container`) never validate and never warn, in either
version — this switch only affects root construction.

### 5. Direct resolve of an unset `ContextProvider` raises

In 2.x, resolving a type backed by a `ContextProvider` with no value set emits
`ContextValueNoneWarning` and returns `None`. In 3.0 the same call raises
`ContextValueNotSetError`. This only affects a *direct* resolve of the context type; a `Factory`
parameter backed by the same `ContextProvider` continues to follow its own
default/nullable/required disposition, unchanged.

**Before (2.x):**
```python
# ContextValueNoneWarning: No context value is set for <class '...'> (scope
# APP); returning None. modern-di 3.0 raises ContextValueNotSetError here.
# Pass context={...} to the container or call set_context().
value = container.resolve(SomeContextType) # None
```

**After (3.0):**
```python
value = container.resolve(SomeContextType) # raises ContextValueNotSetError
```

Pass `context={SomeContextType: value}` to the container (or its ancestor at the
`ContextProvider`'s scope), or call `container.set_context(SomeContextType, value)`, before
resolving.

## Readiness recipe

`ContainerClosedWarning` and `ContextValueNoneWarning` subclass `DeprecationWarning`;
`UnvalidatedContainerWarning` subclasses `FutureWarning`; the `Alias(scope=)` and
`Factory(cache_settings=)` warnings are plain `DeprecationWarning` (they have no dedicated
subclass). Escalating both categories to errors therefore turns all five signals into failures a
green test suite would catch:

```python
import warnings

warnings.filterwarnings("error", category=DeprecationWarning)
warnings.filterwarnings("error", category=FutureWarning)
```

plus the pytest variant:

```toml
[tool.pytest.ini_options]
filterwarnings = [
"error::DeprecationWarning",
"error::FutureWarning",
]
```

!!! warning "Don't add a `module=` filter here"
It's tempting to scope the filter to modern-di with
`module=r"modern_di(\..*)?"`, but that argument matches the module of the *warned-from*
frame at the warning's `stacklevel`, not the module that owns the warning class. Three of the
five signals (`UnvalidatedContainerWarning`, and the `Alias(scope=)` / `Factory(cache_settings=)`
warnings) are raised directly inside the constructor call with `stacklevel=2`, which attributes
them to *your* calling module — not `modern_di` — so a `module=r"modern_di(\..*)?"` filter
silently fails to escalate them. The other two (`ContainerClosedWarning`,
`ContextValueNoneWarning`) fire deep inside a resolve call, where the `stacklevel=2` frame
happens to still be inside `modern_di`, so they *would* match — the inconsistency is exactly
why `module=` isn't part of the recipe above.

If the broad category filter is too wide for your process (e.g. another dependency's
`DeprecationWarning`s should stay warnings), escalate the three dedicated subclasses individually
instead — this covers switches 1, 4, and 5 precisely, but not 2 and 3, since those two have no
dedicated class in 2.x:

```python
from modern_di import exceptions

warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning)
warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning)
warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning)
```

## Deprecation policy

Every breaking change in modern-di is warned for at least one minor release cycle before it flips
or is removed at the next major. If you're on a 2.x release and see none of the five warnings
above under the readiness recipe, upgrading to 3.0 requires no code changes on your part.
Loading
Loading