Skip to content

Releases: modern-python/modern-di

2.21.1

Choose a tag to compare

@github-actions github-actions released this 01 Jul 18:07
4999a4e

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_TOKEN secret. uv publish auto-detects the GitHub Actions id-token; the release job runs under a pypi environment that scopes the trusted publisher (#251).

Downstream

No action required. Nothing about the installed package changes.

2.21.0

Choose a tag to compare

@github-actions github-actions released this 26 Jun 19:00
8df6361

modern-di 2.21.0 — ContextProvider.context_type

Purely additive. One newly-public attribute; the contract of existing code is unchanged.

Feature

  • ContextProvider.context_type is now a public attribute — the type the provider supplies and the key its value is set under in context. 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; ruff and ty clean.

2.20.0

Choose a tag to compare

@github-actions github-actions released this 26 Jun 07:45
1d027a1

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 now list(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; ruff and ty clean.

2.19.2

Choose a tag to compare

@github-actions github-actions released this 25 Jun 08:24
ed9b123

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; ruff and ty clean.

2.19.1 — Internal deepening, no behavior change

Choose a tag to compare

@lesnik512 lesnik512 released this 23 Jun 15:37
e0f6392

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

  • WiringPlan extracted from Factory. The kwarg-wiring decision — match each creator parameter to a provider, else omit / inject None / 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 a Container, and CacheItem holds a single WiringPlan instead of four compiled-kwargs fields.
  • Error messages inlined; errors.py removed. 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 suggester module. The shared difflib "did you mean?" fuzzy match — registry type suggestions and factory unknown-kwarg typos — is now one directly-tested close_matches primitive, 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; ruff and ty clean.

2.19.0 — Public Container.open()

Choose a tag to compare

@lesnik512 lesnik512 released this 16 Jun 14:11
131c173

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. Sets closed = False so a closed container can resolve and build children again. __enter__/__aenter__ now call open() 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

Choose a tag to compare

@lesnik512 lesnik512 released this 14 Jun 13:10
9d6b73e

Mostly additive and backward-compatible. Ships the 2026-06-14 deep audit (#216#220). Full notes: planning/releases/2.18.0.md.

Fixes

  • set_context now propagates across scopes (#216). A deeper-scoped Factory (e.g. REQUEST) reading a shallower-scoped ContextProvider (e.g. APP) now picks up a late set_context on subsequent resolves; previously an unset-at-first-resolve value was baked in and stayed None/default forever. The 2.16.0 fix only covered the same-scope case. Context values are now resolved live on every resolve.
  • Gapped custom-IntEnum child scopes (#218). build_child_container() derives the next-deeper enum member instead of current + 1, so non-contiguous custom scopes (e.g. TENANT=6, JOB=10) no longer raise a spurious MaxScopeReachedError.
  • Late override of a context-backed parameter now takes effect (#216).

New public API

  • ContextProvider.fetch_context_value(container) — live value or types.UNSET (#219)
  • AbstractProvider.display_name — bound-type/creator name for messages (#219)
  • exceptions exported from modern_di and in __all__ (#220)

Performance

  • No per-resolve CacheItem allocation 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_context does not rebuild it.

DX / Internals

  • Class docstrings on every concrete exception (IDE hover); ResolutionStep documented (#220).
  • Internal CacheRegistry.invalidate_compiled_kwargs removed (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

Choose a tag to compare

@lesnik512 lesnik512 released this 13 Jun 13:45
922f513

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 with InvalidScopeDependencyError at validation time, instead of passing validate() and failing only at runtime with ScopeNotInitializedError. Implemented via a new AbstractProvider.effective_scope(container) hook (default self.scope; Alias follows its source chain). This replaces the internal enforces_dependency_scope flag introduced in 2.16.0.

Behavior changes

  • validate() may newly raise ValidationFailedError(InvalidScopeDependencyError) for graphs of the shape Factory(shallow) → Alias → Factory(deeper) that previously passed validation. These graphs were already broken (they raised ScopeNotInitializedError at 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. Passing scope= emits a DeprecationWarning; the parameter will be removed in 3.0.

Internals

  • Removed the AbstractProvider.enforces_dependency_scope ClassVar (superseded by effective_scope). Custom transparent providers should override effective_scope(container) instead.
  • 100% line coverage maintained across Python 3.10–3.14.

References

2.16.1 — Restore zero-dependency install

Choose a tag to compare

@lesnik512 lesnik512 released this 13 Jun 10:00
2f81380

modern-di 2.16.1 — Restore zero-dependency install

Patch release. No API or behavior changes.

Fix

  • import modern_di no longer requires typing_extensions at runtime. modern_di/container.py imported typing_extensions unconditionally at module load, even though it is used only for typing_extensions.Self in (non-evaluated) type annotations and is not declared as a runtime dependency. On a clean install with no transitively-present typing_extensions, import modern_di raised ModuleNotFoundError: No module named 'typing_extensions'. The import is now guarded by if typing.TYPE_CHECKING: (matching modern_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-pytest failed on a clean uv sync). Pre-existing since the import was introduced; not a 2.16.0 regression.

Internals

  • Regression guard: tests/test_packaging.py imports modern_di and builds a child container in a subprocess with typing_extensions blocked, so any future unconditional runtime import of it fails CI.
  • 193 tests; 100% line coverage on Python 3.10–3.14.

References

2.16.0 — Second audit pass: bug fixes, optional-dependency injection, docs

Choose a tag to compare

@lesnik512 lesnik512 released this 13 Jun 09:45
593dbb5

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 None when unregistered. A creator parameter annotated X | None (or Optional[X]) now injects a registered provider for X if one exists, and otherwise injects None — no provider and no default required. container.validate() no longer flags such parameters. The previously-dead SignatureItem.is_nullable field 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 with CacheSettings(clear_cache=False) survive the close→reopen cycle and are returned again (same object); clear_cache=True instances 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 ResolutionError raised while resolving through an Alias now 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. An Alias whose (decorative) scope is shallower than its source's scope is no longer flagged with InvalidScopeDependencyError, since alias scope never affects resolution. Implemented via the new AbstractProvider.enforces_dependency_scope class flag (Alias sets it False). (#203) (A residual transitive-scope limitation is recorded in planning/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 in close_sync), instead of being called and the coroutine silently dropped (a resource leak). (#202)
  • set_context is honored after a dependent factory has resolved. Calling set_context on a container now invalidates compiled kwargs so subsequent resolves of non-cached factories pick up the new value, instead of permanently baking out an unset ContextProvider. (#202)
  • Parameterized generics and positional-only creator params fail clearly at declaration. list[Svc]-style parameterized generics and positional-only parameters now raise UnsupportedCreatorParameterError at Factory(...) construction (unless supplied via kwargs or given a default), instead of silently degrading to the origin type or dying with a raw TypeError at resolve. (#202)
  • get_type_hints TypeError is warn-and-skipped. A creator whose hints can't be introspected (e.g. functools.partial on Python < 3.14) now emits a UserWarning and 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 dangling Alias (bound under a type different from its unregistered source) is now collected into ValidationFailedError along with other issues, instead of aborting the whole validation with a bare AliasSourceNotRegisteredError. (#202)
  • Container(parent_container=...) enforces scope ordering. The public constructor now raises InvalidChildScopeError for a non-increasing scope, the same check build_child_container already applied — preventing a silently-shadowed parent and duplicated singletons. (#202)
  • Atomic group registration. Registering groups= is now all-or-nothing: a duplicate-type collision raises DuplicateProviderTypeError without leaving earlier providers from the failed batch in the shared registry. (#202)
  • ProvidersRegistry is 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 TypeError carries DI context. An argument-binding TypeError from a skip_creator_parsing=True factory with missing kwargs is wrapped in the new CreatorCallError (a ResolutionError) with the creator name and resolution chain. A TypeError raised 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-binding TypeError when calling the creator (e.g. skip_creator_parsing=True with missing required kwargs). (#203)

Internals

  • AbstractProvider now declares real __slots__; subclasses declare only their own fields, so provider instances no longer carry __dict__.
  • AbstractProvider.enforces_dependency_scope: ClassVar[bool] = True — set False on a subclass whose scope is decorative (as Alias does) so validate() skips the scope-ordering check on its dependency edges.
  • CacheRegistry tracks creation-completion order (mark_created) and finalizes in reverse; close_sync retains items whose finalizer is async so a later close_async can recover them.
  • Coverage flags moved out of pytest addopts into just recipes: just test (no coverage, for targeted runs), just test-ci (the gated 100% full run, used by CI), just test-branch, and just bench. Benchmarks renamed benchmarks/test_bench_*.py so 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 in docs/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.py templates; dead TypeVars / 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