Releases: modern-python/modern-di
Release list
2.28.0
modern-di 2.28.0 — the integration kit
Back-compatible: no behavior flips, no new warnings, no changes to any
existing public symbol. This release adds one new module,
modern_di.integrations, for framework-integration authors — nothing else
changes.
Feature
-
modern_di.integrations— shared primitives for building an
integration. Every framework adapter (FastAPI, Starlette, gRPC, ...) has
hand-rolled the same framework-agnostic skeleton: deriving a child
container's scope/context from a connection, and scanning a handler's
Annotatedhints for DI markers. This module extracts that skeleton so
adapters can compose it instead of duplicating it.Connection derivation — never wraps
Container.build_child_container
itself, only computes what to pass it:from modern_di import integrations match = integrations.classify_connection(connection, (request_provider, websocket_provider)) child = root.build_child_container( scope=match.scope if match else None, context=match.context if match else None, )
For a single connection kind with no dispatch,
integrations.bind(provider, connection)
skips straight to the derivation.The
Annotated-marker injector:from modern_di import integrations service: typing.Annotated[Service, integrations.from_di(Deps.service)] markers = integrations.parse_markers(handler) # decoration time resolved = integrations.resolve_markers(child, markers) # call time
integrations.is_injected/mark_injectedguard against double-wrapping a
handler an auto-inject sweep visits more than once.See architecture/integration-kit.md
for the full design, and the updated
writing-integrations guide
for how an adapter composes these.
Downstream
- No action needed for existing integrations — this release changes
nothing they already depend on. - Integration authors adopting the kit (starlette is next, one adapter
per PR per the rollout plan): bump the floor tomodern-di>=2.28,<3to
importmodern_di.integrations.
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffandty
clean; docs built undermkdocs --strictin CI. - Built via
subagent-driven-development: 9 planned tasks, each with an
independent spec+quality review; a whole-branch review before merge.
2.27.0
modern-di 2.27.0 — shorter registrations, self-resetting overrides, anchored errors
Back-compatible: no behavior flips, no new warnings. Four features from the
3.0 UX research land together — the registration line gets shorter twice,
test overrides clean up after themselves, and error messages now point at
the source line that declared the failing provider.
Feature
-
Positional subject arguments (API-5).
Factory,ContextProvider,
andAliasaccept their subject as the first positional argument —
providers.Factory(UserRepository),
providers.ContextProvider(HttpRequest),providers.Alias(Concrete).
The keyword spellings (creator=,context_type=,source_type=)
remain first-class; nothing is deprecated. Docs teach the positional
form throughout. -
Group-level default scope (API-4). A
Groupsubclass may declare its
members' default scope:class RequestGroup(Group, scope=Scope.REQUEST): repo = providers.Factory(UserRepository) # takes REQUEST audit = providers.Factory(AuditLog, scope=Scope.APP) # explicit wins
Priority: explicit
scope=> the group's kwarg (inherited via MRO;
a subclass's kwarg applies to providers declared in its own body) >
Scope.APP.Aliasnever participates (its scope derives from its
source). A scope-defaulted provider shared by two groups with different
defaults raises the newGroupScopeConflictErrorat class creation —
import order never decides a provider's scope. -
Context-manager override (INT-4).
container.override(provider, obj)
still applies immediately and now returns anOverrideHandle:with container.override(MyGroup.api_client, mock_client) as client: ... # resolution returns mock_client # prior override state restored here — even on exception
Exit restores the snapshot taken at the call (a previously stacked
override, or none); nested overrides of the same provider unwind in
order. Imperativeoverride()/reset_override()callers are unaffected. -
Definition-site anchors on error paths (ERR-6). Breadcrumb lines and
cycle hops now end with the creator's declaration site:Cannot resolve dependency chain: REQUEST UserService (app.services:17) APP └─> SessionFactory (app.db:42)Captured lazily on the error path only (zero import/resolution cost),
memoized, with silent fallback for callables without source. Cycle
errors expose the parallel.cycle_locations;.cycle_pathstays bare
type names.
Downstream
- Tests asserting exact error messages will break on the new trailing
(module:line)anchors in breadcrumb and cycle lines. As with 2.24.0
and 2.26.0: preferpytest.raises(..., match=...)with a substring —
anchors are trailing additions, so substring matches survive. Container.overridenow returns anOverrideHandleinstead ofNone—
invisible to callers that ignore the return value.- New exception:
GroupScopeConflictError(aRegistrationError), with
its troubleshooting page. - No floor bumps required.
Internals
- The eager warm-up capability (API-8/INT-3,
init_cache()-style) is
explicitly deferred with a revisit trigger recorded in planning. - 100% line coverage maintained across Python 3.10–3.14;
ruffandty
clean; docs built undermkdocs --strictin CI.
2.26.0
modern-di 2.26.0 — every exception links its troubleshooting page
Back-compatible: no behavior flips, no new warnings, no attribute or
hierarchy changes. The only change is message text — every exception now
ends with a stable docs URL, and the docs grew a page for each one.
Feature
- Stable per-exception docs URLs (ERR-4). Every concrete
ModernDIErrorsubclass carries a class-leveldocs_slug, and its
message now ends with a uniform final line:
See: https://modern-di.modern-python.org/troubleshooting/<slug>/.
The trailer composes with every message shape — plain errors,
dependency-path breadcrumbs, andValidationFailedError's grouped
report alike (path first, trailer always last).
DuplicateProviderTypeError's hand-rolled inline URL is replaced by
the same mechanism (URL unchanged). - Complete troubleshooting registry (DOC-6). 16 new pages under
/troubleshooting/— one per concrete exception, 21 in total — each
with Symptom, Cause, Fix, and escape hatches where real. The
exception catalog
links each entry to its page. - Warnings link their exact migration section. The three transitional
warnings (UnvalidatedContainerWarning,ContainerClosedWarning,
ContextValueNoneWarning) now link the specific
to-3.x guide
section anchor for their switch instead of the guide's landing page.
Why
ERR-4 and DOC-6 from the 2026-07-05 3.0 UX research: a user holding a
production traceback had no stable link to follow — of ~20 exception
classes, only one carried a URL. Field precedent: Angular's NGxxxx
registry, Spring's Description/Action reports, wireup's remedy+URL
messages. A census test now walks modern_di.exceptions and asserts
every concrete class has a unique slug and an existing page — new
exceptions cannot ship without one.
Downstream
Tests asserting exact exception messages will break on the new
trailer line. As with 2.24.0: prefer pytest.raises(..., match=...)
with a substring — the trailer is a separate final line, so substring
matches survive. No floor bumps required.
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffand
tyclean; docs built undermkdocs --strictin CI (which validates
every slug URL has a page).
2.25.0
modern-di 2.25.0 — blessed integration seams: add_providers and resolve_dependency
Back-compatible: no behavior flips, no new warnings. Container gains the two
methods framework integrations have been reaching into internals for — a
post-construction registration verb and a provider-or-type resolve dispatch —
plus a correctness fix that makes registration safe in any order relative to
resolution.
Feature
Container.add_providers(*providers). The blessed way for integrations
(and applications) to register providers after the container is built —
previously only possible by reaching intocontainer.providers_registry.
Root-only: called on a child container it raises the new
ChildContainerRegistrationError(inspect.scope), because registries are
shared tree-wide. On a container that has validated (constructed with
validate=True, or after a manualvalidate()), the batch is re-validated
immediately, so wiring errors surface at the registration site — and the
call is atomic: if re-validation fails for any reason, the whole batch
is rolled back and the container is exactly as it was.Container.resolve_dependency(dep). One entry point that accepts a
provider or a type and dispatches toresolve_provider/resolve—
theisinstancedance every integration's marker handling re-implements
today, now in core. Overrides, caching, scope checks, and "did you mean"
suggestions behave exactly as on the underlying paths.
Fix
- Wiring plans rebuild after registration. A provider's memoized wiring
plan is now stamped with the providers-registry version and rebuilt when
registration has changed the registry. Previously, a provider resolved
before a later registration kept its stale plan silently — an optional
dependency stayedNone, a missing required one kept raising
ArgumentResolutionErroreven after its provider was registered. This
affected the oldproviders_registryreach-in path too.
Why
INT-1 and INT-2 from the 2026-07-05 3.0 UX research: all seven official
integrations reach two attributes deep to register context providers, and all
seven copy-paste the same marker dispatch. Blessing both as Container
methods gives the 3.0 surface a stable integration contract (and, per .NET /
dishka precedent, one place for its semantics). Registration on a validated
container re-validating by default keeps the seam correct under 3.0's
validate-at-construction.
Downstream
No floor bump required — the providers_registry reach-in keeps working in
2.x. Integrations should migrate to add_providers /
resolve_dependency (and bump their floor to modern-di>=2.25 when they
do); the reach-in path is slated for privatization after the org-wide
migration. The
writing-integrations spec
already documents the new contract points.
Internals
- Docs site deduplicated: every core concept now has one canonical page with
links elsewhere; the historical 0.x→1.x and 1.x→2.x guides are condensed.
Two stub pages merged away (testing/fixtures,
introduction/that-depends-or-modern-di— their URLs now 404; content
lives inintegrations/pytestandintroduction/comparison). - Docstring policy pass over the package: internal helpers carry one-line
contracts; two stale references to long-removed functions fixed. No
behavior change. - 100% line coverage maintained across Python 3.10–3.14;
ruffandty
clean; docs built undermkdocs --strictin CI.
2.23.0
modern-di 2.23.0 — private Container internals
Back-compatible. Two Container attributes become private, with deprecated aliases; one member is reclassified as a supported extension point.
Deprecation
Container.scope_mapandContainer.lockare now private (_scope_map/_lock). Reading the old names still works but emitsDeprecationWarningand will be removed in a future release. These attributes were already documented "internal, no stability guarantee"; the thread-safety knobuse_lock=and theContainer(...)constructor are unchanged.
Clarification
find_container(scope)is a supported extension point. It is the primitive a customAbstractProvider.resolvecalls to locate the container at its scope, and is now documented as such (moved out of the internals section).
Downstream
No action required. Nothing in the official integrations reads scope_map or lock. Advanced users who inspected them should switch to _scope_map / _lock (or, for scope lookup, find_container).
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffandtyclean.
2.22.0
modern-di 2.22.0 — ergonomic Factory(cache=…); closed-container reuse softened to a deprecation
Two user-facing changes. Factory gains a cache= toggle so the common "just
cache it" case is cache=True instead of cache_settings=CacheSettings(), and
cache_settings= becomes a deprecated alias. Separately, reusing a closed
container no longer raises — it emits a ContainerClosedWarning and self-reopens,
restoring pre-2.16 behavior; the hard ContainerClosedError returns in 3.0.
Feature
-
Factory(cache=…)— one axis for the whole caching spectrum (#259).
Enabling a cached singleton used to require constructing an empty settings
object:Factory(scope=Scope.APP, creator=Database, cache_settings=CacheSettings()).
Now:cache=True— cached with defaults (the common case).cache=CacheSettings(...)— cached and tuned (finalizer,clear_cache).cache=False/cache=None/ omitted — not cached.
CacheSettingsand all resolution/finalizer/lifecycle behavior are unchanged;
the sugar lives entirely inFactory.__init__. NoSingletonclass was added.
Fix
- Closed-container reuse is transitional again (#261). The
ContainerClosedErrorintroduced in 2.16.0 (#202) was a breaking change for
lifecycles that close then keep resolving.resolve/build_child_container
(and nested providers resolving at a closed ancestor scope) now warn
(exceptions.ContainerClosedWarning, aDeprecationWarning) and self-reopen
instead of raising. The warning fires once per container per closed→reopened
transition — a resolve that crosses several distinct closed containers emits
one warning per container. - Concurrency note. Under concurrent close + reuse without external
synchronization, a resolving thread may now self-reopen and rebuild after
another thread's finalizers already ran, matching pre-2.16 behavior instead
of the 2.16–2.21 raise. This is user-managed synchronization territory, not
a new bug.
Deprecations
cache_settings=onFactory(#259) — usecache=instead
(cache=Truefor defaults,cache=CacheSettings(...)to tune). The old
kwarg still works but emits aDeprecationWarningand will be removed in a
future release. Passing bothcache=andcache_settings=is aTypeError.- Reusing a closed container (#261) — deprecated; it will raise
ContainerClosedErrorin modern-di 3.0. Wrap the container inwith/
async with, or callopen(), before reuse. To fail fast today:
warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning).
Docs
- New integration usage guides for aiohttp (#257, #258) and Starlette
(#254, #255), a "Writing an integration" guide (#253), and an
accuracy/integration-parity pass across the docs set (#260). The retired,
separately-maintainedskills/modern-di/agent-skill set was removed (#256).
Downstream
No action required to keep working. Migrating off the deprecations is
mechanical: rename cache_settings=X to cache=X (or cache=True where you
passed an empty CacheSettings()), and reopen a container (with / open())
before reusing it after close. Integrations that reopen the root container on
startup are already on the recommended path.
Internals
- 100% line coverage across supported Python versions retained.
2.21.1
modern-di 2.21.1 — release pipeline on PyPI Trusted Publishing
No library changes. The package is identical to 2.21.0; this release exercises the new publish path end-to-end.
CI
- Releases now authenticate to PyPI via Trusted Publishing (OIDC) instead of a long-lived
PYPI_TOKENsecret.uv publishauto-detects the GitHub Actions id-token; the release job runs under apypienvironment that scopes the trusted publisher (#251).
Downstream
No action required. Nothing about the installed package changes.
2.21.0
modern-di 2.21.0 — ContextProvider.context_type
Purely additive. One newly-public attribute; the contract of existing code is unchanged.
Feature
ContextProvider.context_typeis now a public attribute — the type the provider supplies and the key its value is set under incontext. It was stored privately (_context_type) with no accessor.
Implemented as a plain public slot, matching its sibling bound_type (both derive from the same constructor argument) and the base provider's public scope / bound_type. Properties in the providers are reserved for derived values (display_name); context_type is stored config, so it is a plain attribute.
Why
Framework integrations build a connection → scope → context-key mapping. Without a public context_type they re-state, in an isinstance ladder, the very type they already passed into the provider — so the connection-kind knowledge is split across two places. Exposing context_type lets an integration drive that dispatch off the provider objects themselves, single-sourcing the mapping.
Downstream
No action required. Integrations that want to single-source their connection-kind dispatch (the modern-di-fastapi refactor, and the same pattern in modern-di-litestar / modern-di-faststream / modern-di-typer) can read provider.context_type and bump their floor to modern-di>=2.21.0. Nothing breaks for consumers that don't.
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffandtyclean.
2.20.0
modern-di 2.20.0 — Group.get_named_providers()
Purely additive. One new public method; the contract of existing code is unchanged.
Feature
Group.get_named_providers() -> dict[str, AbstractProvider]— an MRO-walking accessor that maps each declared attribute name to its provider.Group.get_providers()is nowlist(cls.get_named_providers().values()), so the traversal and dedup/masking logic lives in one place.
The new method preserves the exact semantics of the old get_providers() traversal:
- MRO order (most-derived first)
- first-seen name wins (diamond inheritance returns each provider once)
- a non-provider override masks the parent provider of the same name
get_providers()'s contract (return type, order, dedup, masking) is unchanged.
Why
get_providers() discarded the attribute name each provider was declared under. Downstream integrations that need names (notably modern-di-litestar's autowiring) reconstructed them with a fragile id()-keyed reverse lookup over group.__dict__. That lookup only sees the subclass __dict__ while get_providers() walks the full MRO, so autowiring a Group that inherits a provider raised KeyError. Exposing names at the source — where Group owns provider declaration and traversal — fixes the bug for every consumer.
Downstream
Unblocks the modern-di-litestar autowiring fix, which consumes get_named_providers() and bumps its floor to modern-di>=2.20.0. The FastAPI, FastStream, Typer, and modern-di-pytest integrations do not need to bump.
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffandtyclean.
2.19.2
modern-di 2.19.2 — Docs and tag-driven releases, no code change
Maintenance release. No API or behavior changes — the installed package is byte-for-byte identical to 2.19.1. It ships new documentation and moves the release process to a tag-driven workflow.
Documentation
- Exploratory roadmap (
ROADMAP.md) sketching possible future directions. - Comparison pages — a feature comparison and a "that-depends or modern-di?" guide to help choose between the two.
Release tooling
- Releases are now tag-driven. Pushing a bare semver tag (e.g.
2.19.2) publishes to PyPI and creates the matching GitHub Release in one workflow, replacing the previous "publish a GitHub Release to trigger PyPI" flow. Pre-release tags use the PEP 440 form (2.0.0rc1). No change for installers.
Downstream
No action needed. There is no API change, so the FastAPI, Litestar, FastStream, Typer, and modern-di-pytest integrations do not need to bump their modern-di floor.
Internals
- 100% line coverage maintained across Python 3.10–3.14;
ruffandtyclean.