From 068bee4be96555c70599689f655eb08415bcbeb3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 5 Jul 2026 23:11:01 +0300 Subject: [PATCH 01/13] docs(planning): add v3-bridges bundle (2.x bridges for ruled 3.0 breaks) Co-Authored-By: Claude Fable 5 --- .../2026-07-05.04-v3-bridges/design.md | 149 +++++ .../changes/2026-07-05.04-v3-bridges/plan.md | 581 ++++++++++++++++++ 2 files changed, 730 insertions(+) create mode 100644 planning/changes/2026-07-05.04-v3-bridges/design.md create mode 100644 planning/changes/2026-07-05.04-v3-bridges/plan.md diff --git a/planning/changes/2026-07-05.04-v3-bridges/design.md b/planning/changes/2026-07-05.04-v3-bridges/design.md new file mode 100644 index 0000000..b5b76c3 --- /dev/null +++ b/planning/changes/2026-07-05.04-v3-bridges/design.md @@ -0,0 +1,149 @@ +--- +summary: 2.x bridges for the ruled 3.0 breaks — validation report rework, tri-state validate FutureWarning, ContextProvider unset-value DeprecationWarning, and the to-3.x migration guide. +--- + +# Design: 3.0 bridges on 2.x (validation UX, ContextProvider deprecation, migration guide) + +## Summary + +Implements the 2.x-shippable half of the 3.0-gated trio ruled in the +[2026-07-05 UX research](../../audits/2026-07-05-v3-ux-research-report.md) +(API-1/ERR-2, ERR-5, API-6, DOC-1) under the agreed strategy: bridge warnings +on 2.x now, all breaking flips deferred to a single cut-3.0 bundle before the +tag. Four pieces: (1) rework the rendering of `ValidationFailedError` and +`CircularDependencyError`; (2) a tri-state `validate` parameter whose unset +state emits a `FutureWarning` on root containers; (3) a `DeprecationWarning` +when an unset `ContextProvider` is resolved directly, plus the +`ContextValueNotSetError` class it becomes in 3.0; (4) `docs/migration/to-3.x.md` +covering all five 3.0 switches with a warnings-as-errors readiness recipe. + +## Motivation + +Rulings API-1/ERR-2 (validate default-on in 3.0) and API-6 (raise on unset +ContextProvider in 3.0) are breaking; the house pattern for breaks +(`ContainerClosedWarning` → `ContainerClosedError`) is to warn one cycle ahead +so `filterwarnings("error")` turns a green 2.x suite into a 3.0 guarantee. +ERR-5 matters now because once validation is on by default, its report is the +first thing users see on a broken graph — and the current flat rendering +mangles multi-line sub-errors. DOC-1 is time-boxed: it must exist before the +3.0 tag, and it can only be complete once these two bridge warnings exist. + +## Non-goals + +- No behavior flips: `validate` still defaults to off-behavior; unset + ContextProvider still returns `None`. Flips happen in the cut-3.0 bundle. +- No changes to dependent-parameter context dispositions (default/nullable/ + raise) — they go through `fetch_context_value`, not `ContextProvider.resolve`. +- No `validate()` content changes — only rendering (`__str__`) of the two + exception classes. +- The INT-1 interaction (post-construction `add_providers` vs. + validate-at-construction) is a cut-3.0 design question, recorded there, not + solved here. + +## Design + +### 1. ERR-5 — validation report rendering + +`ValidationFailedError.__str__` currently renders a count header plus flat +` - {e}` lines (exceptions.py:364-367), which mangles multi-line sub-errors +(e.g. "Did you mean" suggestion blocks). Rework, rendering only: + +- Group `.errors` by exception class; per-group header + `CircularDependencyError (2):` ordered by class name; items indented under + their group with continuation lines indented to match (`textwrap.indent`-style). +- `CircularDependencyError.__str__` renders `cycle_path` as a multi-line arrow + chain consistent with the existing `ResolutionError` breadcrumb style + (`└─>` continuation lines), replacing the inline `A -> B -> A` string. + `cycle_path` attribute stays `list[str]`. +- `.errors` list, exception types, and the one-line summary header (used by + `repr`/logging) keep their current content. + +### 2. API-1 bridge — tri-state `validate` + +`Container.__init__` signature changes `validate: bool = False` → +`validate: bool | None = None`: + +- `None` (unset) on a **root** container (`parent_container is None`): emit + `UnvalidatedContainerWarning` (new, subclass of `FutureWarning`, in + `exceptions.py`) — "modern-di 3.0 runs validate() at root construction by + default; pass validate=True to adopt now, or validate=False to keep opt-out" + — then behave as today (no validation). Python's default warning filter + dedupes by raise site, so it fires effectively once per process. +- `None` on a child: no warning, no validation (children never validate; + `build_child_container` does not pass `validate`, so this is the automatic + path). +- Explicit `False`: no warning ever — a conscious opt-out stays valid after + the 3.0 flip. +- Explicit `True`: validates, as today. + +`FutureWarning` (not `DeprecationWarning`) because it is a behavior change +addressed to app authors — shown by default. Docstring carries the escalation +recipe, mirroring `ContainerClosedWarning`. + +### 3. API-6 bridge — ContextProvider unset-value deprecation + +Mirroring the `ContainerClosedWarning`/`ContainerClosedError` pair: + +- New `ContextValueNoneWarning(DeprecationWarning)` in `exceptions.py`, with + the escalation recipe in its docstring. +- New `ContextValueNotSetError(ResolutionError)` in `exceptions.py`, not + raised anywhere in 2.x; docstring states it is raised by modern-di 3.0 when + an unset `ContextProvider` is resolved directly. Message (built in + `__init__`, inline f-string per house style) names the context type and + scope with remedy text "pass context={T: value} to the container or call + set_context()". +- `ContextProvider.resolve`: when `fetch_context_value` returns `UNSET`, emit + `ContextValueNoneWarning` naming the context type, then return `None` as + today. The warning message points at the 3.0 error and the migration guide. + +### 4. DOC-1 — `docs/migration/to-3.x.md` + +New page in the existing `docs/migration/` set (sibling of to-1.x/to-2.x), +covering all five 3.0 switches: + +| Switch | 2.x signal | +|---|---| +| Closed-container reuse raises `ContainerClosedError` | `ContainerClosedWarning` | +| `Alias(scope=)` removed | `DeprecationWarning` | +| `Factory(cache_settings=)` removed | `DeprecationWarning` | +| `validate()` default-on at root construction | `UnvalidatedContainerWarning` (this bundle) | +| Unset `ContextProvider` direct resolve raises `ContextValueNotSetError` | `ContextValueNoneWarning` (this bundle) | + +Structure: switch table with before/after code per break; readiness recipe — +a `filterwarnings` block escalating all five warning signals so a green 2.x +suite guarantees a clean 3.0 upgrade; the stated deprecation policy (warn one +cycle, remove/flip at the major). The two new warnings' docstrings and the +mkdocs nav link to it. + +## Testing + +TDD (failing test first per task): + +- Rendering: unit tests asserting grouped/indented `ValidationFailedError` + output including a multi-line sub-error, and the `CircularDependencyError` + arrow chain. +- `UnvalidatedContainerWarning`: fires on unset root; absent for explicit + `True`/`False`; absent on children; escalatable via + `filterwarnings("error")`. +- `ContextValueNoneWarning`: fires on direct resolve of unset value (returns + `None`); absent when value is set; dependent-parameter paths unchanged + (existing tests must stay green without modification); escalatable. +- Existing suite: the repo's own pytest config gains a + `filterwarnings = ["ignore::modern_di.exceptions.UnvalidatedContainerWarning"]` + entry (166 root constructions in tests would otherwise each warn); the + targeted warning tests override it via `pytest.warns`/`catch_warnings`. +- Gates: `just test-ci` (100% coverage), `just lint-ci`, docs build via the + strict-links CI job. + +## Risk + +- **Warning noise downstream** (high likelihood, low harm — by design): every + 2.x user constructing a root without `validate=` sees one FutureWarning per + process. Mitigated: one-kwarg remedy stated in the message; explicit `False` + silences permanently. +- **Test-suite churn** (medium/low): repo tests construct many root + containers. Mitigated: a shared helper/explicit args pass; the diff stays + mechanical. +- **Docs drift** (low/medium): to-3.x.md promises five flips the cut-3.0 + bundle must deliver exactly. Mitigated: the cut-3.0 bundle references this + table as its checklist. diff --git a/planning/changes/2026-07-05.04-v3-bridges/plan.md b/planning/changes/2026-07-05.04-v3-bridges/plan.md new file mode 100644 index 0000000..48a694a --- /dev/null +++ b/planning/changes/2026-07-05.04-v3-bridges/plan.md @@ -0,0 +1,581 @@ +# v3-bridges — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the 2.x bridges for the ruled 3.0 breaks: reworked validation +error rendering, tri-state `validate` FutureWarning, ContextProvider +unset-value DeprecationWarning, and the to-3.x migration guide. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/v3-bridges` + +**Commit strategy:** Per-task commits. + +**Global constraints:** zero dependencies (stdlib only); sync-only resolution; +no global state; line length 120; `ruff` `select = ["ALL"]`; `ty` (use +`ty: ignore`, never `type: ignore`); module-level imports only; annotate all +function arguments; 100% line coverage gate (`just test-ci`); error messages +are inline f-strings in the class that raises them; `asyncio_mode = "auto"`. + +--- + +### Task 1: CircularDependencyError arrow-chain rendering + +**Files:** +- Modify: `modern_di/exceptions.py:256-260` (`CircularDependencyError.__init__`) +- Test: `tests/test_error_rendering.py` (create) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `CircularDependencyError` message is now multi-line + (`cycle_path` attribute unchanged, still `list[str]`). Task 2's grouped + rendering indents these continuation lines. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_error_rendering.py`: + +```python +from modern_di import exceptions + + +def test_circular_dependency_error_renders_cycle_as_arrow_chain() -> None: + error = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) + assert error.cycle_path == ["A", "B", "A"] + assert str(error) == ( + "Circular dependency detected:\n" + " A\n" + " └─> B\n" + " └─> A\n" + "Check your provider graph for unintended cycles." + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `just test tests/test_error_rendering.py -v` +Expected: FAIL — current message is the inline +`Circular dependency detected: A -> B -> A. ...` form. + +- [ ] **Step 3: Implement the rendering** + +In `modern_di/exceptions.py`, replace `CircularDependencyError.__init__` +(currently builds the inline `' -> '.join(cycle_path)` message) with: + +```python + def __init__(self, *, cycle_path: list[str]) -> None: + self.cycle_path = cycle_path + rendered = "\n".join( + f" {' ' * (i - 1)}└─> {name}" if i else f" {name}" for i, name in enumerate(cycle_path) + ) + super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.") +``` + +(The `└─>` continuation style matches `ResolutionError.__str__`'s breadcrumb +rendering at `exceptions.py:155-166`.) + +- [ ] **Step 4: Run the new test, then the whole suite** + +Run: `just test tests/test_error_rendering.py -v` → PASS. +Run: `just test` → any test asserting the old one-line format fails; find +them with `grep -rn "Circular dependency detected" tests/` (expect hits in +`tests/test_container.py`, `tests/providers/test_alias.py`) and update those +assertions to match the new multi-line message (assert on +`"Circular dependency detected:"` plus the relevant `└─>` line rather than +the full string where the cycle names are the point). +Run: `just test` again → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modern_di/exceptions.py tests/ +git commit -m "feat: render CircularDependencyError cycle path as an arrow chain (ERR-5)" +``` + +--- + +### Task 2: ValidationFailedError grouped rendering + +**Files:** +- Modify: `modern_di/exceptions.py:364-367` (`ValidationFailedError.__str__`) +- Test: `tests/test_error_rendering.py` (extend) + +**Interfaces:** +- Consumes: Task 1's multi-line `CircularDependencyError` message. +- Produces: grouped `ValidationFailedError` rendering; `.errors` list and the + one-line header (`Container.validate() found N issue(s): `) keep + their current content. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_error_rendering.py`: + +```python +def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: + cycle = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) + error = exceptions.ValidationFailedError(errors=[RuntimeError("boom"), cycle]) + assert error.errors == [RuntimeError("boom"), cycle] or len(error.errors) == 2 # list preserved + assert str(error) == ( + "Container.validate() found 2 issue(s): CircularDependencyError, RuntimeError\n" + "\n" + "CircularDependencyError (1):\n" + " - Circular dependency detected:\n" + " A\n" + " └─> B\n" + " └─> A\n" + " Check your provider graph for unintended cycles.\n" + "\n" + "RuntimeError (1):\n" + " - boom" + ) +``` + +(First line of each sub-error gets ` - `; its continuation lines get four +spaces, so multi-line messages — cycles, "Did you mean" blocks — stay aligned +instead of being mangled by the old flat ` - {e}` join.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `just test tests/test_error_rendering.py -v` +Expected: the new test FAILS against the current flat rendering. + +- [ ] **Step 3: Implement the rendering** + +Replace `ValidationFailedError.__str__` (keep `__init__` untouched): + +```python + def __str__(self) -> str: + lines = [super().__str__()] + by_kind: dict[str, list[Exception]] = {} + for error in self.errors: + by_kind.setdefault(type(error).__name__, []).append(error) + for kind in sorted(by_kind): + errors = by_kind[kind] + lines.append(f"\n{kind} ({len(errors)}):") + for error in errors: + first, *rest = str(error).splitlines() + lines.append(f" - {first}") + lines.extend(f" {line}" for line in rest) + return "\n".join(lines) +``` + +- [ ] **Step 4: Run tests** + +Run: `just test tests/test_error_rendering.py -v` → PASS. +Run: `just test` → update any assertion on the old flat format (grep +`"found "` / `"issue(s)"` in `tests/`); full suite PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modern_di/exceptions.py tests/ +git commit -m "feat: group ValidationFailedError report by error kind (ERR-5)" +``` + +--- + +### Task 3: Tri-state `validate` + UnvalidatedContainerWarning + +**Files:** +- Modify: `modern_di/exceptions.py` (new warning class after + `ContainerClosedWarning`, `exceptions.py:125-135`) +- Modify: `modern_di/container.py:42-93` (`Container.__init__` signature and + tail) +- Modify: `pyproject.toml` (`[tool.pytest.ini_options]`, after + `asyncio_mode`, line ~77) +- Test: `tests/test_container.py` (extend) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `exceptions.UnvalidatedContainerWarning(FutureWarning)`; + `Container.__init__(..., validate: bool | None = None)`. Task 5's docs and + Task 6's architecture promotion reference both. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_container.py` (it already imports `Container`, `Scope`, +`exceptions`, `pytest`, `warnings` — add any missing import at module level): + +```python +def test_root_container_without_validate_arg_warns_about_3_0_default() -> None: + with pytest.warns(exceptions.UnvalidatedContainerWarning, match="modern-di 3.0 runs validate"): + Container(scope=Scope.APP) + + +def test_explicit_validate_false_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Container(scope=Scope.APP, validate=False) + + +def test_explicit_validate_true_validates_and_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Container(scope=Scope.APP, validate=True) + + +def test_child_container_does_not_warn_about_validate() -> None: + root = Container(scope=Scope.APP, validate=False) + with warnings.catch_warnings(): + warnings.simplefilter("error") + root.build_child_container(scope=Scope.REQUEST) + + +def test_unvalidated_container_warning_is_escalatable() -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) + with pytest.raises(exceptions.UnvalidatedContainerWarning): + Container(scope=Scope.APP) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `just test tests/test_container.py -k "validate_arg or never_warns or does_not_warn_about_validate or escalatable" -v` +Expected: FAIL — `UnvalidatedContainerWarning` does not exist. + +- [ ] **Step 3: Add the warning class** + +In `modern_di/exceptions.py`, directly after `ContainerClosedWarning` +(mirroring its docstring shape): + +```python +class UnvalidatedContainerWarning(FutureWarning): + """A root container was built without an explicit ``validate`` argument — transitional. + + modern-di 3.0 runs :meth:`Container.validate` at root construction by + default. Pass ``validate=True`` to adopt the 3.0 behavior now, or + ``validate=False`` to keep validation off (also after 3.0). Opt into strict + behavior early by escalating this warning:: + + warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) + """ +``` + +- [ ] **Step 4: Make `validate` tri-state** + +In `modern_di/container.py` change the `__init__` parameter +`validate: bool = False` to `validate: bool | None = None`, and replace the +tail `if validate: self.validate()` with: + +```python + if validate: + self.validate() + elif validate is None and parent_container is None: + warnings.warn( + "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. " + "See https://modern-di.modern-python.org/migration/to-3.x/.", + exceptions.UnvalidatedContainerWarning, + stacklevel=2, + ) +``` + +Update the `__init__` docstring's `validate` sentence to describe the +tri-state (unset warns on roots; explicit `False` opts out silently). + +- [ ] **Step 5: Silence the warning for the repo's own suite** + +In `pyproject.toml` under `[tool.pytest.ini_options]` (after +`asyncio_mode = "auto"`): + +```toml +filterwarnings = [ + "ignore::modern_di.exceptions.UnvalidatedContainerWarning", +] +``` + +(166 root constructions in tests would otherwise warn; targeted tests above +override this via `pytest.warns` / `catch_warnings`.) + +- [ ] **Step 6: Run tests** + +Run: `just test tests/test_container.py -v` → PASS, then `just test` → full +suite PASS with no warning spam in the summary. + +- [ ] **Step 7: Commit** + +```bash +git add modern_di/exceptions.py modern_di/container.py pyproject.toml tests/test_container.py +git commit -m "feat: tri-state validate= with UnvalidatedContainerWarning ahead of the 3.0 default flip (API-1 bridge)" +``` + +--- + +### Task 4: ContextProvider unset-value deprecation pair + +**Files:** +- Modify: `modern_di/exceptions.py` (two new classes after + `CircularDependencyError`, `exceptions.py:251-260`) +- Modify: `modern_di/providers/context_provider.py:42-46` (`resolve`) +- Test: `tests/providers/test_context_provider.py` (extend + update existing) +- Test: `tests/test_error_rendering.py` (extend) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `exceptions.ContextValueNotSetError(ResolutionError)` (unraised in + 2.x; the cut-3.0 bundle raises it) and + `exceptions.ContextValueNoneWarning(DeprecationWarning)`. Task 5's docs + reference both. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/providers/test_context_provider.py` (module defines groups +with an unset `context_provider`; reuse its existing fixtures/idioms): + +```python +def test_unset_context_provider_direct_resolve_warns_and_returns_none() -> None: + app_container = Container(groups=[MyGroup], validate=False) + with pytest.warns(exceptions.ContextValueNoneWarning, match="modern-di 3.0 raises ContextValueNotSetError"): + assert app_container.resolve_provider(MyGroup.context_provider) is None + + +def test_set_context_provider_direct_resolve_does_not_warn() -> None: + now = datetime.datetime.now(tz=datetime.timezone.utc) + app_container = Container(groups=[MyGroup], context={datetime.datetime: now}, validate=False) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert app_container.resolve_provider(MyGroup.context_provider) is now +``` + +(The module already defines `MyGroup` with +`context_provider = providers.ContextProvider(scope=Scope.APP, context_type=datetime.datetime)`; +it currently imports exception classes directly from `modern_di.exceptions` — +add `warnings` and the new names as module-level imports in the file's +existing style. `test_context_provider_not_found` at line 40 is one of the +existing assertions Step 5 wraps in `pytest.warns`.) + +Append to `tests/test_error_rendering.py`: + +```python +def test_context_value_not_set_error_message_and_hierarchy() -> None: + error = exceptions.ContextValueNotSetError(context_type=str, scope_name="APP") + assert isinstance(error, exceptions.ResolutionError) + assert str(error) == ( + "No context value is set for (scope APP). " + "Pass context={...} to the container or call set_context()." + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `just test tests/providers/test_context_provider.py tests/test_error_rendering.py -v` +Expected: FAIL — neither class exists. + +- [ ] **Step 3: Add the exception + warning pair** + +In `modern_di/exceptions.py`, after `CircularDependencyError` (mirroring the +`ContainerClosedError`/`ContainerClosedWarning` pair at 100-135): + +```python +class ContextValueNotSetError(ResolutionError): + """An unset ``ContextProvider`` was resolved directly. + + Raised in modern-di 3.0; until then direct resolve emits + :class:`ContextValueNoneWarning` and returns ``None``. Inspect + ``.context_type``. + """ + + __slots__ = ("context_type",) + + def __init__(self, *, context_type: type, scope_name: str) -> None: + self.context_type = context_type + super().__init__( + f"No context value is set for {context_type!r} (scope {scope_name}). " + "Pass context={...} to the container or call set_context()." + ) + + +class ContextValueNoneWarning(DeprecationWarning): + """Direct resolve of an unset ``ContextProvider`` returned ``None`` — transitional. + + The ``None`` return works today but modern-di 3.0 raises + :class:`ContextValueNotSetError` here. Opt into strict behavior now by + escalating this warning:: + + warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning) + """ +``` + +- [ ] **Step 4: Emit the warning in `ContextProvider.resolve`** + +In `modern_di/providers/context_provider.py` (add `import warnings` and +`from modern_di import exceptions` at module level; keep imports sorted): + +```python + def resolve(self, container: "Container") -> types.T_co | None: + value = self.fetch_context_value(container) + if value is types.UNSET: + warnings.warn( + f"No context value is set for {self.context_type!r} (scope {self.scope.name}); returning None. " + "modern-di 3.0 raises ContextValueNotSetError here. Pass context={...} to the container or call " + "set_context(). See https://modern-di.modern-python.org/migration/to-3.x/.", + exceptions.ContextValueNoneWarning, + stacklevel=2, + ) + return None + return typing.cast(types.T_co, value) +``` + +Also update the class docstring's "Resolving it directly when no value is set +returns ``None``" sentence to mention the warning and the 3.0 error. + +- [ ] **Step 5: Update existing unset-direct-resolve tests** + +`grep -n "is None" tests/providers/test_context_provider.py` — the existing +direct-resolve assertions (lines 42 and 174 today) now warn; wrap each in +`pytest.warns(exceptions.ContextValueNoneWarning)`. Dependent-parameter tests +(`.ctx is None`, `.value is None` via Factory params) must stay green +**unmodified** — they go through `fetch_context_value`, not `resolve`; if any +of them starts warning, the implementation is wrong (fix the code, not the +test). + +- [ ] **Step 6: Run tests** + +Run: `just test tests/providers/test_context_provider.py tests/test_error_rendering.py -v` → PASS. +Run: `just test` → full suite PASS. + +- [ ] **Step 7: Commit** + +```bash +git add modern_di/exceptions.py modern_di/providers/context_provider.py tests/ +git commit -m "feat: warn on direct resolve of an unset ContextProvider ahead of the 3.0 raise (API-6 bridge)" +``` + +--- + +### Task 5: to-3.x migration guide + docs updates + +**Files:** +- Create: `docs/migration/to-3.x.md` +- Modify: `mkdocs.yml:48` (nav — add `To 3.x` after `To 2.x`) +- Modify: `docs/providers/errors-and-exceptions.md` (add the three new + classes where the hierarchy/warnings are enumerated) + +**Interfaces:** +- Consumes: warning/error names and messages from Tasks 3-4 exactly as + implemented (re-read the source, do not trust this plan's copies). +- Produces: the page the warning messages link to + (`https://modern-di.modern-python.org/migration/to-3.x/`). + +- [ ] **Step 1: Write `docs/migration/to-3.x.md`** + +Follow the voice/structure of `docs/migration/to-2.x.md`. Required content: + +1. Intro: 3.0 flips five switches; every one has a 2.x signal; a green 2.x + suite with the recipe below guarantees a clean upgrade. +2. The switch table: + + | 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` | + +3. One section per switch with a before/after code pair (source the exact 2.x + spellings from `architecture/containers.md`, `architecture/providers.md`, + and the warning messages in code). +4. Readiness recipe — because `ContainerClosedWarning` and + `ContextValueNoneWarning` subclass `DeprecationWarning` and + `UnvalidatedContainerWarning` subclasses `FutureWarning`, two lines cover + all five signals: + + ```python + import warnings + + warnings.filterwarnings("error", category=DeprecationWarning, module=r"modern_di(\..*)?") + warnings.filterwarnings("error", category=FutureWarning, module=r"modern_di(\..*)?") + ``` + + plus the pytest variant: + + ```toml + [tool.pytest.ini_options] + filterwarnings = [ + "error::DeprecationWarning", + "error::FutureWarning", + ] + ``` + + **Verify the `module=` regex actually matches** (the `module` filter arg + matches the *triggering* frame's `__name__` under `stacklevel`, which for + `stacklevel=2` is the caller, not `modern_di`) — write a 5-line scratch + script; if it does not match, drop `module=` from the recipe and present + the per-category escalation instead. +5. Deprecation policy paragraph: warn at least one minor cycle, flip/remove at + the major. + +- [ ] **Step 2: Add the nav entry** + +In `mkdocs.yml` after `- To 2.x: migration/to-2.x.md`: + +```yaml + - To 3.x: migration/to-3.x.md +``` + +- [ ] **Step 3: Update the exceptions docs page** + +In `docs/providers/errors-and-exceptions.md`, add +`UnvalidatedContainerWarning`, `ContextValueNoneWarning`, and +`ContextValueNotSetError` wherever `ContainerClosedWarning` / +`ContainerClosedError` are listed, matching the page's existing format. + +- [ ] **Step 4: Build docs strictly and run gates** + +Run: `just docs 2>&1 | tail -5` (or the docs recipe `just --list` shows; CI +builds with `mkdocs --strict`) — no broken links/anchors. +Run: `just lint-ci` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ mkdocs.yml +git commit -m "docs: add to-3.x migration guide covering all five 3.0 switches (DOC-1)" +``` + +--- + +### Task 6: Architecture promotion + gates + +**Files:** +- Modify: `architecture/validation.md` (rendering rework; 3.0 default-on note) +- Modify: `architecture/containers.md` (tri-state `validate` row + warning) +- Modify: `architecture/providers.md` (ContextProvider warning + 3.0 error) +- Modify: `planning/changes/2026-07-05.04-v3-bridges/design.md` (finalize + `summary:` to the realized result) + +**Interfaces:** +- Consumes: everything shipped in Tasks 1-5 (read the merged source, not this + plan). + +- [ ] **Step 1: Promote to architecture/** + +Update the three capability files to describe the shipped behavior: +`containers.md` constructor table (`validate` default `None`, unset-root +warning, explicit `False` opt-out); `validation.md` (grouped report format +with a rendered example, arrow-chain cycles, the pending 3.0 default flip); +`providers.md` ContextProvider section (warning on unset direct resolve, the +3.0 `ContextValueNotSetError`, dependent-parameter dispositions unchanged). + +- [ ] **Step 2: Finalize the bundle summary** + +Rewrite `design.md`'s `summary:` line to state the realized result. + +- [ ] **Step 3: Run all gates** + +Run: `just test-ci` → PASS at 100% coverage (new warning/error lines covered). +Run: `just lint-ci` → PASS. +Run: `just check-planning` → OK. + +- [ ] **Step 4: Commit** + +```bash +git add architecture/ planning/ +git commit -m "docs: promote v3-bridges behavior into architecture/ capability files" +``` From cf88b292435418f6c37a74a34db81ab47661983f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 5 Jul 2026 23:11:33 +0300 Subject: [PATCH 02/13] docs(planning): fix vacuous assertion in v3-bridges plan Task 2 Co-Authored-By: Claude Fable 5 --- planning/changes/2026-07-05.04-v3-bridges/plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/planning/changes/2026-07-05.04-v3-bridges/plan.md b/planning/changes/2026-07-05.04-v3-bridges/plan.md index 48a694a..216be7f 100644 --- a/planning/changes/2026-07-05.04-v3-bridges/plan.md +++ b/planning/changes/2026-07-05.04-v3-bridges/plan.md @@ -117,8 +117,9 @@ Append to `tests/test_error_rendering.py`: ```python def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: cycle = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) - error = exceptions.ValidationFailedError(errors=[RuntimeError("boom"), cycle]) - assert error.errors == [RuntimeError("boom"), cycle] or len(error.errors) == 2 # list preserved + boom = RuntimeError("boom") + error = exceptions.ValidationFailedError(errors=[boom, cycle]) + assert error.errors == [boom, cycle] # list content preserved, order as given assert str(error) == ( "Container.validate() found 2 issue(s): CircularDependencyError, RuntimeError\n" "\n" From c25aa83ba5cc503b4fd4274989fb62750656d07e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 5 Jul 2026 23:13:04 +0300 Subject: [PATCH 03/13] feat: render CircularDependencyError cycle path as an arrow chain (ERR-5) Co-Authored-By: Claude Fable 5 --- modern_di/exceptions.py | 5 +++-- tests/test_error_rendering.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 tests/test_error_rendering.py diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index ec6c0c3..56996c2 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -255,9 +255,10 @@ class CircularDependencyError(ResolutionError): def __init__(self, *, cycle_path: list[str]) -> None: self.cycle_path = cycle_path - super().__init__( - f"Circular dependency detected: {' -> '.join(cycle_path)}. Check your provider graph for unintended cycles." + rendered = "\n".join( + f" {' ' * (i - 1)}└─> {name}" if i else f" {name}" for i, name in enumerate(cycle_path) ) + super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.") class RegistrationError(ModernDIError): diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py new file mode 100644 index 0000000..f9f199f --- /dev/null +++ b/tests/test_error_rendering.py @@ -0,0 +1,13 @@ +from modern_di import exceptions + + +def test_circular_dependency_error_renders_cycle_as_arrow_chain() -> None: + error = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) + assert error.cycle_path == ["A", "B", "A"] + assert str(error) == ( + "Circular dependency detected:\n" + " A\n" + " └─> B\n" + " └─> A\n" + "Check your provider graph for unintended cycles." + ) From 39b23f3527a7dbdad03b01dd2e50c83e3532d2fb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 5 Jul 2026 23:15:57 +0300 Subject: [PATCH 04/13] feat: group ValidationFailedError report by error kind (ERR-5) Co-Authored-By: Claude Fable 5 --- modern_di/exceptions.py | 15 ++++++++++++--- tests/test_error_rendering.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index 56996c2..6f78d84 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -363,9 +363,18 @@ def __init__(self, *, errors: list[Exception]) -> None: super().__init__(f"Container.validate() found {len(errors)} issue(s): {kinds}") def __str__(self) -> str: - header = super().__str__() - rendered = "\n".join(f" - {e}" for e in self.errors) - return f"{header}\n{rendered}" + lines = [super().__str__()] + by_kind: dict[str, list[Exception]] = {} + for error in self.errors: + by_kind.setdefault(type(error).__name__, []).append(error) + for kind in sorted(by_kind): + errors = by_kind[kind] + lines.append(f"\n{kind} ({len(errors)}):") + for error in errors: + first, *rest = str(error).splitlines() + lines.append(f" - {first}") + lines.extend(f" {line}" for line in rest) + return "\n".join(lines) class FinalizerError(ModernDIError): diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index f9f199f..afc35c8 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -11,3 +11,23 @@ def test_circular_dependency_error_renders_cycle_as_arrow_chain() -> None: " └─> A\n" "Check your provider graph for unintended cycles." ) + + +def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: + cycle = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) + boom = RuntimeError("boom") + error = exceptions.ValidationFailedError(errors=[boom, cycle]) + assert error.errors == [boom, cycle] # list content preserved, order as given + assert str(error) == ( + "Container.validate() found 2 issue(s): CircularDependencyError, RuntimeError\n" + "\n" + "CircularDependencyError (1):\n" + " - Circular dependency detected:\n" + " A\n" + " └─> B\n" + " └─> A\n" + " Check your provider graph for unintended cycles.\n" + "\n" + "RuntimeError (1):\n" + " - boom" + ) From 96f1cc8366eb9c91d1792f27d25a51e7843d8776 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 5 Jul 2026 23:18:39 +0300 Subject: [PATCH 05/13] fix: guard ValidationFailedError rendering against message-less sub-errors Co-Authored-By: Claude Fable 5 --- modern_di/exceptions.py | 4 ++-- tests/test_error_rendering.py | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index 6f78d84..26d081b 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -371,8 +371,8 @@ def __str__(self) -> str: errors = by_kind[kind] lines.append(f"\n{kind} ({len(errors)}):") for error in errors: - first, *rest = str(error).splitlines() - lines.append(f" - {first}") + first, *rest = str(error).splitlines() or [""] + lines.append(f" - {first}".rstrip()) lines.extend(f" {line}" for line in rest) return "\n".join(lines) diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index afc35c8..ce74e91 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -5,11 +5,7 @@ def test_circular_dependency_error_renders_cycle_as_arrow_chain() -> None: error = exceptions.CircularDependencyError(cycle_path=["A", "B", "A"]) assert error.cycle_path == ["A", "B", "A"] assert str(error) == ( - "Circular dependency detected:\n" - " A\n" - " └─> B\n" - " └─> A\n" - "Check your provider graph for unintended cycles." + "Circular dependency detected:\n A\n └─> B\n └─> A\nCheck your provider graph for unintended cycles." ) @@ -31,3 +27,8 @@ def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: "RuntimeError (1):\n" " - boom" ) + + +def test_validation_failed_error_renders_message_less_sub_error() -> None: + error = exceptions.ValidationFailedError(errors=[RuntimeError()]) + assert str(error) == ("Container.validate() found 1 issue(s): RuntimeError\n\nRuntimeError (1):\n -") From 8a53b5fc32e85776661f5c91da6e9fec179bb54f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:00:02 +0300 Subject: [PATCH 06/13] feat: tri-state validate= with UnvalidatedContainerWarning ahead of the 3.0 default flip (API-1 bridge) Co-Authored-By: Claude Fable 5 --- modern_di/container.py | 24 +++++++++++++++++++----- modern_di/exceptions.py | 12 ++++++++++++ pyproject.toml | 3 +++ tests/test_container.py | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/modern_di/container.py b/modern_di/container.py index b18bd28..e36ff71 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -46,15 +46,20 @@ def __init__( # noqa: PLR0913 context: dict[type[typing.Any], typing.Any] | None = None, groups: list[type[Group]] | None = None, use_lock: bool = True, - validate: bool = False, + validate: bool | None = None, ) -> 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. + at construction time. ``validate=False`` skips the check silently. + Leaving ``validate`` unset (``None``, the default) skips the check but + warns with :class:`~modern_di.exceptions.UnvalidatedContainerWarning` on + a root container — modern-di 3.0 will run ``validate()`` by default; + pass ``validate=True`` to adopt that now or ``validate=False`` to opt + out silently. Child containers (with ``parent_container`` set) never + warn regardless. ``context`` seeds this container's context registry. + A root container owns fresh registries; a child shares the parent's + providers/overrides registries and inherits its scope map. """ if not isinstance(scope, enum.IntEnum): raise exceptions.InvalidScopeTypeError(scope_value=scope) @@ -89,6 +94,15 @@ def __init__( # noqa: PLR0913 self.providers_registry.add_providers(*all_providers) if validate: self.validate() + elif validate is None and parent_container is None: + warnings.warn( + "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. " + "See https://modern-di.modern-python.org/migration/to-3.x/.", + exceptions.UnvalidatedContainerWarning, + stacklevel=2, + ) def build_child_container( self, diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index 26d081b..30c1f1c 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -133,6 +133,18 @@ class ContainerClosedWarning(DeprecationWarning): """ +class UnvalidatedContainerWarning(FutureWarning): + """A root container was built without an explicit ``validate`` argument — transitional. + + modern-di 3.0 runs :meth:`Container.validate` at root construction by + default. Pass ``validate=True`` to adopt the 3.0 behavior now, or + ``validate=False`` to keep validation off (also after 3.0). Opt into strict + behavior early by escalating this warning:: + + warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) + """ + + class ResolutionError(ModernDIError): """Base class for errors raised while resolving a provider. diff --git a/pyproject.toml b/pyproject.toml index 010c41a..680413f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,9 @@ isort.no-lines-before = ["standard-library", "local-folder"] addopts = "" testpaths = ["tests"] asyncio_mode = "auto" +filterwarnings = [ + "ignore:This root container was created without an explicit `validate` argument:FutureWarning", +] pythonpath = ["."] asyncio_default_fixture_loop_scope = "function" diff --git a/tests/test_container.py b/tests/test_container.py index 8e89b13..ba93333 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -5,7 +5,7 @@ import pytest -from modern_di import Container, Group, Scope, providers +from modern_di import Container, Group, Scope, exceptions, providers from modern_di.exceptions import ( ArgumentResolutionError, CircularDependencyError, @@ -521,3 +521,34 @@ class _Group(Group): warnings.simplefilter("error", DeprecationWarning) container.resolve(_Dep) # touches _lock and _scope_map internally container.build_child_container(scope=Scope.REQUEST) + + +def test_root_container_without_validate_arg_warns_about_3_0_default() -> None: + with pytest.warns(exceptions.UnvalidatedContainerWarning, match="modern-di 3.0 runs validate"): + Container(scope=Scope.APP) + + +def test_explicit_validate_false_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Container(scope=Scope.APP, validate=False) + + +def test_explicit_validate_true_validates_and_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Container(scope=Scope.APP, validate=True) + + +def test_child_container_does_not_warn_about_validate() -> None: + root = Container(scope=Scope.APP, validate=False) + with warnings.catch_warnings(): + warnings.simplefilter("error") + root.build_child_container(scope=Scope.REQUEST) + + +def test_unvalidated_container_warning_is_escalatable() -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) + with pytest.raises(exceptions.UnvalidatedContainerWarning): + Container(scope=Scope.APP) From b17321a1411e851d8e39385c85a9a52120816e5e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:07:44 +0300 Subject: [PATCH 07/13] test: guard pyproject warning filter against message drift Co-Authored-By: Claude Fable 5 --- pyproject.toml | 3 +++ tests/test_container.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 680413f..09186e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,9 @@ isort.no-lines-before = ["standard-library", "local-folder"] addopts = "" testpaths = ["tests"] asyncio_mode = "auto" +# message-filter form on purpose: ignore::modern_di.exceptions.UnvalidatedContainerWarning +# would import modern_di at pytest config time, before pytest-cov starts tracing, collapsing +# coverage. Kept in sync by tests/test_container.py::test_unvalidated_warning_pyproject_filter_matches_live_message. filterwarnings = [ "ignore:This root container was created without an explicit `validate` argument:FutureWarning", ] diff --git a/tests/test_container.py b/tests/test_container.py index ba93333..40f555c 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,8 +1,17 @@ import copy import dataclasses +import pathlib +import re +import sys import typing import warnings + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover + import tomli as tomllib # ty: ignore[unresolved-import] + import pytest from modern_di import Container, Group, Scope, exceptions, providers @@ -552,3 +561,29 @@ def test_unvalidated_container_warning_is_escalatable() -> None: warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) with pytest.raises(exceptions.UnvalidatedContainerWarning): Container(scope=Scope.APP) + + +def test_unvalidated_warning_pyproject_filter_matches_live_message() -> None: + """The pyproject.toml filterwarnings message must keep matching the live warning text. + + The filter is message-based on purpose (see the comment in pyproject.toml), so nothing + else ties it to the warning it silences. If the wording in container.py drifts, this test + catches it instead of the suite silently filling with warning noise. + """ + with pytest.warns(exceptions.UnvalidatedContainerWarning) as record: + Container(scope=Scope.APP) + + pyproject_path = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml" + pyproject = tomllib.loads(pyproject_path.read_text()) + filters = pyproject["tool"]["pytest"]["ini_options"]["filterwarnings"] + + message_pattern = None + for filter_str in filters: + action, message, rest = ([*filter_str.split(":", 2), "", ""])[:3] + category = rest.split(":", 1)[0] + if action == "ignore" and category == "FutureWarning": + message_pattern = message + break + assert message_pattern is not None, "no ignore:...:FutureWarning filter found in pyproject.toml filterwarnings" + + assert re.compile(message_pattern).match(str(record[0].message)) is not None From 68f92c6ce2549bd36e7152300c1ddd87d9f05249 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:16:27 +0300 Subject: [PATCH 08/13] test: drop tomllib dependency from warning-filter guard (fixes ty on py3.10 lint) Co-Authored-By: Claude Fable 5 --- tests/test_container.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/tests/test_container.py b/tests/test_container.py index 40f555c..09cb85c 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -2,16 +2,9 @@ import dataclasses import pathlib import re -import sys import typing import warnings - -if sys.version_info >= (3, 11): - import tomllib -else: # pragma: no cover - import tomli as tomllib # ty: ignore[unresolved-import] - import pytest from modern_di import Container, Group, Scope, exceptions, providers @@ -574,16 +567,8 @@ def test_unvalidated_warning_pyproject_filter_matches_live_message() -> None: Container(scope=Scope.APP) pyproject_path = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml" - pyproject = tomllib.loads(pyproject_path.read_text()) - filters = pyproject["tool"]["pytest"]["ini_options"]["filterwarnings"] - - message_pattern = None - for filter_str in filters: - action, message, rest = ([*filter_str.split(":", 2), "", ""])[:3] - category = rest.split(":", 1)[0] - if action == "ignore" and category == "FutureWarning": - message_pattern = message - break - assert message_pattern is not None, "no ignore:...:FutureWarning filter found in pyproject.toml filterwarnings" - - assert re.compile(message_pattern).match(str(record[0].message)) is not None + raw = pyproject_path.read_text() + pattern = re.search(r'"ignore:(?P[^"]+):FutureWarning"', raw) + assert pattern is not None, "no ignore:...:FutureWarning filter found in pyproject.toml filterwarnings" + + assert re.compile(pattern.group("message")).match(str(record[0].message)) is not None From 5d4d66b538212ae7b050ba7fa85e27e2121ad370 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:20:46 +0300 Subject: [PATCH 09/13] feat: warn on direct resolve of an unset ContextProvider ahead of the 3.0 raise (API-6 bridge) Co-Authored-By: Claude Fable 5 --- modern_di/exceptions.py | 29 ++++++++++++++++++++++++ modern_di/providers/context_provider.py | 15 +++++++++--- tests/providers/test_context_provider.py | 23 ++++++++++++++++--- tests/test_error_rendering.py | 9 ++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/modern_di/exceptions.py b/modern_di/exceptions.py index 30c1f1c..c47a03d 100644 --- a/modern_di/exceptions.py +++ b/modern_di/exceptions.py @@ -273,6 +273,35 @@ def __init__(self, *, cycle_path: list[str]) -> None: super().__init__(f"Circular dependency detected:\n{rendered}\nCheck your provider graph for unintended cycles.") +class ContextValueNotSetError(ResolutionError): + """An unset ``ContextProvider`` was resolved directly. + + Raised in modern-di 3.0; until then direct resolve emits + :class:`ContextValueNoneWarning` and returns ``None``. Inspect + ``.context_type``. + """ + + __slots__ = ("context_type",) + + def __init__(self, *, context_type: type, scope_name: str) -> None: + self.context_type = context_type + super().__init__( + f"No context value is set for {context_type!r} (scope {scope_name}). " + "Pass context={...} to the container or call set_context()." + ) + + +class ContextValueNoneWarning(DeprecationWarning): + """Direct resolve of an unset ``ContextProvider`` returned ``None`` — transitional. + + The ``None`` return works today but modern-di 3.0 raises + :class:`ContextValueNotSetError` here. Opt into strict behavior now by + escalating this warning:: + + warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning) + """ + + class RegistrationError(ModernDIError): """Base class for errors raised while registering providers.""" diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 7e62195..15d0d35 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -1,7 +1,8 @@ import enum import typing +import warnings -from modern_di import types +from modern_di import exceptions, types from modern_di.providers import AbstractProvider from modern_di.scope import Scope @@ -15,8 +16,9 @@ class ContextProvider(AbstractProvider[types.T_co]): 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 + Resolving it directly when no value is set emits :class:`~modern_di.exceptions.ContextValueNoneWarning` + and returns ``None`` (modern-di 3.0 raises :class:`~modern_di.exceptions.ContextValueNotSetError` instead); + injecting it into a non-nullable, no-default ``Factory`` parameter instead raises ``ArgumentResolutionError``. """ @@ -42,6 +44,13 @@ def __repr__(self) -> str: def resolve(self, container: "Container") -> types.T_co | None: value = self.fetch_context_value(container) if value is types.UNSET: + warnings.warn( + f"No context value is set for {self.context_type!r} (scope {self.scope.name}); returning None. " + "modern-di 3.0 raises ContextValueNotSetError here. Pass context={...} to the container or call " + "set_context(). See https://modern-di.modern-python.org/migration/to-3.x/.", + exceptions.ContextValueNoneWarning, + stacklevel=2, + ) return None return typing.cast(types.T_co, value) diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index e060c36..4386470 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -1,10 +1,11 @@ import dataclasses import datetime +import warnings import pytest from modern_di import Container, Group, Scope, providers -from modern_di.exceptions import ArgumentResolutionError, ContainerClosedWarning +from modern_di.exceptions import ArgumentResolutionError, ContainerClosedWarning, ContextValueNoneWarning request_context_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=datetime.datetime) @@ -39,7 +40,8 @@ def test_context_provider_set_context_after_creation() -> None: def test_context_provider_not_found() -> None: app_container = Container() - assert app_container.resolve_provider(MyGroup.context_provider) is None + with pytest.warns(ContextValueNoneWarning): + assert app_container.resolve_provider(MyGroup.context_provider) is None def test_context_provider_not_found_but_required() -> None: @@ -171,7 +173,8 @@ def test_context_provider_reads_registry_at_its_own_scope_not_resolving_containe app = Container(scope=Scope.APP, groups=[_ScopedCtxGroup]) request = app.build_child_container(scope=Scope.REQUEST, context={_ScopedCtx: _ScopedCtx()}) # context set on the CHILD must be invisible to an APP-scoped provider - assert request.resolve(_ScopedCtx) is None + with pytest.warns(ContextValueNoneWarning): + assert request.resolve(_ScopedCtx) is None # context set on the container at the provider's scope is what counts app.set_context(_ScopedCtx, value) assert request.resolve(_ScopedCtx) is value @@ -275,3 +278,17 @@ def test_late_context_does_not_rebuild_cached_singleton() -> None: second = app.resolve(_CachedCtxSvc) assert second is first assert second.ctx is None + + +def test_unset_context_provider_direct_resolve_warns_and_returns_none() -> None: + app_container = Container(groups=[MyGroup], validate=False) + with pytest.warns(ContextValueNoneWarning, match="modern-di 3.0 raises ContextValueNotSetError"): + assert app_container.resolve_provider(MyGroup.context_provider) is None + + +def test_set_context_provider_direct_resolve_does_not_warn() -> None: + now = datetime.datetime.now(tz=datetime.timezone.utc) + app_container = Container(groups=[MyGroup], context={datetime.datetime: now}, validate=False) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert app_container.resolve_provider(MyGroup.context_provider) is now diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index ce74e91..6b511ef 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -32,3 +32,12 @@ def test_validation_failed_error_groups_by_kind_and_indents_multiline() -> None: def test_validation_failed_error_renders_message_less_sub_error() -> None: error = exceptions.ValidationFailedError(errors=[RuntimeError()]) assert str(error) == ("Container.validate() found 1 issue(s): RuntimeError\n\nRuntimeError (1):\n -") + + +def test_context_value_not_set_error_message_and_hierarchy() -> None: + error = exceptions.ContextValueNotSetError(context_type=str, scope_name="APP") + assert isinstance(error, exceptions.ResolutionError) + assert str(error) == ( + "No context value is set for (scope APP). " + "Pass context={...} to the container or call set_context()." + ) From b89b589751e5c4f03777ece811c56eec685bfa7d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:27:30 +0300 Subject: [PATCH 10/13] docs: add to-3.x migration guide covering all five 3.0 switches (DOC-1) Co-Authored-By: Claude Fable 5 --- docs/migration/to-3.x.md | 209 ++++++++++++++++++++++++ docs/providers/errors-and-exceptions.md | 17 +- mkdocs.yml | 1 + 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 docs/migration/to-3.x.md diff --git a/docs/migration/to-3.x.md b/docs/migration/to-3.x.md new file mode 100644 index 0000000..3e1cc11 --- /dev/null +++ b/docs/migration/to-3.x.md @@ -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 (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. diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index e9b2fc6..1e5ffa6 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -26,7 +26,8 @@ ModernDIError (RuntimeError) │ ├── AliasSourceNotRegisteredError │ ├── ArgumentResolutionError │ ├── CircularDependencyError -│ └── CreatorCallError +│ ├── CreatorCallError +│ └── ContextValueNotSetError ├── RegistrationError │ ├── DuplicateProviderTypeError │ ├── UnknownFactoryKwargError @@ -65,10 +66,15 @@ Catch `ContainerError` for any container/scope failure. `DeprecationWarning`) and the container self-reopens. Re-enter the `with` block or call `open()` to reuse it cleanly; escalate the warning with `warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning)` to fail fast today. + See [Migration: To 3.x](../migration/to-3.x.md). - **`ValidationFailedError`** — raised by `Container.validate()` (and `Container(..., validate=True)`) when the graph has problems. Catch this for validation results; its `.errors` attribute holds the list of individual issues (each itself a `ResolutionError` or `RegistrationError`), and `str()` - renders them all. + renders them all, grouped by error kind. Leaving `validate` unset on a root container skips the + check (as before) but emits **`UnvalidatedContainerWarning`** (a `FutureWarning`) — modern-di + **3.0** runs `validate()` at root construction by default; pass `validate=True` to adopt that now + or `validate=False` to keep validation off permanently. See + [Migration: To 3.x](../migration/to-3.x.md). ## `ResolutionError` — failures while resolving a type @@ -92,6 +98,13 @@ to render the chain programmatically. - **`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__`). +- **`ContextValueNotSetError`** — raised in modern-di **3.0** when an unset `ContextProvider` is + resolved *directly* (`container.resolve(SomeContextType)` with no value set). Until then that + resolve emits **`ContextValueNoneWarning`** (a `DeprecationWarning`) and returns `None`; escalate + it with `warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning)` to fail + fast today. Only the direct-resolve path is affected — a `Factory` parameter backed by the same + `ContextProvider` keeps following its own default/nullable/required disposition. Inspect + `.context_type`. See [Migration: To 3.x](../migration/to-3.x.md). ## `RegistrationError` — declaration / registration problems diff --git a/mkdocs.yml b/mkdocs.yml index f283d1f..df1b5df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Migration: - To 1.x: migration/to-1.x.md - To 2.x: migration/to-2.x.md + - To 3.x: migration/to-3.x.md - From that-depends: migration/from-that-depends.md - Development: - Contributing: dev/contributing.md From 193274656cdba990f7383fe6f85518c6dfa460f6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:35:30 +0300 Subject: [PATCH 11/13] docs: promote v3-bridges behavior into architecture/ capability files Co-Authored-By: Claude Fable 5 --- architecture/containers.md | 26 ++++++++- architecture/providers.md | 19 +++++-- architecture/validation.md | 56 +++++++++++++++++-- .../2026-07-05.04-v3-bridges/design.md | 2 +- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/architecture/containers.md b/architecture/containers.md index af6cdab..d53e4a1 100644 --- a/architecture/containers.md +++ b/architecture/containers.md @@ -22,12 +22,32 @@ 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 always take this branch — but the warning is conditioned on + `parent_container is None`, so children never warn regardless. + +Escalate the warning to catch it in CI ahead of the 3.0 upgrade: + +```python +warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) +``` + ## Child containers ```python @@ -144,7 +164,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()` diff --git a/architecture/providers.md b/architecture/providers.md index 64a8faa..187259e 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -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 + `~modern_di.exceptions.ContextValueNoneWarning` (a `DeprecationWarning` naming the context type and scope) and + returns `None`. modern-di 3.0 raises `~modern_di.exceptions.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. diff --git a/architecture/validation.md b/architecture/validation.md index 32cdcd3..0f03a0d 100644 --- a/architecture/validation.md +++ b/architecture/validation.md @@ -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 @@ -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 @@ -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 +style 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 @@ -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 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 diff --git a/planning/changes/2026-07-05.04-v3-bridges/design.md b/planning/changes/2026-07-05.04-v3-bridges/design.md index b5b76c3..c6a9547 100644 --- a/planning/changes/2026-07-05.04-v3-bridges/design.md +++ b/planning/changes/2026-07-05.04-v3-bridges/design.md @@ -1,5 +1,5 @@ --- -summary: 2.x bridges for the ruled 3.0 breaks — validation report rework, tri-state validate FutureWarning, ContextProvider unset-value DeprecationWarning, and the to-3.x migration guide. +summary: Shipped 2.x bridges for the 3.0 breaks — grouped/arrow-chain validation rendering, a tri-state validate= FutureWarning, the ContextProvider unset-value DeprecationWarning/3.0-error pair, and the to-3.x migration guide. --- # Design: 3.0 bridges on 2.x (validation UX, ContextProvider deprecation, migration guide) From 9bcb13849d292ad830a13004921483790be30b73 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:36:11 +0300 Subject: [PATCH 12/13] docs: fix wording drift in architecture doc self-review Clarify the child-container warning-suppression path in containers.md and drop nonstandard ~module.Class notation in providers.md to match the rest of the file's plain-backtick style. Co-Authored-By: Claude Fable 5 --- architecture/containers.md | 5 +++-- architecture/providers.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/architecture/containers.md b/architecture/containers.md index d53e4a1..cbc480f 100644 --- a/architecture/containers.md +++ b/architecture/containers.md @@ -39,8 +39,9 @@ provider declaring `Container` as a dependency receives the resolving container (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 always take this branch — but the warning is conditioned on - `parent_container is None`, so children never warn regardless. + 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: diff --git a/architecture/providers.md b/architecture/providers.md index 187259e..ec5c022 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -128,8 +128,8 @@ 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 - `~modern_di.exceptions.ContextValueNoneWarning` (a `DeprecationWarning` naming the context type and scope) and - returns `None`. modern-di 3.0 raises `~modern_di.exceptions.ContextValueNotSetError` here instead of warning — + `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 From d81febbcfdc0a068bfd50d728941473acdf70355 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 6 Jul 2026 00:47:55 +0300 Subject: [PATCH 13/13] docs+test: final-review fixes (stale troubleshooting output, validate bridge-contract tests, drift one-liners) Co-Authored-By: Claude Fable 5 --- architecture/validation.md | 2 +- docs/providers/errors-and-exceptions.md | 5 ++- docs/troubleshooting/circular-dependency.md | 10 ++++- docs/troubleshooting/scope-chain.md | 4 +- tests/test_container.py | 47 ++++++++++++++++++++- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/architecture/validation.md b/architecture/validation.md index 0f03a0d..5e4e965 100644 --- a/architecture/validation.md +++ b/architecture/validation.md @@ -47,7 +47,7 @@ type names showing the loop (e.g., `["A", "B", "A"]`). The recursive walk does * but the rest of the graph continues to be checked. `CircularDependencyError.__str__` renders `.cycle_path` as a multi-line arrow chain (the same `└─>` continuation -style as the `ResolutionError` dependency-chain breadcrumb), not an inline `A -> B -> A` string: +glyph as the `ResolutionError` dependency-chain breadcrumb), not an inline `A -> B -> A` string: ``` Circular dependency detected: diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 1e5ffa6..6ddc192 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -92,8 +92,9 @@ to render the chain programmatically. - **`ArgumentResolutionError`** — raised when a creator parameter cannot be resolved: no provider 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. See +- **`CircularDependencyError`** — raised only by `validate()` when the provider graph contains a + cycle (A → B → A); the message shows the cycle path. An unvalidated cyclic graph fails with a raw + `RecursionError` at first resolve instead. 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 diff --git a/docs/troubleshooting/circular-dependency.md b/docs/troubleshooting/circular-dependency.md index a497996..bb50473 100644 --- a/docs/troubleshooting/circular-dependency.md +++ b/docs/troubleshooting/circular-dependency.md @@ -7,8 +7,14 @@ This error occurs when providers form a dependency cycle, meaning A depends on B When you see this error: ``` -ValidationFailedError: Container.validate() found 1 issue(s): CircularDependencyError - - Circular dependency detected: ServiceA -> ServiceB -> ServiceA. Check your provider graph for unintended cycles. +Container.validate() found 1 issue(s): CircularDependencyError + +CircularDependencyError (1): + - Circular dependency detected: + ServiceA + └─> ServiceB + └─> ServiceA + Check your provider graph for unintended cycles. ``` It means the listed providers form a cycle that cannot be resolved. Without `validate()`, this would manifest as a `RecursionError` during resolution. diff --git a/docs/troubleshooting/scope-chain.md b/docs/troubleshooting/scope-chain.md index 490d1ba..7ec6a14 100644 --- a/docs/troubleshooting/scope-chain.md +++ b/docs/troubleshooting/scope-chain.md @@ -7,7 +7,9 @@ This error fires when a provider depends on another provider with a *shorter* li You'll see something like: ``` -ValidationFailedError: Container.validate() found 1 issue(s): InvalidScopeDependencyError +Container.validate() found 1 issue(s): InvalidScopeDependencyError + +InvalidScopeDependencyError (1): - Provider UserCache (scope APP) declares parameter 'session' typed as a provider of Session at deeper scope REQUEST. A provider cannot depend on a deeper-scoped provider. ``` diff --git a/tests/test_container.py b/tests/test_container.py index 09cb85c..99b8e89 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -537,9 +537,54 @@ def test_explicit_validate_false_never_warns() -> None: def test_explicit_validate_true_validates_and_never_warns() -> None: + @dataclasses.dataclass(kw_only=True, slots=True) + class Missing: + pass + + @dataclasses.dataclass(kw_only=True, slots=True) + class BrokenService: + missing: Missing + + class BrokenGroup(Group): + svc = providers.Factory(creator=BrokenService) + + with pytest.raises(ValidationFailedError): + Container(scope=Scope.APP, groups=[BrokenGroup], validate=True) + + @dataclasses.dataclass(kw_only=True, slots=True) + class Dep: + pass + + @dataclasses.dataclass(kw_only=True, slots=True) + class ValidService: + dep: Dep + + class ValidGroup(Group): + dep = providers.Factory(creator=Dep) + svc = providers.Factory(creator=ValidService) + with warnings.catch_warnings(): warnings.simplefilter("error") - Container(scope=Scope.APP, validate=True) + Container(scope=Scope.APP, groups=[ValidGroup], validate=True) + + +def test_unset_validate_warns_but_does_not_validate() -> None: + @dataclasses.dataclass(kw_only=True, slots=True) + class Missing: + pass + + @dataclasses.dataclass(kw_only=True, slots=True) + class Service: + missing: Missing + + class G(Group): + svc = providers.Factory(creator=Service) + + with pytest.warns(exceptions.UnvalidatedContainerWarning): + Container(scope=Scope.APP, groups=[G]) # constructs fine; the broken graph is never checked + + with pytest.raises(ValidationFailedError): + Container(scope=Scope.APP, groups=[G], validate=True) def test_child_container_does_not_warn_about_validate() -> None: