Releases: modern-python/modern-di
Release list
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.
2.19.1 — Internal deepening, no behavior change
modern-di 2.19.1 — Internal deepening, no behavior change
A maintenance release: three behavior-preserving refactors that improve testability and locality, plus richer PyPI metadata. No public API or behavior changes — every error message and resolution result is identical to 2.19.0.
Internal refactors
WiringPlanextracted fromFactory. The kwarg-wiring decision — match each creator parameter to a provider, else omit / injectNone/ raise — moved out of four separate methods (with the absent-value rule hand-copied three times) into one pure module,modern_di/wiring.py. It is now unit-testable without aContainer, andCacheItemholds a singleWiringPlaninstead of four compiled-kwargs fields.- Error messages inlined;
errors.pyremoved. The 1:1 message-template indirection is gone — each error message is now an f-string in the exception class that raises it, so the message lives with its raise. Messages are byte-for-byte unchanged (verified by a full before/after dump across every exception). - New
suggestermodule. The shareddifflib"did you mean?" fuzzy match — registry type suggestions and factory unknown-kwarg typos — is now one directly-testedclose_matchesprimitive, with the similarity cutoff living in a single place.
Packaging
- Richer PyPI metadata: keywords, trove classifiers (including Python 3.14), and project URLs.
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.
2.19.0 — Public Container.open()
modern-di 2.19.0 — Public Container.open()
Purely additive. One new public method; no behavior changes to existing code.
Feature
Container.open()reopens a closed container. Setsclosed = Falseso a closed container can resolve and build children again.__enter__/__aenter__now callopen()instead of clearing the flag inline. Reopening an already-open container is a no-op.
Why
__enter__/__aenter__ already reopened containers on re-entry, but only through the context-manager protocol. Callback-style framework lifecycles can't wrap a long-lived root container in a with block — e.g. FastStream exposes on_startup/after_shutdown hooks, not a context manager. Without a public reopen entry point, those integrations closed the root at shutdown and never reopened it, so a second startup cycle (broker restart, repeated test lifespans) raised ContainerClosedError on the first resolve.
open() gives such integrations a clean, public reopen:
app.on_startup(container.open)
app.after_shutdown(container.close_async)Downstream
Unblocks the closed-state reopen fixes in the FastAPI, Litestar, and FastStream integrations, all of which bump their floor to modern-di>=2.19.0.
Internals
- 100% line coverage maintained across Python 3.10–3.14.
2.18.0 — Deep-audit fixes: live context resolution, new public API
Mostly additive and backward-compatible. Ships the 2026-06-14 deep audit (#216–#220). Full notes: planning/releases/2.18.0.md.
Fixes
set_contextnow propagates across scopes (#216). A deeper-scopedFactory(e.g.REQUEST) reading a shallower-scopedContextProvider(e.g.APP) now picks up a lateset_contexton subsequent resolves; previously an unset-at-first-resolve value was baked in and stayedNone/default forever. The 2.16.0 fix only covered the same-scope case. Context values are now resolved live on every resolve.- Gapped custom-
IntEnumchild scopes (#218).build_child_container()derives the next-deeper enum member instead ofcurrent + 1, so non-contiguous custom scopes (e.g.TENANT=6, JOB=10) no longer raise a spuriousMaxScopeReachedError. - Late override of a context-backed parameter now takes effect (#216).
New public API
ContextProvider.fetch_context_value(container)— live value ortypes.UNSET(#219)AbstractProvider.display_name— bound-type/creator name for messages (#219)exceptionsexported frommodern_diand in__all__(#220)
Performance
- No per-resolve
CacheItemallocation on cache hits; creation stays atomic for concurrent singleton resolution (#218).
Behavior changes
- Context-backed parameters resolve live every resolve (effects are all corrections). A cached provider is still built once — a late
set_contextdoes not rebuild it.
DX / Internals
- Class docstrings on every concrete exception (IDE hover);
ResolutionStepdocumented (#220). - Internal
CacheRegistry.invalidate_compiled_kwargsremoved (context is live); three-bucket kwargs compilation; test hardening. 100% coverage across Python 3.10–3.14.
All actionable audit findings resolved. See planning/audits/2026-06-14-deep-audit-report.md.
2.17.0 — Alias scope transparency
modern-di 2.17.0 — Alias scope transparency
Mostly additive. One behavior change in Container.validate() is called out below.
Fix
validate()now checks scope ordering transitively through aliases (X-4). An alias is a transparent redirect — at resolution its source's scope governs where the instance lives. Validation now matches that: a shallow-scoped caller depending through an alias on a deeper-scoped source is flagged withInvalidScopeDependencyErrorat validation time, instead of passingvalidate()and failing only at runtime withScopeNotInitializedError. Implemented via a newAbstractProvider.effective_scope(container)hook (defaultself.scope;Aliasfollows its source chain). This replaces the internalenforces_dependency_scopeflag introduced in 2.16.0.
Behavior changes
validate()may newly raiseValidationFailedError(InvalidScopeDependencyError)for graphs of the shapeFactory(shallow) → Alias → Factory(deeper)that previously passed validation. These graphs were already broken (they raisedScopeNotInitializedErrorat resolve time);validate()now surfaces them up front. No change for correctly-scoped graphs.
Deprecations
Alias(scope=...)is deprecated. The parameter never affected resolution and (as of this release) no longer affects validation — an alias's effective scope is derived from its source. Passingscope=emits aDeprecationWarning; the parameter will be removed in 3.0.
Internals
- Removed the
AbstractProvider.enforces_dependency_scopeClassVar (superseded byeffective_scope). Custom transparent providers should overrideeffective_scope(container)instead. - 100% line coverage maintained across Python 3.10–3.14.
References
2.16.1 — Restore zero-dependency install
modern-di 2.16.1 — Restore zero-dependency install
Patch release. No API or behavior changes.
Fix
import modern_dino longer requirestyping_extensionsat runtime.modern_di/container.pyimportedtyping_extensionsunconditionally at module load, even though it is used only fortyping_extensions.Selfin (non-evaluated) type annotations and is not declared as a runtime dependency. On a clean install with no transitively-presenttyping_extensions,import modern_diraisedModuleNotFoundError: No module named 'typing_extensions'. The import is now guarded byif typing.TYPE_CHECKING:(matchingmodern_di/group.py), restoring the documented zero-dependency install. Surfaced by checking the sibling integration repos against 2.16.0 (modern-di-typer/modern-di-pytestfailed on a cleanuv sync). Pre-existing since the import was introduced; not a 2.16.0 regression.
Internals
- Regression guard:
tests/test_packaging.pyimportsmodern_diand builds a child container in a subprocess withtyping_extensionsblocked, so any future unconditional runtime import of it fails CI. - 193 tests; 100% line coverage on Python 3.10–3.14.
References
- Release notes for the audit work this patches:
planning/releases/2.16.0.md
2.16.0 — Second audit pass: bug fixes, optional-dependency injection, docs
modern-di 2.16.0 — Second audit pass: bug fixes, optional-dependency injection, docs
2.16.0 is mostly additive. One deliberate behavior change — optional (X | None) parameters now inject None instead of raising — is called out in Behavior changes. Code that already registered providers for all its dependencies is unaffected.
This release ships two PRs (#202, #203) driven by a full code-and-docs audit of the codebase (planning/audits/2026-06-12-code-docs-audit-report.md). All audit findings are now resolved; none remain deferred for the main repo. The audit covered 57 findings across bugs, doc/code drift, internals, public-API DX, and documentation gaps.
New features
- Optional parameters resolve to
Nonewhen unregistered. A creator parameter annotatedX | None(orOptional[X]) now injects a registered provider forXif one exists, and otherwise injectsNone— no provider and no default required.container.validate()no longer flags such parameters. The previously-deadSignatureItem.is_nullablefield is now wired into resolution. Also applies to multi-member optional unions (A | B | None). See Behavior changes for the trade-off. (#203) - Closed containers are reusable via the context manager. Resolving from — or building a child of — a closed container raises the new
ContainerClosedError. Re-entering the container as a context manager (with container:/async with container:) reopens it. Instances cached withCacheSettings(clear_cache=False)survive the close→reopen cycle and are returned again (same object);clear_cache=Trueinstances are finalized at close and rebuilt on the next resolve. This supports patterns like a test broker whose mocks are bound to one instance reused across tests. (#202) - Aliases appear in resolution-error chains. A
ResolutionErrorraised while resolving through anAliasnow includes the alias hop in the rendered dependency chain, instead of jumping straight to the underlying factory. (#203) validate()no longer false-positives on decorative Alias scope. AnAliaswhose (decorative) scope is shallower than its source's scope is no longer flagged withInvalidScopeDependencyError, since alias scope never affects resolution. Implemented via the newAbstractProvider.enforces_dependency_scopeclass flag (Aliassets itFalse). (#203) (A residual transitive-scope limitation is recorded inplanning/deferred.md.)
Correctness fixes
- Finalizers run in reverse creation order (true LIFO teardown). Cached instances are now finalized in the reverse of the order they were created, matching the documented contract. Previously they ran in cache-insertion order, so the docs-recommended warm-up pattern could finalize a dependency before its dependents. (#202)
- Sync finalizers that return an awaitable are awaited. A finalizer that is a plain callable returning a coroutine/awaitable is now awaited in
close_async(and rejected inclose_sync), instead of being called and the coroutine silently dropped (a resource leak). (#202) set_contextis honored after a dependent factory has resolved. Callingset_contexton a container now invalidates compiled kwargs so subsequent resolves of non-cached factories pick up the new value, instead of permanently baking out an unsetContextProvider. (#202)- Parameterized generics and positional-only creator params fail clearly at declaration.
list[Svc]-style parameterized generics and positional-only parameters now raiseUnsupportedCreatorParameterErroratFactory(...)construction (unless supplied viakwargsor given a default), instead of silently degrading to the origin type or dying with a rawTypeErrorat resolve. (#202) get_type_hintsTypeErroris warn-and-skipped. A creator whose hints can't be introspected (e.g.functools.partialon Python < 3.14) now emits aUserWarningand skips wiring with a workaround hint, instead of crashing at declaration. (#202)- Honest messages for unannotated and union parameters. An unannotated parameter now reports "has no usable type annotation" and a union parameter names its members, instead of the misleading "of type None". (#202)
validate()aggregates dangling-Alias errors. A danglingAlias(bound under a type different from its unregistered source) is now collected intoValidationFailedErroralong with other issues, instead of aborting the whole validation with a bareAliasSourceNotRegisteredError. (#202)Container(parent_container=...)enforces scope ordering. The public constructor now raisesInvalidChildScopeErrorfor a non-increasing scope, the same checkbuild_child_containeralready applied — preventing a silently-shadowed parent and duplicated singletons. (#202)- Atomic group registration. Registering
groups=is now all-or-nothing: a duplicate-type collision raisesDuplicateProviderTypeErrorwithout leaving earlier providers from the failed batch in the shared registry. (#202) ProvidersRegistryis safe under concurrent registration. Registry mutations are lock-guarded and iteration is snapshot-based, eliminating "dictionary changed size during iteration" crashes when one thread registers while another resolves/validates. (#202)- Creator-call
TypeErrorcarries DI context. An argument-bindingTypeErrorfrom askip_creator_parsing=Truefactory with missing kwargs is wrapped in the newCreatorCallError(aResolutionError) with the creator name and resolution chain. ATypeErrorraised inside the creator body still propagates unchanged — only genuine wiring failures are wrapped. (#203)
Behavior changes
Optional (X | None) parameters now inject None instead of raising. (#203)
Previously, a parameter annotated X | None (or Optional[X]) with no registered provider for X and no default raised ArgumentResolutionError at resolve, and validate() flagged it. Now None is injected and validate() does not flag it.
This is a convenience, but it removes a safety net: if you intended to register a provider for an optional dependency and forgot, neither resolve() nor validate() will report it — the parameter silently receives None. For dependencies that must always be present, use a non-optional annotation (dep: X), which still raises ArgumentResolutionError when unregistered and is flagged by validate().
class Service:
def __init__(self, cache: Cache | None) -> None: # optional
self.cache = cache
# Before 2.16.0: resolving Service with no Cache provider -> ArgumentResolutionError
# 2.16.0+: resolving Service with no Cache provider -> Service(cache=None)New exceptions
ContainerClosedError(ContainerError)— resolving from / building a child of a closed container (re-enter the context manager to reopen). (#202)UnsupportedCreatorParameterError(RegistrationError)— a creator declares a parameterized-generic or positional-only parameter that cannot be injected. (#202)CreatorCallError(ResolutionError)— an argument-bindingTypeErrorwhen calling the creator (e.g.skip_creator_parsing=Truewith missing required kwargs). (#203)
Internals
AbstractProvidernow declares real__slots__; subclasses declare only their own fields, so provider instances no longer carry__dict__.AbstractProvider.enforces_dependency_scope: ClassVar[bool] = True— setFalseon a subclass whose scope is decorative (asAliasdoes) sovalidate()skips the scope-ordering check on its dependency edges.CacheRegistrytracks creation-completion order (mark_created) and finalizes in reverse;close_syncretains items whose finalizer is async so a laterclose_asynccan recover them.- Coverage flags moved out of pytest
addoptsintojustrecipes:just test(no coverage, for targeted runs),just test-ci(the gated 100% full run, used by CI),just test-branch, andjust bench. Benchmarks renamedbenchmarks/test_bench_*.pyso they collect;testpaths = ["tests"]keeps them out of the default run. - New docs:
docs/providers/errors-and-exceptions.md(exception taxonomy) and an open/close/reopen + close-failure expansion indocs/providers/lifecycle.md. Numerous drift fixes (real error texts in troubleshooting pages, duplicate-type-raises-not-shadows, context scope rule, creator-signature matrix). - Two static exception messages moved to
errors.pytemplates; deadTypeVars / sentinel / a no-op parse pass removed. - Test suite grew from 150 to 192 tests; 100% line coverage maintained across Python 3.10–3.14.
References
- Audit report:
planning/audits/2026-06-12-code-docs-audit-report.md - Plans:
planning/plans/2026-06-12-code-docs-audit.md,2026-06-12-audit-fixes.md,2026-06-13-audit-fixes-round2.md - Deferred follow-ups:
planning/deferred.md