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, and adds one more that has no 2.x precedent to warn from. Each of the five 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 below escalating those five warnings to errors, those five switches are a no-op for you.
3.0 additionally requires a container to be opened (with/async with/open()) before it can
resolve or build_child_container — switch 6 below — and changes validate's constructor
signature from bool | None to a plain bool. Neither has a 2.x warning to escalate: 2.x has no
"unopened" state to signal on, and an explicit validate=True in 2.x validates eagerly at
construction, a timing 3.0 changes without ever warning about it. These are genuine hard breaks —
a green suite under the recipe does not, by itself, get you past them. See
switch 4 and
switch 6 below.
The six 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 defaults to True and runs at container entry (open()/with) |
UnvalidatedContainerWarning — covers the unset case only; see below |
Direct resolve of an unset ContextProvider raises ContextValueNotSetError |
ContextValueNoneWarning |
A container must be opened before resolve/build_child_container |
none — inherent hard break, no 2.x state to warn from |
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):
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):
container = Container(scope=Scope.APP, groups=[MyGroup], validate=True)
with container:
service = container.resolve(MyService) # works — container is open inside the block
# the `with` block closed the container on exit
service = container.resolve(MyService) # raises ContainerClosedError — reused after close
Re-enter the container with with/async with, or call container.open(), before reusing it.
This is one half of a single rule: a container must be open to be used. This switch is the
closed-after-use half (a container that was open, then closed); switch 6
below is the never-opened half (a fresh container that was never entered at all). Both raise the
same ContainerClosedError, and both are fixed the same way — enter the container with
with/async with, or call open(), before resolving or building children.
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):
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(DatabaseProtocol, scope=Scope.APP)
After (3.0):
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):
# 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(
create_resource,
scope=Scope.REQUEST,
cache_settings=providers.CacheSettings(finalizer=lambda resource: resource.close()),
)
After (3.0):
factory = providers.Factory(
create_resource,
scope=Scope.REQUEST,
cache=providers.CacheSettings(finalizer=lambda resource: resource.close()),
)
4. validate runs at container entry, on by default¶
The final 3.0 form differs from what 2.x signals in two ways, so read this one carefully.
The signature. In 2.x, Container's validate argument is bool | None = None: unset (None)
skips validation but emits UnvalidatedContainerWarning; False skips it silently; True enables
it. In 3.0, the parameter is a plain validate: bool = True — the None sentinel is gone.
Passing validate=False still means "off"; there is no other spelling to adopt for the unset case,
because unset now is the default-on case.
The timing. In 2.x, validate=True validates eagerly at construction — Container(...)
itself raises ValidationFailedError if the graph is broken. In 3.0, validation never runs in
__init__. It runs once, at container entry — open(), or with/async with (which call
open()) — so an invalid graph raises there instead. This lets a framework integration register
its own providers (e.g. via add_providers) after construction and still have the complete graph
validated before first use. validate=True is not eager: if you need a construction-time
check, call container.validate() explicitly right after building it.
This timing change has no 2.x warning: an explicit validate=True caller in 2.x sees no
deprecation notice, because from 2.x's perspective that call already validates and already
succeeds — 2.x has nothing to warn about a timing it doesn't yet have. UnvalidatedContainerWarning
only ever covered the unset case (2.x's "no explicit validate= argument" state); it says nothing
about when validation happens once enabled. Escalating it to an error still gets you a 2.x-clean
signal for switching the default to on — it does not, and cannot, warn you about the timing
move for callers who already pass validate=True.
Before (2.x):
# UnvalidatedContainerWarning: This root container was created without an
# explicit `validate` argument. modern-di 3.0 runs validate() at container
# entry 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])
container.resolve(MyService)
# Explicit opt-in — validates immediately, no warning, at construction:
container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) # raises here if broken
After (3.0):
# validate is on by default; it runs once at open(), not at construction.
with Container(scope=Scope.APP, groups=[MyGroup]) as container:
# validate() already ran here — raises ValidationFailedError before this
# block is entered if the graph has cycles or scope-ordering problems.
service = container.resolve(MyService)
# Opt out entirely — this spelling works identically before and after 3.0.
container = Container(scope=Scope.APP, groups=[MyGroup], validate=False)
# Want the check at construction time instead of at open()? Call it yourself.
container = Container(scope=Scope.APP, groups=[MyGroup])
container.validate() # raises ValidationFailedError here if the graph is broken
Child containers (built via build_child_container) never validate, in either version — this
switch only affects root containers.
Changed again in 3.1 — validation is explicit-only; open() no longer runs it either. See
the 3.1 note under switch 6 below for the full
correction.
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):
# ContextValueNoneWarning: No context value is set for <class '...'> (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):
Pass context={SomeContextType: value} to the container (or its ancestor at the
ContextProvider's scope), or call container.set_context(SomeContextType, value), before
resolving.
6. A container must be opened before use¶
New in 3.0, added mid-development, with no 2.x deprecation signal at all — 2.x has no
"unopened" state, so there was never anything for it to warn about. A freshly constructed
container now starts unopened; using it before entering it — resolve, resolve_provider,
build_child_container — raises ContainerClosedError. Enter it with with/async with, or call
open() directly (for a callback-style lifecycle that cannot use a with block), before the first
use. Child containers (from build_child_container) also start unopened and must be entered
themselves before they can be used.
This is the never-opened half of the same rule as switch 1
above (the closed-after-use half): a container must be open to be used, whether it was never
opened or was opened and then closed. Both cases raise the identical ContainerClosedError, with a
message that names which state applies, and both are fixed the same way.
Before (2.x):
container = Container(scope=Scope.APP, groups=[MyGroup])
service = container.resolve(MyService) # works — no open() call needed
child = container.build_child_container(scope=Scope.REQUEST)
value = child.resolve(SomeContextType) # works — no open() call needed either
After (3.0):
container = Container(scope=Scope.APP, groups=[MyGroup])
service = container.resolve(MyService) # raises ContainerClosedError: not open
# Fix: enter the container first.
with Container(scope=Scope.APP, groups=[MyGroup]) as container:
service = container.resolve(MyService) # works
# A child also starts unopened and must be entered before use.
with container.build_child_container(scope=Scope.REQUEST) as child:
value = child.resolve(SomeContextType) # works
# Or, without a `with` block:
container = Container(scope=Scope.APP, groups=[MyGroup])
container.open()
service = container.resolve(MyService) # works
Because there is no 2.x signal for this one, the readiness recipe
below cannot surface it in advance — a green 2.x suite under that recipe still needs every
construct-then-use call site audited for a matching with/open() before it can run against 3.0.
Changed again in 3.1. This requirement is relaxed, not reversed: see the
3.1 release notes for the full
change. A container is open from construction again — closed = False the moment
Container(...) returns, no open() step required — and reusing a container after an
explicit close warns (ContainerClosedWarning) and reopens instead of raising
ContainerClosedError.
An earlier version of this note said every pattern shown above under "After (3.0)" kept
working unchanged, including that with/open() "still validates, still fails fast." That
part was wrong and has been corrected here: validation is explicit-only as of 3.1.
open() (and with/async with, which call it) no longer runs validate() — it only
clears closed, unconditionally. Nothing validates automatically: not construction,
not open(), not add_providers, not resolve().
A test suite that asserts on closed will fail
3.1 is a relaxation for callers, but not for tests that assert the lifecycle flag. Two 3.0-era assertions break, and both were found in the wild across the official integrations:
assert container.closed is Trueon a freshly built container — it isFalsein 3.1, because construction leaves it open.with pytest.raises(ValidationFailedError): container.open()—open()validates nothing in 3.1, so it does not raise.
Both are mechanical to fix, but the second needs care: if a test's subject is the
lifecycle transition ("this signal opens the root"), flipping the assertion makes it
pass without proving anything. Close the container first, so the transition stays
observable. If the subject is the validation-ordering rule, point it at
container.validate() — the rule still holds, it just binds a different call.
container.validate() is the only thing that walks the graph, and Container(validate=...)
is deprecated — passing True or False is ignored and emits ValidateArgumentWarning
(a DeprecationWarning), removed in 4.0. So in the "After (3.0)" example above, the comment
# validate() already ran here no longer holds in 3.1 — call container.validate()
explicitly, right after construction (or after an integration's setup_di registers its own
providers via add_providers, if you want the complete graph checked), for the same
fail-fast check. with/open() still open the container and still guarantee close_* runs
finalizers on the way out — that part of "After (3.0)" is unaffected — this switch (mandatory
open) just stops being mandatory for code that skips it, and validation timing is fully
decoupled from it.
Readiness recipe: escalating warnings to errors with filterwarnings¶
This is the one place in the docs that lists the full filterwarnings escalation recipe; every
other page that mentions escalating a specific warning links back here.
This recipe covers switches 1, 2, 3, and 5 fully, and switch 4 only for the unset-validate
case — the case UnvalidatedContainerWarning actually warns about. It has nothing to say about
switch 6 (mandatory-open) or about switch 4's timing move for callers who already pass
validate=True explicitly: both are hard breaks with no 2.x warning to escalate. A green suite
under this recipe rules out five-and-a-half of the six switches; you still need to audit
construct-then-use call sites for with/open() (switch 6) and, if you pass validate=True
explicitly today, re-check any code that depends on validation happening at construction rather
than at open() (switch 4).
ContainerClosedWarning was a DeprecationWarning in 2.x. As of 3.1 it is a RuntimeWarning
instead — deliberately, since CPython hides DeprecationWarning outside __main__, which would
hide exactly the diagnostic this warning exists for — so the blanket categories below no longer
catch it; add its dedicated-class filter alongside them. ContextValueNoneWarning subclasses
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, plus ContainerClosedWarning's own class,
therefore turns all five signals into failures a green test suite would catch:
import warnings
from modern_di import exceptions
warnings.filterwarnings("error", category=DeprecationWarning)
warnings.filterwarnings("error", category=FutureWarning)
warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning)
plus the pytest variant:
[tool.pytest.ini_options]
filterwarnings = [
"error::DeprecationWarning",
"error::FutureWarning",
"error::modern_di.exceptions.ContainerClosedWarning",
]
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.
Changed again in 3.1. ContainerClosedWarning now computes its stacklevel (via
_caller_stacklevel) so it attributes outside modern_di, and ContextValueNoneWarning
has no raise sites left at all — so on 3.1 a module=r"modern_di(\..*)?" filter escalates
none of the five. The paragraph above describes 2.x, which is what this page's recipe runs
against.
If the broad category filter is too wide for your process (e.g. another dependency's
DeprecationWarnings 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:
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 that can be signalled 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, those five switches require no code changes on your part.
That policy has a boundary: it only covers changes 2.x has a state to warn from. Mandatory-open
(switch 6) is a new requirement with no 2.x precedent — a 2.x container has no "unopened" state, so
there was never a warning to add. Likewise, switch 4's timing move (construction to open()) only
affects callers who already pass validate=True, a code path 2.x treats as already-correct and so
never warns about. Neither omission is an oversight in this guide; there is no signal to point to.
Upgrading to 3.0 requires opening every container you construct-then-use, in addition to a clean
run under the readiness recipe above.