diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index 545e4a90..3f7e0682 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -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: diff --git a/docs/providers/context.md b/docs/providers/context.md index ee48f454..9e890779 100644 --- a/docs/providers/context.md +++ b/docs/providers/context.md @@ -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`: diff --git a/modern_di/container.py b/modern_di/container.py index a8da786c..9ed77c3e 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -1,3 +1,4 @@ +import copy import enum import threading import typing @@ -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 @@ -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`). diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 0c03ef17..47538054 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -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)