From d026aaed75bdaa0726678beb5071465eadc03b32 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 4 Oct 2026 22:34:16 +0300 Subject: [PATCH] feat!: tighten Container's public surface (#565) --- docs/migration/to-4.x.md | 37 +++++ docs/providers/container.md | 18 ++- docs/providers/errors-and-exceptions.md | 3 +- docs/providers/lifecycle.md | 4 +- docs/providers/scopes.md | 2 +- .../child-container-registration-error.md | 15 +- modern_di/container.py | 134 ++++++++++++------ modern_di/dependency_graph.py | 7 +- modern_di/exceptions/registration.py | 8 +- modern_di/providers/alias.py | 2 +- modern_di/providers/factory.py | 7 +- modern_di/resolver_compiler.py | 14 +- tests/providers/test_alias.py | 4 +- tests/providers/test_factory.py | 52 +++---- tests/providers/test_singleton.py | 14 +- tests/registries/test_providers_registry.py | 4 +- tests/test_container.py | 56 +++++++- tests/test_dependency_graph.py | 2 +- tests/test_free_threading.py | 2 +- tests/test_group.py | 5 +- tests/test_resolver_compiler.py | 20 +-- tests/test_types_parser.py | 4 +- 22 files changed, 281 insertions(+), 133 deletions(-) diff --git a/docs/migration/to-4.x.md b/docs/migration/to-4.x.md index fabe68be..3cf7b402 100644 --- a/docs/migration/to-4.x.md +++ b/docs/migration/to-4.x.md @@ -200,6 +200,43 @@ parameters from the `__new__` annotations. A `Factory` whose creator returns a union of several types (`-> A | B`) still gets no bound type, and now emits a `UserWarning` saying so. Pass `bound_type=` explicitly to silence it. +### `Container` registries are private + +`providers_registry`, `cache_registry`, `context_registry` and `overrides_registry` are no longer +public attributes of `Container`. To look up the provider registered for a type, call +`container.find_provider(SomeType)`, which returns the provider or `None`: + +```python +# 3.x +provider = container.providers_registry.find_provider(SomeType) + +# 4.0 +provider = container.find_provider(SomeType) +``` + +Register providers with `groups=` on the root or with `add_providers()`, and manage overrides with +`override()` and `reset_override()`. See +[Container: looking up a provider](../providers/container.md#looking-up-a-provider). + +### `Container.closed` is read-only + +`container.closed` still reports whether the container is closed, but assigning to it raises +`AttributeError`. Close a container with `close_sync()`, `close_async()` or by leaving `with` / +`async with`, and reopen it with `open()`. + +### A child container rejects `groups=` + +`Container(scope=..., parent_container=parent, groups=[...])` raises +`ChildContainerRegistrationError`, the same error `add_providers()` raises on a child. In 3.x the +groups were registered into the registry the whole tree shares. Pass the groups to the root +container instead. The error message now covers both cases, so update any test that matched the +old `Container.add_providers can only be called on a root container` text. + +### A closed container raises before the provider lookup + +`resolve()` on a closed container raises `ContainerClosedError` even when the type is not +registered. In 3.x that call raised `ProviderNotRegisteredError`. + ### The 3.x deprecations are removed - `Container(validate=...)` raises `TypeError`, and `ValidateArgumentWarning` is gone with it. Drop diff --git a/docs/providers/container.md b/docs/providers/container.md index b9d3dd06..bfc0bec8 100644 --- a/docs/providers/container.md +++ b/docs/providers/container.md @@ -70,11 +70,23 @@ context/cache), while app-scoped code reaches the app container. ## Registering providers after construction `container.add_providers(*providers)` registers additional providers on a **root** container after -it's built. It is the blessed seam framework integrations use instead of reaching into -`providers_registry` directly. Raises `ChildContainerRegistrationError` if called on a child -container. See [Writing an integration](../integrations/writing-integrations.md#the-contract) for +it's built. Framework integrations use it to register their connection providers. Raises +`ChildContainerRegistrationError` if called on a child container, and so does passing `groups=` +together with `parent_container=`. See [Writing an integration](../integrations/writing-integrations.md#the-contract) for the full contract. +## Looking up a provider + +`container.find_provider(SomeType)` returns the provider registered for `SomeType`, or `None` when +nothing is. Every container in a tree sees the same providers, so a child answers the same as its +root. The lookup ignores overrides and the closed state, and it never resolves anything: + +```python +provider = request_container.find_provider(UserRepository) +if provider is not None: + repository = request_container.resolve_provider(provider) +``` + ## Resolving a provider or type `container.resolve_dependency(dep)` accepts either a provider reference or a type and dispatches to diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index e5117cd0..d820f4ad 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -145,7 +145,8 @@ declared or registered, or by `validate()`, which reports `InvalidScopeDependenc (within one group, across groups passed together, or against an already-registered type). See [Troubleshooting: Duplicate type](../troubleshooting/duplicate-type-error.md). - `ChildContainerRegistrationError` is raised by `Container.add_providers()` when called on a child - container; registration is root-only because the providers registry is shared tree-wide, so + container, and by `Container(...)` when `groups=` comes with `parent_container=`. Registration is + root-only because the providers registry is shared tree-wide, so registering from a child would mutate every container in the tree. Call `add_providers` on the root container instead. Inspect `.scope` for the offending child container's scope. See [Container: registering after construction](container.md#registering-providers-after-construction) and diff --git a/docs/providers/lifecycle.md b/docs/providers/lifecycle.md index 07860500..0faf1f92 100644 --- a/docs/providers/lifecycle.md +++ b/docs/providers/lifecycle.md @@ -132,8 +132,8 @@ finalizer; the sync path is only a safety net. ## Closing and reopening -A constructed container is **open from construction**: `closed = False` the moment `Container(...)` -returns, with no `open()` step required before the first `resolve()` / `resolve_provider()` call. +A constructed container is **open from construction**: `container.closed` is `False` the moment +`Container(...)` returns, with no `open()` step required before the first `resolve()` / `resolve_provider()` call. `build_child_container()` never checks or touches any container's open/closed state (it only reads the parent's shared registries and scope map), and the returned child starts open too, same as any fresh container. `close_sync()` / `close_async()` run the finalizers (in reverse-creation order, as diff --git a/docs/providers/scopes.md b/docs/providers/scopes.md index 73cfd302..52257b3c 100644 --- a/docs/providers/scopes.md +++ b/docs/providers/scopes.md @@ -36,7 +36,7 @@ with app_container.build_child_container(scope=Scope.REQUEST) as request_contain `Dependencies` here is a `Group` subclass holding the provider definitions. See the [Quick Start](../index.md) or [Resolving dependencies](../introduction/resolving.md) for how it's declared. -Children share their parent's `providers_registry` (provider definitions) and `overrides_registry` (test overrides) but have their own `cache_registry` (resolved instances) and `context_registry` (runtime context values). That's why a REQUEST-scoped factory produces one instance per request: the cache lives on the request container, not the app container. +Children share their parent's provider definitions and test overrides, and each one keeps its own resolved instances and runtime context values. That's why a REQUEST-scoped factory produces one instance per request: the cache lives on the request container, not the app container. ## The scope dependency rule diff --git a/docs/troubleshooting/child-container-registration-error.md b/docs/troubleshooting/child-container-registration-error.md index bf610fdc..f377a368 100644 --- a/docs/troubleshooting/child-container-registration-error.md +++ b/docs/troubleshooting/child-container-registration-error.md @@ -2,17 +2,19 @@ ## Symptom -Raised from `Container.add_providers()`, naming the scope of the child container it was called on. +Raised from `Container.add_providers()` on a child container, or from `Container(...)` when +`groups=` is passed together with `parent_container=`. It names the child container's scope. ## Cause -`add_providers()` was called on a child container rather than the root. The providers registry is +Providers were registered on a child container rather than the root. The providers registry is shared tree-wide (every container in the chain points at the same registry), so registering from a child would silently mutate every container in the tree, so the call is disallowed. ## Fix -Call `add_providers()` on the root container instead: +Register on the root container instead, either with `groups=` when you build it or with +`add_providers()` later: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) @@ -23,6 +25,13 @@ request_container.add_providers(late_provider) # raises ChildContainerRegistrat # Works app_container.add_providers(late_provider) + +# Wrong +Container(scope=Scope.REQUEST, parent_container=app_container, groups=[RequestGroup]) + +# Right +app_container = Container(scope=Scope.APP, groups=[MyGroup, RequestGroup]) +request_container = app_container.build_child_container(scope=Scope.REQUEST) ``` If you only have a reference to the child container at the call site, keep a reference to the root diff --git a/modern_di/container.py b/modern_di/container.py index 9ed77c3e..5ab85cb4 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -10,22 +10,21 @@ from modern_di.providers.container_provider import container_provider from modern_di.registries.cache_registry import CacheRegistry from modern_di.registries.context_registry import ContextRegistry -from modern_di.registries.overrides_registry import OverrideHandle, OverridesRegistry +from modern_di.registries.overrides_registry import OverrideHandle from modern_di.registries.providers_registry import ProvidersRegistry from modern_di.resolver_compiler import STEP_ERRORS from modern_di.scope import Scope, _next_deeper def _handle_recursion_error( - provider: AbstractProvider[typing.Any], container: "Container", exc: RecursionError + provider: AbstractProvider[typing.Any], container: "Container", registry: ProvidersRegistry, exc: RecursionError ) -> typing.NoReturn: """Convert an escaped `RecursionError` to `CircularDependencyError`, or re-raise it unchanged. A separate call, not inlined into `resolve_provider`: the coverage tracer re-arms on the fresh call boundary before this raises. """ - reg = container.providers_registry - if reg.is_validated(): + if registry.is_validated(): raise exc # validated => acyclic static graph => genuine self-recursion cycle = DependencyGraph().find_cycle_from(provider, container) if cycle is None: @@ -42,14 +41,13 @@ class Container: """ __slots__ = ( + "_cache_registry", + "_closed", + "_context_registry", "_lock", + "_providers_registry", "_scope_map", - "cache_registry", - "closed", - "context_registry", - "overrides_registry", "parent_container", - "providers_registry", "scope", ) @@ -69,14 +67,18 @@ def __init__( 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 - deeper than ``parent_container``'s. + ``IntEnum``, :class:`~modern_di.exceptions.InvalidChildScopeError` when it is not deeper + than ``parent_container``'s, and + :class:`~modern_di.exceptions.ChildContainerRegistrationError` when ``groups`` is passed + with ``parent_container``. """ if not isinstance(scope, enum.IntEnum): raise exceptions.InvalidScopeTypeError(scope_value=scope) if parent_container is not None and scope <= parent_container.scope: raise exceptions.InvalidChildScopeError(parent_scope=parent_container.scope, child_scope=scope) - self.closed = False + if parent_container is not None and groups: + raise exceptions.ChildContainerRegistrationError(scope=scope) + self._closed = False self.scope = scope self.parent_container = parent_container # Ancestors only, never self: a `scope: self` entry is a reference cycle, so no container @@ -87,26 +89,28 @@ def __init__( if parent_container else {} ) - self.cache_registry = CacheRegistry() - self.context_registry = ContextRegistry(copy.copy(context) if context is not None else {}) - self.providers_registry: ProvidersRegistry - self.overrides_registry: OverridesRegistry + self._cache_registry = CacheRegistry() + self._context_registry = ContextRegistry(copy.copy(context) if context is not None else {}) + self._providers_registry: ProvidersRegistry # Inlined rather than a helper: this runs per child build (benchmark `test_g6_build_child_container`). if parent_container: # SLF001 exempts `self`/`cls` only, so it flags this same-class read; no boundary is crossed. self._lock = parent_container._lock # noqa: SLF001 - self.providers_registry = parent_container.providers_registry - self.overrides_registry = parent_container.overrides_registry + self._providers_registry = parent_container._providers_registry # noqa: SLF001 else: self._lock = threading.RLock() - self.providers_registry = ProvidersRegistry() - self.providers_registry.register(Container, container_provider) - self.overrides_registry = self.providers_registry.overrides + self._providers_registry = ProvidersRegistry() + self._providers_registry.register(Container, container_provider) if groups: all_providers: list[AbstractProvider[typing.Any]] = [] for one_group in groups: all_providers.extend(one_group.get_providers()) - self.providers_registry.add_providers(*all_providers) + self._providers_registry.add_providers(*all_providers) + + @property + def closed(self) -> bool: + """Whether this container is closed; :meth:`open` and re-entering ``with`` reopen it.""" + return self._closed def build_child_container( self, @@ -114,6 +118,13 @@ def build_child_container( scope: enum.IntEnum | None = None, context: dict[type[typing.Any], typing.Any] | None = None, ) -> typing.Self: + """Return a new open child at ``scope``, seeded with ``context``. + + The child shares this container's providers and overrides and owns its own cache and + context. Without ``scope`` it takes the next deeper member of the scope enum. Raises + :class:`~modern_di.exceptions.MaxScopeReachedError` when there is none, and + :class:`~modern_di.exceptions.InvalidChildScopeError` when ``scope`` is not deeper. + """ if scope is None: scope = _next_deeper(self.scope) if scope is None: @@ -122,6 +133,12 @@ def build_child_container( return self.__class__(scope=scope, parent_container=self, context=context) def find_container(self, scope: enum.IntEnum) -> typing.Self: + """Return the container at ``scope``: this one or an ancestor. + + Raises :class:`~modern_di.exceptions.ScopeNotInitializedError` when ``scope`` is deeper + than this container, and :class:`~modern_di.exceptions.ScopeSkippedError` when no + ancestor was built at ``scope``. + """ if scope == self.scope: return self target = self._scope_map.get(scope) @@ -137,16 +154,16 @@ def resolve(self, dependency_type: type[types.T]) -> types.T: Raises :class:`~modern_di.exceptions.ContainerClosedError` when this container, or the ancestor a provider resolves in, is closed. """ - registry = self.providers_registry + registry = self._providers_registry try: + if self._closed: + raise exceptions.ContainerClosedError(container_scope=self.scope) resolver = registry._resolvers_by_type.get(dependency_type) # noqa: SLF001 if resolver is None: resolver = registry.resolver_for_type(dependency_type) - if self.closed: - raise exceptions.ContainerClosedError(container_scope=self.scope) return resolver(self) except RecursionError as exc: - _handle_recursion_error(registry._providers[dependency_type], self, exc) # noqa: SLF001 + _handle_recursion_error(registry._providers[dependency_type], self, registry, exc) # noqa: SLF001 except STEP_ERRORS as exc: provider = registry.find_provider(dependency_type) if provider is not None: @@ -165,16 +182,16 @@ def resolve_provider(self, provider: "AbstractProvider[types.T]") -> types.T: Raises :class:`~modern_di.exceptions.ContainerClosedError` when this container, or the ancestor the provider resolves in, is closed. """ - if self.closed: + if self._closed: raise exceptions.ContainerClosedError(container_scope=self.scope) + registry = self._providers_registry try: - registry = self.providers_registry resolver = registry._resolvers.get(provider.provider_id) # noqa: SLF001 if resolver is None: resolver = registry.resolver_for(provider) return resolver(self) except RecursionError as exc: - _handle_recursion_error(provider, self, exc) + _handle_recursion_error(provider, self, registry, exc) except STEP_ERRORS as exc: exc.prepend_step(*redirect_hops(provider, self)) raise @@ -187,11 +204,11 @@ def validate(self) -> None: validates — construction, :meth:`open`, :meth:`add_providers` and :meth:`resolve` never do. A clean walk is memoized until the registry changes. """ - reg = self.providers_registry + reg = self._providers_registry if reg.is_validated(): return - if errors := collect_errors(self): + if errors := collect_errors(self, reg): raise exceptions.ValidationFailedError(errors=errors) reg.mark_validated() @@ -206,17 +223,37 @@ def add_providers(self, *providers: AbstractProvider[typing.Any]) -> None: """ if self.parent_container is not None: raise exceptions.ChildContainerRegistrationError(scope=self.scope) - self.providers_registry.add_providers(*providers) + self._providers_registry.add_providers(*providers) + + def find_provider(self, dependency_type: type[types.T]) -> AbstractProvider[types.T] | None: + """Return the provider registered for ``dependency_type`` anywhere in the tree, or ``None``. + + A pure lookup: it ignores overrides and the closed state, and never compiles or resolves. + """ + return self._providers_registry.find_provider(dependency_type) async def close_async(self) -> None: - self.closed = True - if self.cache_registry._creation_order: # noqa: SLF001 - await self.cache_registry.close_async() + """Mark this container closed, then run its finalizers, sync and async, newest first. + + Overrides are kept. A resolve from inside a finalizer raises + :class:`~modern_di.exceptions.ContainerClosedError`. Every finalizer runs even when one + raises; the failures come back together as one :class:`~modern_di.exceptions.FinalizerError`. + """ + self._closed = True + if self._cache_registry._creation_order: # noqa: SLF001 + await self._cache_registry.close_async() def close_sync(self) -> None: - self.closed = True - if self.cache_registry._creation_order: # noqa: SLF001 - self.cache_registry.close_sync() + """Mark this container closed, then run its sync finalizers, newest first. + + Overrides are kept. A resolve from inside a finalizer raises + :class:`~modern_di.exceptions.ContainerClosedError`. Every finalizer runs even when one + raises; the failures come back together as one :class:`~modern_di.exceptions.FinalizerError`. + An async finalizer fails here and stays pending for a later :meth:`close_async`. + """ + self._closed = True + if self._cache_registry._creation_order: # noqa: SLF001 + self._cache_registry.close_sync() def override(self, provider: AbstractProvider[types.T], override_object: types.T) -> OverrideHandle[types.T]: """Apply an override immediately, tree-wide. @@ -224,17 +261,22 @@ def override(self, provider: AbstractProvider[types.T], override_object: types.T Use the returned handle as a context manager to restore the prior state. A test-time operation, not coordinated with concurrent resolves on other threads. """ - prior = self.overrides_registry.fetch_override(provider.provider_id) - self.overrides_registry.override(provider.provider_id, override_object) + overrides = self._providers_registry.overrides + prior = overrides.fetch_override(provider.provider_id) + overrides.override(provider.provider_id, override_object) return OverrideHandle( - registry=self.overrides_registry, + registry=overrides, provider_id=provider.provider_id, prior=prior, override_object=override_object, ) def reset_override(self, provider: AbstractProvider[types.T] | None = None) -> None: - self.overrides_registry.reset_override(provider.provider_id if provider else None) + """Drop the override on ``provider``, or every override when ``provider`` is ``None``. + + Applies tree-wide. Resetting a provider that has no override is a no-op. + """ + self._providers_registry.overrides.reset_override(provider.provider_id if provider else None) def set_context(self, context_type: type[types.T], obj: types.T) -> None: """Register a runtime context value on *this* container. @@ -243,11 +285,11 @@ def set_context(self, context_type: type[types.T], obj: types.T) -> None: matches the ``ContextProvider``. A cached provider is built once and is not rebuilt by a later ``set_context``; set the context before its first resolve. """ - self.context_registry.set_context(context_type, obj) + self._context_registry.set_context(context_type, obj) def __repr__(self) -> str: - n_providers = len(self.providers_registry) - n_cached = self.cache_registry.cached_count() + n_providers = len(self._providers_registry) + n_cached = self._cache_registry.cached_count() parent = self.parent_container.scope.name if self.parent_container else None return f"Container(scope={self.scope.name}, parent={parent}, providers={n_providers}, cached={n_cached})" @@ -258,7 +300,7 @@ def open(self) -> None: :class:`~modern_di.exceptions.ContainerClosedError` until this, or re-entering the container with ``with``/``async with``, reopens it. """ - self.closed = False + self._closed = False def __enter__(self) -> typing.Self: self.open() diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index 86e03675..31484a7c 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -16,6 +16,7 @@ if typing.TYPE_CHECKING: from modern_di import Container + from modern_di.registries.providers_registry import ProvidersRegistry class NodeEntered(NamedTuple): @@ -181,10 +182,10 @@ def _enter( stack.append(iter(dependencies.items())) -def collect_errors(container: "Container") -> list[Exception]: - """Walk ``container``'s provider graph once and return every wiring error in walk order.""" +def collect_errors(container: "Container", registry: "ProvidersRegistry") -> list[Exception]: + """Walk the graph rooted at ``registry``'s providers once; return every wiring error in walk order.""" errors: list[Exception] = [] - for event in DependencyGraph().walk(container.providers_registry, container): + for event in DependencyGraph().walk(registry, container): match event: case NodeEntered(provider): errors.extend(provider._iter_validation_issues(container)) # noqa: SLF001 diff --git a/modern_di/exceptions/registration.py b/modern_di/exceptions/registration.py index e48dffbd..96ceaf8b 100644 --- a/modern_di/exceptions/registration.py +++ b/modern_di/exceptions/registration.py @@ -36,7 +36,7 @@ def __init__(self, *, provider_type: type) -> None: class ChildContainerRegistrationError(RegistrationError): - """``add_providers`` was called on a child container; registration is root-only. Inspect ``.scope``.""" + """Providers were registered on a child container, via ``add_providers`` or ``groups=``. Inspect ``.scope``.""" docs_slug = "child-container-registration-error" @@ -45,9 +45,9 @@ class ChildContainerRegistrationError(RegistrationError): def __init__(self, *, scope: enum.IntEnum) -> None: self.scope = scope super().__init__( - f"Container.add_providers can only be called on a root container: the providers " - f"registry is shared tree-wide, so registering on a child container (scope {scope.name}) " - "would mutate every container in the tree. Call add_providers on the root container instead." + f"Providers can only be registered on a root container: the providers registry is shared " + f"tree-wide, so registering on a child container (scope {scope.name}) would mutate every " + "container in the tree. Pass groups= to the root Container or call add_providers on it instead." ) diff --git a/modern_di/providers/alias.py b/modern_di/providers/alias.py index 485a5773..01a7a29d 100644 --- a/modern_di/providers/alias.py +++ b/modern_di/providers/alias.py @@ -28,7 +28,7 @@ def __repr__(self) -> str: return f"Alias(source_type={self._source_type!r}, bound_type={self.bound_type!r}, scope={self.scope!r})" def _find_source(self, container: "Container") -> "AbstractProvider[types.T_co]": - source = container.providers_registry.find_provider(self._source_type) + source = container.find_provider(self._source_type) if source is None: raise exceptions.AliasSourceNotRegisteredError(source_type=self._source_type) return source diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 0461e6cc..391019e7 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -199,10 +199,11 @@ def _can_call_positionally(self, plan: WiringPlan) -> bool: def _get_dependencies(self, container: "Container") -> dict[str, "AbstractProvider[typing.Any]"]: """Return parameter name → dependency provider: a pure registry lookup, no scope or cache touched.""" - return self._wiring_plan(container.providers_registry).edges + return self._wiring_plan(container._providers_registry).edges # noqa: SLF001 def _iter_validation_issues(self, container: "Container") -> typing.Iterable[Exception]: """Yield ArgumentResolutionError for parameters with no provider, no default, no static kwarg.""" - plan = self._wiring_plan(container.providers_registry) + registry = container._providers_registry # noqa: SLF001 + plan = self._wiring_plan(registry) for name, item in plan.unwireable: - yield self._argument_resolution_error(arg_name=name, item=item, registry=container.providers_registry) + yield self._argument_resolution_error(arg_name=name, item=item, registry=registry) diff --git a/modern_di/resolver_compiler.py b/modern_di/resolver_compiler.py index a92a8a69..21765742 100644 --- a/modern_di/resolver_compiler.py +++ b/modern_di/resolver_compiler.py @@ -66,7 +66,7 @@ def resolve(container): target = container._scope_map.get(scope) if target is None: target = _navigate(container, scope, resolution_step) - if target.closed: + if target._closed: raise ContainerClosedError(container_scope=target.scope) try: {build} @@ -127,9 +127,9 @@ def resolve(container): target = container._scope_map.get(scope) if target is None: target = _navigate(container, scope, resolution_step) - if target.closed: + if target._closed: raise ContainerClosedError(container_scope=target.scope) - cache_registry = target.cache_registry + cache_registry = target._cache_registry cache_item = cache_registry._items.get(pid) if cache_item is None: cache_item = cache_registry.fetch_cache_item(provider) @@ -235,9 +235,9 @@ def _compile_unwireable_factory(f: "Factory[typing.Any]", plan: "WiringPlan") -> def resolve(container: "Container") -> typing.Any: target = container if container.scope == scope else _navigate(container, scope, resolution_step) - if target.closed: + if target._closed: raise exceptions.ContainerClosedError(container_scope=target.scope) - error = build_error(arg_name=arg_name, item=item, registry=target.providers_registry) + error = build_error(arg_name=arg_name, item=item, registry=target._providers_registry) error.prepend_step(resolution_step()) raise error @@ -276,9 +276,9 @@ def resolve(container: "Container") -> typing.Any: target = container._scope_map.get(scope) if target is None: target = _navigate(container, scope, resolution_step) - if target.closed: + if target._closed: raise exceptions.ContainerClosedError(container_scope=target.scope) - context = target.context_registry.context + context = target._context_registry.context # Not `.get(key, UNSET)`: that skips a dict subclass's `__contains__`/`__getitem__`. if context_type in context: return context[context_type] diff --git a/tests/providers/test_alias.py b/tests/providers/test_alias.py index b1825d6a..a6dbe7b0 100644 --- a/tests/providers/test_alias.py +++ b/tests/providers/test_alias.py @@ -429,7 +429,7 @@ class X: ... def test_alias_redirect_target_returns_source() -> None: container = Container(groups=[MyGroup]) - source = container.providers_registry.find_provider(PostgresRepository) + source = container.find_provider(PostgresRepository) assert source is not None target = MyGroup.abstract_repo._redirect_target(container) assert target is not None @@ -497,7 +497,7 @@ class G(Group): container = Container(scope=_BelowApp.ROOT, groups=[G]) container.validate() - assert container.providers_registry.is_validated() is True + assert container._providers_registry.is_validated() is True def test_alias_on_a_closed_container_raises() -> None: diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 54551d1c..2656e1f6 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -90,7 +90,7 @@ def test_func_with_broken_annotation() -> None: app_container = Container() app_container.open() - app_container.providers_registry.add_providers(factory) + app_container._providers_registry.add_providers(factory) with pytest.raises(ArgumentResolutionError, match="has no usable type annotation"): app_container.resolve_provider(factory) @@ -217,7 +217,7 @@ def second_creator(first_factory: str) -> str: app_container = Container() app_container.open() - app_container.providers_registry.add_providers(first_factory, second_factory) + app_container._providers_registry.add_providers(first_factory, second_factory) assert app_container.resolve_provider(second_factory) == "one two" @@ -233,7 +233,7 @@ def make(x: int | SelfRef = 1) -> SelfRef: factory = providers.Factory(creator=make) app_container = Container() app_container.open() - app_container.providers_registry.add_providers(factory) + app_container._providers_registry.add_providers(factory) result = app_container.resolve(SelfRef) assert isinstance(result, SelfRef) @@ -251,7 +251,7 @@ def make(nested: SelfRefByType = SelfRefByType()) -> SelfRefByType: # noqa: B00 factory = providers.Factory(creator=make) app_container = Container() app_container.open() - app_container.providers_registry.add_providers(factory) + app_container._providers_registry.add_providers(factory) # `nested` is typed as the factory's own bound type: it must not wire to itself, # and with no other provider it falls through to the creator default. @@ -311,7 +311,7 @@ def make(**kwargs: object) -> dict[str, object]: factory = providers.Factory(creator=make, kwargs={"anything": 1, "extra": 2}) container = Container() container.open() - container.providers_registry.add_providers(factory) + container._providers_registry.add_providers(factory) result = container.resolve(dict) assert result == {"anything": 1, "extra": 2} @@ -326,7 +326,7 @@ def make(x: SomeUnregisteredType = unittest.mock.ANY) -> str: factory = providers.Factory(creator=make) container = Container() container.open() - container.providers_registry.add_providers(factory) + container._providers_registry.add_providers(factory) result = container.resolve(str) assert result == repr(unittest.mock.ANY) @@ -444,7 +444,7 @@ def test_creator_raising_mid_creation_caches_nothing_and_retry_succeeds() -> Non with pytest.raises(RuntimeError, match="boom"): container.resolve(_FlakySvc) expected_cached_after_failure = 1 # only the dep cached; failed svc not cached - assert container.cache_registry.cached_count() == expected_cached_after_failure + assert container._cache_registry.cached_count() == expected_cached_after_failure retried = container.resolve(_FlakySvc) assert isinstance(retried, _FlakySvc) container.close_sync() @@ -503,8 +503,8 @@ class WithoutLeaf(Group): with_leaf.open() without_leaf.open() - assert with_leaf.providers_registry.find_provider(OptionalDepSvc) is svc_factory - assert without_leaf.providers_registry.find_provider(OptionalDepSvc) is svc_factory + assert with_leaf.find_provider(OptionalDepSvc) is svc_factory + assert without_leaf.find_provider(OptionalDepSvc) is svc_factory assert isinstance(with_leaf.resolve(OptionalDepSvc).dep, SimpleCreator) assert without_leaf.resolve(OptionalDepSvc).dep is None @@ -532,7 +532,7 @@ def test_optional_param_injects_none_when_no_provider() -> None: factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(_NeedsOptionalSingle, factory) + container._providers_registry.register(_NeedsOptionalSingle, factory) obj = container.resolve(_NeedsOptionalSingle) assert obj.dep is None @@ -542,8 +542,8 @@ def test_optional_param_uses_provider_when_present() -> None: factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(_OptionalDep, dep_factory) - container.providers_registry.register(_NeedsOptionalSingle, factory) + container._providers_registry.register(_OptionalDep, dep_factory) + container._providers_registry.register(_NeedsOptionalSingle, factory) obj = container.resolve(_NeedsOptionalSingle) assert isinstance(obj.dep, _OptionalDep) @@ -552,7 +552,7 @@ def test_optional_multi_member_union_injects_none_when_no_provider() -> None: factory: providers.Factory[_NeedsOptionalUnion] = providers.Factory(creator=_NeedsOptionalUnion, scope=Scope.APP) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(_NeedsOptionalUnion, factory) + container._providers_registry.register(_NeedsOptionalUnion, factory) obj = container.resolve(_NeedsOptionalUnion) assert obj.dep is None @@ -560,7 +560,7 @@ def test_optional_multi_member_union_injects_none_when_no_provider() -> None: def test_validate_does_not_flag_optional_param_without_provider() -> None: factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) - container.providers_registry.register(_NeedsOptionalSingle, factory) + container._providers_registry.register(_NeedsOptionalSingle, factory) container.validate() # must not raise @@ -582,17 +582,17 @@ def test_optional_param_backed_by_unset_context_provider_raises() -> None: factory: providers.Factory[_NeedsOptionalCtx] = providers.Factory(creator=_NeedsOptionalCtx, scope=Scope.APP) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(_OptionalCtx, ctx_provider) - container.providers_registry.register(_NeedsOptionalCtx, factory) + container._providers_registry.register(_OptionalCtx, ctx_provider) + container._providers_registry.register(_NeedsOptionalCtx, factory) with pytest.raises(exceptions.ContextValueNotSetError) as exc: container.resolve(_NeedsOptionalCtx) assert exc.value.parameter_name == "ctx" defaulted = Container(scope=Scope.APP) - defaulted.providers_registry.register( + defaulted._providers_registry.register( _OptionalCtx, providers.ContextProvider(scope=Scope.APP, context_type=_OptionalCtx, default=None) ) - defaulted.providers_registry.register(_NeedsOptionalCtx, factory) + defaulted._providers_registry.register(_NeedsOptionalCtx, factory) assert defaulted.resolve(_NeedsOptionalCtx).ctx is None @@ -609,7 +609,7 @@ def test_skip_creator_parsing_missing_args_raises_di_error() -> None: ) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(int, factory) + container._providers_registry.register(int, factory) with pytest.raises(exceptions.CreatorCallError) as exc_info: container.resolve(int) assert "_needs_two_args" in str(exc_info.value) @@ -627,7 +627,7 @@ def test_skip_creator_parsing_missing_args_cached_raises_di_error() -> None: ) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(int, factory) + container._providers_registry.register(int, factory) with pytest.raises(exceptions.CreatorCallError) as exc_info: container.resolve(int) assert "_needs_two_args" in str(exc_info.value) @@ -647,7 +647,7 @@ def test_internal_typeerror_from_creator_body_is_not_wrapped() -> None: ) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(_InternalTypeErrorService, factory) + container._providers_registry.register(_InternalTypeErrorService, factory) with pytest.raises(TypeError) as exc_info: container.resolve(_InternalTypeErrorService) assert not isinstance(exc_info.value, exceptions.CreatorCallError) @@ -676,7 +676,7 @@ def test_repeated_failing_resolve_breadcrumb_does_not_compound() -> None: factory: providers.Factory[_NeedsUnregistered] = providers.Factory(creator=_NeedsUnregistered, scope=Scope.APP) container = Container(scope=Scope.APP) # exercise resolve-time breadcrumb, not validation container.open() - container.providers_registry.register(_NeedsUnregistered, factory) + container._providers_registry.register(_NeedsUnregistered, factory) def _grab() -> str: try: @@ -714,8 +714,8 @@ def __init__(self, leaf: _Leaf2) -> None: parent2: providers.Factory[_Parent2] = providers.Factory(creator=_Parent2, scope=Scope.APP) c2 = Container(scope=Scope.APP) # exercise resolve-time breadcrumb, not validation c2.open() - c2.providers_registry.register(_Leaf2, leaf2) - c2.providers_registry.register(_Parent2, parent2) + c2._providers_registry.register(_Leaf2, leaf2) + c2._providers_registry.register(_Parent2, parent2) # Resolve parent — propagates through leaf → parent step prepended with contextlib.suppress(exceptions.ResolutionError): @@ -925,7 +925,7 @@ def __init__(self, hook: None = None) -> None: factory = providers.Factory(scope=Scope.APP, creator=Svc) container = Container() container.open() - container.providers_registry.add_providers(factory) + container._providers_registry.add_providers(factory) result = container.resolve(Svc) assert result.hook is None @@ -941,7 +941,7 @@ def __init__(self, hook: None) -> None: factory = providers.Factory(scope=Scope.APP, creator=Svc) container = Container() container.open() - container.providers_registry.add_providers(factory) + container._providers_registry.add_providers(factory) result = container.resolve(Svc) assert result.hook is None diff --git a/tests/providers/test_singleton.py b/tests/providers/test_singleton.py index 35dea774..0eafda25 100644 --- a/tests/providers/test_singleton.py +++ b/tests/providers/test_singleton.py @@ -144,7 +144,7 @@ async def test_request_singleton() -> None: assert instance3 is instance4 assert instance1 is not instance3 - cache_item = request_container.cache_registry.fetch_cache_item(MyGroup.request_singleton) + cache_item = request_container._cache_registry.fetch_cache_item(MyGroup.request_singleton) with pytest.raises(FinalizerError) as exc_info: request_container.close_sync() @@ -411,7 +411,7 @@ class NoneGroup(Group): app_container.resolve_provider(NoneGroup.none_resource) assert call_count == 1 # cached after first call, not re-created - assert app_container.cache_registry.cached_count() == 1 + assert app_container._cache_registry.cached_count() == 1 app_container.close_sync() assert cleaned_up == [None] @@ -642,7 +642,7 @@ class FinGroup(Group): assert len(errors) == 1 assert _Built.count == 1 - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 assert container.closed is True @@ -669,7 +669,7 @@ class FinGroup(Group): assert len(errors) == 1 assert _Built.count == 1 - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 assert container.closed is True @@ -694,7 +694,7 @@ class FailGroup(Group): container.close_sync() assert [type(e) for e in exc.value.exceptions] == [ValueError] - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 container.open() assert container.resolve(_First) is not stale @@ -710,7 +710,7 @@ class FailGroup(Group): await container.close_async() assert [type(e) for e in exc.value.exceptions] == [ValueError] - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 container.open() assert container.resolve(_First) is not stale @@ -747,4 +747,4 @@ class SlowGroup(Group): assert events == ["second", "first"] assert slow_calls == [0, 1] - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 diff --git a/tests/registries/test_providers_registry.py b/tests/registries/test_providers_registry.py index 7110b332..dcd6a7fb 100644 --- a/tests/registries/test_providers_registry.py +++ b/tests/registries/test_providers_registry.py @@ -259,7 +259,7 @@ def hold_open( # _RaceDep is registered, so a resolve must now inject it -- from a fresh resolver, because the # one compiled against the old registry must not have survived the invalidation. - assert container.providers_registry.find_provider(_RaceDep) is not None + assert container.find_provider(_RaceDep) is not None assert isinstance(container.resolve_provider(svc).dep, _RaceDep) @@ -273,7 +273,7 @@ class G(Group): s = svc container = Container(scope=Scope.APP, groups=[G]) - registry = container.providers_registry + registry = container._providers_registry built = threading.Event() may_publish = threading.Event() real_build = pr_mod.WiringPlan.build diff --git a/tests/test_container.py b/tests/test_container.py index 74e80bb9..f0dd3fd9 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -268,7 +268,7 @@ def __init__(self, inner: object = None) -> None: ... outer = providers.Factory(scope=Scope.APP, creator=Outer, kwargs={"inner": inner}) container = Container() - container.providers_registry.add_providers(inner, outer) + container._providers_registry.add_providers(inner, outer) with pytest.raises(ValidationFailedError) as exc: container.validate() @@ -345,7 +345,7 @@ class G(Group): svc = providers.Factory(creator=_NeedsMissing) container = Container(scope=Scope.APP, groups=[G]) - errors = collect_errors(container) + errors = collect_errors(container, container._providers_registry) # Root order is registration order (a, b, svc): the cycle closes while walking from root # `a`, so it is appended before `svc`'s missing dependency is reached. @@ -470,6 +470,21 @@ def test_resolve_provider_on_closed_container_raises() -> None: assert container.closed is True +def test_resolve_unregistered_type_on_closed_container_raises_closed() -> None: + container = Container(scope=Scope.APP) + container.close_sync() + with pytest.raises(ContainerClosedError) as exc: + container.resolve(_PersistentBroker) + assert exc.value.container_scope is Scope.APP + + +def test_resolve_provider_unregistered_on_closed_container_raises_closed() -> None: + container = Container(scope=Scope.APP) + container.close_sync() + with pytest.raises(ContainerClosedError): + container.resolve_provider(_AppBrokerGroup.broker) + + def test_reenter_reopens_closed_container() -> None: container = Container(scope=Scope.APP) container.close_sync() @@ -501,7 +516,7 @@ def test_resolving_through_closed_parent_via_open_child_raises() -> None: child.resolve(_PersistentBroker) assert exc.value.container_scope is Scope.APP assert app.closed is True - assert app.cache_registry.cached_count() == 0 + assert app._cache_registry.cached_count() == 0 async def test_async_context_manager_reopens() -> None: @@ -535,7 +550,7 @@ class G(Group): with pytest.raises(ContainerClosedError): container.resolve(str) assert calls == [] - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 def test_explicit_open_after_close_does_not_warn() -> None: @@ -725,6 +740,37 @@ def test_add_providers_on_child_container_raises() -> None: assert exc.value.scope is Scope.REQUEST +def test_child_constructor_with_groups_raises() -> None: + root = Container(scope=Scope.APP) + + with pytest.raises(ChildContainerRegistrationError, match="groups= to the root") as exc: + Container(scope=Scope.REQUEST, parent_container=root, groups=[_AppBrokerGroup]) + assert exc.value.scope is Scope.REQUEST + assert root.find_provider(_PersistentBroker) is None + + +def test_find_provider_returns_registered_provider_or_none() -> None: + root = Container(scope=Scope.APP, groups=[_AppBrokerGroup]) + child = root.build_child_container(scope=Scope.REQUEST) + + assert root.find_provider(_PersistentBroker) is _AppBrokerGroup.broker + assert child.find_provider(_PersistentBroker) is _AppBrokerGroup.broker + assert root.find_provider(str) is None + + +def test_closed_is_read_only() -> None: + container = Container(scope=Scope.APP) + with pytest.raises(AttributeError): + container.closed = True # ty: ignore[invalid-assignment] + assert container.closed is False + + +def test_registries_are_not_public() -> None: + container = Container(scope=Scope.APP) + for name in ("providers_registry", "cache_registry", "context_registry", "overrides_registry"): + assert not hasattr(container, name) + + def test_resolve_dependency_with_provider_returns_same_instance_as_resolve_provider() -> None: class G(Group): cached = providers.Factory(creator=lambda: "value", bound_type=str, cache=True) @@ -1041,7 +1087,7 @@ class Broken: container.add_providers(broken) # registers quietly: no raise, no rollback - assert container.providers_registry.find_provider(Broken) is broken + assert container.find_provider(Broken) is broken with pytest.raises(ValidationFailedError): container.validate() diff --git a/tests/test_dependency_graph.py b/tests/test_dependency_graph.py index 34595fc9..8b68ac3f 100644 --- a/tests/test_dependency_graph.py +++ b/tests/test_dependency_graph.py @@ -205,7 +205,7 @@ def test_walk_emits_cycle_closed_through_kwargs_overlay() -> None: b = Factory(scope=Scope.APP, creator=KwCycB, kwargs={"a": a}) # kwargs edge B -> A c = Container(scope=Scope.APP) - c.providers_registry.add_providers(a, b) + c._providers_registry.add_providers(a, b) events = list(DependencyGraph().walk([a], c)) cycles = [e for e in events if isinstance(e, Cycle)] diff --git a/tests/test_free_threading.py b/tests/test_free_threading.py index 2979206a..8e37c384 100644 --- a/tests/test_free_threading.py +++ b/tests/test_free_threading.py @@ -96,4 +96,4 @@ def worker() -> None: assert len(raised) == n assert container.closed is True - assert container.cache_registry.cached_count() == 0 + assert container._cache_registry.cached_count() == 0 diff --git a/tests/test_group.py b/tests/test_group.py index f9b6788e..24b9cd82 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -107,10 +107,9 @@ def test_duplicate_type_across_two_groups_raises() -> None: def test_failed_group_registration_does_not_pollute_shared_registry() -> None: app = Container(scope=Scope.APP, groups=[_GroupOne]) - child_scope = Scope.SESSION with pytest.raises(DuplicateProviderTypeError): - Container(scope=child_scope, parent_container=app, groups=[_GroupTwo]) - assert app.providers_registry.find_provider(_ExtraSvc) is None + app.add_providers(*_GroupTwo.get_providers()) + assert app.find_provider(_ExtraSvc) is None def test_get_named_providers_maps_each_provider_to_its_attribute_name() -> None: diff --git a/tests/test_resolver_compiler.py b/tests/test_resolver_compiler.py index 041addcc..f5becccc 100644 --- a/tests/test_resolver_compiler.py +++ b/tests/test_resolver_compiler.py @@ -174,7 +174,7 @@ class G(Group): container = Container(groups=[G]) container.open() - plan = _plan(container.providers_registry, G.ordered) + plan = _plan(container._providers_registry, G.ordered) assert G.ordered._can_call_positionally(plan) # self-guard: positional path selected result = container.resolve(_Ordered) @@ -201,7 +201,7 @@ def test_positional_path_binds_args_in_signature_order_at_every_arity(arity: int container = Container(scope=Scope.APP, groups=[group]) container.open() - plan = _plan(container.providers_registry, members["bag"]) + plan = _plan(container._providers_registry, members["bag"]) assert members["bag"]._can_call_positionally(plan) # self-guard: positional path selected bag = container.resolve_provider(members["bag"]) @@ -268,7 +268,7 @@ def test_arity_rung_navigates_to_its_own_scope(arity: int) -> None: def test_arity_rung_raises_for_a_closed_target(arity: int) -> None: # The closed target must be an ANCESTOR, not the container the call enters on: the entry # `resolve_provider` checks itself first, so only a cross-scope hop reaches the resolver's - # own `if target.closed` guard. + # own `if target._closed` guard. group = _arity_group(arity) app = Container(scope=Scope.APP, groups=[group]) app.open() @@ -661,7 +661,7 @@ class _G(Group): container.override(_G.iface, sentinel) assert container.resolve(_Iface) is sentinel - assert list(container.providers_registry._resolvers) == [_G.iface.provider_id] + assert list(container._providers_registry._resolvers) == [_G.iface.provider_id] def test_no_compiled_resolver_closes_over_its_registry() -> None: @@ -681,7 +681,7 @@ class _G(Group): container = Container(scope=Scope.APP, groups=[_G]) container.resolve(_Iface) - registry = container.providers_registry + registry = container._providers_registry capturing = [ fn.__qualname__ @@ -744,9 +744,9 @@ class _G(Group): leaf = providers.Factory(creator=_L0, scope=Scope.APP) container = Container(scope=Scope.APP, groups=[_G]) - left = typing.cast("_pytypes.FunctionType", container.providers_registry.resolver_for(_G.left)) - right = typing.cast("_pytypes.FunctionType", container.providers_registry.resolver_for(_G.right)) - leaf = typing.cast("_pytypes.FunctionType", container.providers_registry.resolver_for(_G.leaf)) + left = typing.cast("_pytypes.FunctionType", container._providers_registry.resolver_for(_G.left)) + right = typing.cast("_pytypes.FunctionType", container._providers_registry.resolver_for(_G.right)) + leaf = typing.cast("_pytypes.FunctionType", container._providers_registry.resolver_for(_G.leaf)) assert left.__code__ is right.__code__ assert leaf.__code__ is not left.__code__ @@ -811,7 +811,7 @@ class _G(Group): container = Container(scope=Scope.APP, groups=[_G]) container.resolve_provider(_G.node) - registry = container.providers_registry + registry = container._providers_registry assert registry._resolvers sentinel = _L0() @@ -841,7 +841,7 @@ class G(Group): cached = providers.Factory(creator=_A, scope=Scope.APP, cache=True) container = Container(scope=Scope.APP, groups=[G]) - resolver = container.providers_registry.resolver_for(G.cached) + resolver = container._providers_registry.resolver_for(G.cached) code = typing.cast("_pytypes.FunctionType", resolver).__code__ assert code.co_cellvars == (), ( diff --git a/tests/test_types_parser.py b/tests/test_types_parser.py index b2ca6993..b92bc8df 100644 --- a/tests/test_types_parser.py +++ b/tests/test_types_parser.py @@ -270,7 +270,7 @@ def test_parameterized_generic_param_supplied_via_kwargs_is_allowed() -> None: provider = providers.Factory(creator=_generic_param_creator, kwargs={"x": sentinel}) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(str, provider) + container._providers_registry.register(str, provider) assert container.resolve(str) == str(sentinel) @@ -283,7 +283,7 @@ def test_parameterized_generic_param_with_default_is_allowed() -> None: provider = providers.Factory(creator=_generic_param_with_default) container = Container(scope=Scope.APP) container.open() - container.providers_registry.register(str, provider) + container._providers_registry.register(str, provider) assert container.resolve(str) == str(())