Releases: modern-python/modern-di
2.15.0
modern-di 2.15.0 — Audit-driven correctness pass
2.15.0 is mostly additive. One behavior change in Container.validate() is called out in Behavior changes. Code that does not catch CircularDependencyError from validate() directly continues to work unchanged.
This release ships ten focused PRs (#188–#197) driven by a single bug-hunt audit of the codebase. Eighteen audit findings — 2 must-fix, 3 should-fix, 13 nice-to-have — are addressed. The remaining audit items (22 intentional-behavior entries) are recorded in planning/audits/2026-06-05-bug-hunt-audit-report.md and not actioned.
New features
Container.validate()now works across scopes. Previously,validate()(andvalidate=Trueat construction time) raisedScopeNotInitializedErrorfor any provider whose scope was deeper than the root container — making the feature unusable for any realistic web app withREQUEST-scoped providers.Factory.get_dependencieswas refactored into a pure type→provider lookup that no longer requiresfind_container. (#189)validate()now catches two new wiring-bug classes at startup. Inverted-scope dependencies (anAPP-scoped provider depending on aREQUEST-scoped one) raise a newInvalidScopeDependencyError. Missing required dependencies (parameter with no provider, no default, no static kwarg) raise the sameArgumentResolutionErroryou'd see at resolve time, just earlier. All issues are accumulated and reported together. (#189)Groupinheritance is now supported. Subclassing aGroupto add providers ("class TestDeps(AppDeps):") now correctly registers both the parent's and the child's providers.Group.get_providerswalkscls.__mro__and tracks attribute names so subclass overrides and non-provider masks work the way Python's normal attribute lookup does. (#197)Factoryvalidates statickwargsagainst the creator signature at construction time. A typo likekwargs={'connetion_string': ...}previously surfaced at instantiation as a rawTypeErrorwith no provider identity. NowFactory.__init__raisesUnknownFactoryKwargError(RegistrationError)immediately, naming the offending keys and usingdifflib.get_close_matchesto suggest the closest valid parameter. Validation is skipped when the creator accepts**kwargsor when its signature is uninspectable. (#194)Container(use_lock=False)is honored across child containers.build_child_containernow propagates the parent'suse_locksetting; the documented single-threaded opt-out no longer silently re-acquires locking atREQUESTscope. (#193)container_providerworks onContainersubclasses. Auto-injection of aContainerparameter (the documented pattern indocs/providers/container.md) now resolves correctly even when the user subclassesContainer— the provider is now registered under the baseContainerclass instead oftype(self). (#193)Container.__init__rejects non-IntEnumscopevalues up front. A newInvalidScopeTypeError(ContainerError)raises at construction with a clear message instead of exploding later from an unrelated__repr__call with a bareAttributeError. (#193)
Correctness fixes
- Singleton resolution is now re-entrant.
Container.lockswitched fromthreading.Locktothreading.RLock. The documentedcontainer_providerauto-injection pattern (where a creator's__init__acceptscontainer: Containerand callscontainer.resolve(other_singleton)) no longer deadlocks the calling thread. (#188) build_child_container(scope=ZERO_VALUED_INTENUM)raises instead of silently auto-incrementing. CustomIntEnummembers with value 0 are falsy in Python; the previous truthiness check misclassified them as "scope omitted" and fell through to the auto-increment branch. Now usesis None. (#190)- Self-reference in union-typed parameters falls through to the default instead of deadlocking. A creator typed
def make(x: int | SelfType = 1)previously triggeredRecursionErrorat resolve time because the union-lookup branch did not checkprovider is self. Now mirrors the single-type guard. (#191) - Default parameter is used when its
ContextProvider's value is unset. Previously,_compile_kwargsraisedArgumentResolutionErrorimmediately on finding an unsetContextProvider— without checking whether the parameter had a default. The check is now symmetric with the no-provider branch directly below it. (#192) Factorycorrectly handlesdefaultvalues whose__eq__returnsTruefor all comparands (e.g.,unittest.mock.ANY). The UNSET sentinel comparison is nowisinstead of==, matching the rest of the codebase. (#194)CacheItem.close_sync/close_asyncno longer re-run the finalizer whenclear_cache=False. Repeated container shutdowns — or the canonical Resource-replacement pattern fromdocs/migration/to-2.x.md— no longer double-close DB connections. A newfinalizedflag guards the finalizer;_clear()resets it when actually clearing the cache so re-resolved values can be finalized again on the next close. (#195)
Behavior changes
Container.validate() now raises ValidationFailedError (aggregate) instead of CircularDependencyError directly. (#189)
The new exception carries an .errors: list[Exception] attribute containing every issue found during the walk — cycles, inverted-scope deps, and missing required deps. Single-error runs still go through ValidationFailedError; the inner exceptions are typed (CircularDependencyError, InvalidScopeDependencyError, ArgumentResolutionError). Callers who previously caught CircularDependencyError directly need to update to either catch the aggregate or unwrap .errors:
# Before
try:
container.validate()
except CircularDependencyError as e:
log.error("cycle: %s", e.cycle_path)
# After
try:
container.validate()
except ValidationFailedError as e:
for issue in e.errors:
if isinstance(issue, CircularDependencyError):
log.error("cycle: %s", issue.cycle_path)
elif isinstance(issue, InvalidScopeDependencyError):
log.error("inverted scope: %s -> %s", issue.provider, issue.dep_provider)
elif isinstance(issue, ArgumentResolutionError):
log.error("missing dep: %s", issue.arg_name)If you only ever called validate() and let exceptions propagate at startup, no change is needed.
New exceptions
ValidationFailedError(ContainerError)— aggregate raised byContainer.validate()when any issues are found.InvalidScopeDependencyError(RegistrationError)— a provider depends on another with a strictly deeper scope.InvalidScopeTypeError(ContainerError)—Container.__init__received a non-IntEnumscopevalue.UnknownFactoryKwargError(RegistrationError)—Factory(kwargs=...)contains a key not present in the creator's signature.
Internals
AbstractProvidergained aniter_validation_issues(container) -> Iterable[Exception]method with a default no-op implementation.Factoryoverrides it to yield missing-depArgumentResolutionErrors;Container.validate()collects them. CustomAbstractProvidersubclasses (if any) continue to work unchanged.Factorygained an internal_find_dep_provider(container, v)helper shared betweenget_dependenciesand_compile_kwargs.CacheItemgained afinalized: boolfield.- Test suite grew from 125 to 150 tests over this release; 100% coverage maintained throughout.
References
- Audit report:
planning/audits/2026-06-05-bug-hunt-audit-report.md - Audit harness (reusable):
planning/scripts/bug-hunt-audit.workflow.mjs - Specs:
planning/specs/— audit design, singleton RLock, validate rework - Plans:
planning/plans/— implementation plans for each major fix
2.14.0
What's Changed
- Argument suggestions, context falsy/None fix, docs lead with
withby @lesnik512 in #184 - DX polish: repr, keyword-only build_child_container, typed UNSET, error hints by @lesnik512 in #185
- Raise on async finalizer in close_sync; drop Group cache; slot exceptions by @lesnik512 in #186
Full Changelog: 2.13.0...2.14.0
2.13.0
What's Changed
- Replace RuntimeError raises with custom exception hierarchy by @lesnik512 in #178
- Include dependency path in resolution error messages by @lesnik512 in #179
- Add smart suggestions to resolution error messages by @lesnik512 in #180
- Add Alias provider for interface-to-implementation binding by @lesnik512 in #181
- Unify validation and remove Container.validate_provider by @lesnik512 in #182
- Support user-defined custom scopes via any IntEnum by @lesnik512 in #183
Full Changelog: 2.12.0...2.13.0
2.12.0
What's Changed
- add info about typer integration by @lesnik512 in #168
- Optimize singleton resolution with early cache return by @lesnik512 in #169
- Add Container.validate() for cycle detection
Full Changelog: 2.11.0...2.12.0
2.11.0
What's Changed
- Perf optimizations by @lesnik512 in #166
- Switch build backend to uv_build by @lesnik512 in #167
Full Changelog: 2.10.0...2.11.0
2.10.0
What's Changed
- Add context manager support, repr, and skip_creator_parsing warning by @lesnik512 in #165
Full Changelog: 2.9.0...2.10.0
2.9.0
What's Changed
Code fixes:
- Replaced all private typing._* internals with public get_origin() / get_args() / hasattr(metadata) API — future-proof across Python versions
- Silent NameError on unresolvable forward references now emits a UserWarning naming the creator and the missing annotation
- Removed misleading frozen=True from CacheRegistry, ContextRegistry, OverridesRegistry — all three mutate their dict fields in place
- ContainerProvider no longer requires post-construction mutation by the container — added ProvidersRegistry.register(type, provider) to map the container type directly, keeping bound_type=None permanently on the singleton
- Finalizer exceptions no longer abort remaining cleanup — both close_async() and close_sync() now run all finalizers, collect failures, and raise a combined RuntimeError at the end
Tooling:
- Migrated all 8 # type: ignore[...] suppressions to # ty: ignore[rule] after mypy was replaced by ty
Documentation:
- use_lock=False documented under Cached Factories
- Union resolution order documented in factories and resolving pages
- Override global scope behavior documented in testing fixtures
- Async finalizers vs sync-only resolution distinction added to migration guide
Tests:
- Added test_sync_finalizer_exception_does_not_abort_remaining_cleanup
- Added test_async_finalizer_exception_does_not_abort_remaining_cleanu
Full Changelog: 2.8.4...2.9.0
2.8.4
What's Changed
- add page about DI by @lesnik512 in #161
- add instruction about duplicate provider error by @lesnik512 in #162
Full Changelog: 2.8.3...2.8.4