Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/migration/to-4.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ the default applies. `ContextProvider.fetch_context_value()` is removed; resolve
instead, giving it a `default=` if the value may be absent. See
[Context providers: optional context](../providers/context.md#optional-context-default).

### The container copies `context=`

`Container(context=...)` and `build_child_container(context=...)` copy the dict you pass. In 3.x
the container kept your dict, so `set_context()` wrote into it, and two containers built from the
same dict saw each other's values. A module-level dict reused for every request leaked values set on
one request into the next. In 4.0 `set_context()` changes only that container, and changes you make
to your dict after building the container are not seen by it. Call `set_context()` on the container
to add a value later.

### Exception attributes are renamed

`ArgumentResolutionError` names the creator parameter the same way the other errors do:
Expand Down
2 changes: 2 additions & 0 deletions docs/providers/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ stays required, so `container.resolve(fastapi.Request)` outside a request still

Context never propagates between containers. A `ContextProvider` reads the context registry of the container **at the provider's own scope**; build order is irrelevant.

Each container copies the `context=` dict it is built with, so containers built from one dict do not share values, and `set_context()` never writes into your dict.

!!! warning "Scope determines which container is read, not timing"
Setting context on a parent container never reaches a child-scoped provider, regardless of when you call `set_context`:

Expand Down
10 changes: 6 additions & 4 deletions modern_di/container.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import enum
import threading
import typing
Expand Down Expand Up @@ -62,9 +63,10 @@ def __init__(
) -> None:
"""Build a container at ``scope``, open and ready to :meth:`resolve`.

``context`` seeds the context registry. A root binds :class:`Container` itself, so
``resolve(Container)`` returns the resolving container. A cached factory creates at most one
instance per cache key across the tree, whichever threads resolve it.
``context`` is copied into the context registry, so later changes to the caller's dict are
not seen and :meth:`set_context` never writes into it. A root binds :class:`Container`
itself, so ``resolve(Container)`` returns the resolving container. A cached factory creates at
most one instance per cache key across the tree, whichever threads resolve it.

Raises :class:`~modern_di.exceptions.InvalidScopeTypeError` when ``scope`` is not an
``IntEnum``, and :class:`~modern_di.exceptions.InvalidChildScopeError` when it is not
Expand All @@ -86,7 +88,7 @@ def __init__(
else {}
)
self.cache_registry = CacheRegistry()
self.context_registry = ContextRegistry(context or {})
self.context_registry = ContextRegistry(copy.copy(context) if context is not None else {})
self.providers_registry: ProvidersRegistry
self.overrides_registry: OverridesRegistry
# Inlined rather than a helper: this runs per child build (benchmark `test_g6_build_child_container`).
Expand Down
61 changes: 61 additions & 0 deletions tests/providers/test_context_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,3 +1011,64 @@ class G(Group):
" caused by: Provider of scope REQUEST cannot be resolved in container of scope APP.\n"
"See: https://modern-di.modern-python.org/troubleshooting/scope-not-initialized-error/"
)


class _SharedReq: ...


class _LeakedUser: ...


class _LeakGroup(Group):
app_user = providers.ContextProvider(scope=Scope.APP, context_type=_LeakedUser, default=None)
request_user = providers.ContextProvider(
scope=Scope.REQUEST, context_type=_LeakedUser, default=None, bound_type=None
)


def test_set_context_on_child_does_not_leak_to_sibling_or_caller_dict() -> None:
app = Container(scope=Scope.APP, groups=[_LeakGroup])
app.open()
shared = {_SharedReq: _SharedReq()}
first = app.build_child_container(scope=Scope.REQUEST, context=shared)
second = app.build_child_container(scope=Scope.REQUEST, context=shared)

first.set_context(_LeakedUser, _LeakedUser())

assert second.resolve_provider(_LeakGroup.request_user) is None
assert _LeakedUser not in shared
assert isinstance(first.resolve_provider(_LeakGroup.request_user), _LeakedUser)


def test_set_context_on_root_does_not_leak_to_other_root_or_caller_dict() -> None:
shared = {_SharedReq: _SharedReq()}
first = Container(scope=Scope.APP, groups=[_LeakGroup], context=shared)
second = Container(scope=Scope.APP, groups=[_LeakGroup], context=shared)

first.set_context(_LeakedUser, _LeakedUser())

assert second.resolve_provider(_LeakGroup.app_user) is None
assert _LeakedUser not in shared
assert isinstance(first.resolve_provider(_LeakGroup.app_user), _LeakedUser)


def test_caller_dict_changes_after_construction_are_not_seen() -> None:
shared: dict[type, object] = {_SharedReq: _SharedReq()}
container = Container(scope=Scope.APP, groups=[_LeakGroup], context=shared)

shared[_LeakedUser] = _LeakedUser()

assert container.resolve_provider(_LeakGroup.app_user) is None


class _LookupHookContext(dict[type[typing.Any], typing.Any]):
def __contains__(self, key: object) -> bool:
return key is datetime.datetime or super().__contains__(key)

def __getitem__(self, key: type[typing.Any]) -> datetime.datetime:
return datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)


def test_dict_subclass_context_keeps_its_lookup_hooks() -> None:
container = Container(groups=[MyGroup], context=_LookupHookContext())
assert container.resolve(datetime.datetime) == datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
Loading