From 98c91ff671581725c849c38343ebe0c13fc75cf3 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jul 2026 00:38:12 +0200 Subject: [PATCH 01/20] fix: resolve postponed injection annotations --- tests/test_future_annotations.py | 68 ++++++++++++++++++++++++++++++++ that_depends/injection.py | 21 +++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/test_future_annotations.py diff --git a/tests/test_future_annotations.py b/tests/test_future_annotations.py new file mode 100644 index 00000000..4cb53564 --- /dev/null +++ b/tests/test_future_annotations.py @@ -0,0 +1,68 @@ +from __future__ import annotations +import typing + +import pytest + +from that_depends import BaseContainer, Provide, providers + + +if typing.TYPE_CHECKING: + + class Unresolvable: + pass + + +class Service: + pass + + +class FutureAnnotationsContainer(BaseContainer): + service = providers.Object(Service()).bind(Service) + + +@FutureAnnotationsContainer.inject +def inject_service_sync(service: Service = Provide()) -> Service: + return service + + +@FutureAnnotationsContainer.inject +async def inject_service_async(service: Service = Provide()) -> Service: + return service + + +def test_type_based_injection_resolves_postponed_annotations_sync() -> None: + assert isinstance(inject_service_sync(), Service) + + +async def test_type_based_injection_resolves_postponed_annotations_async() -> None: + assert isinstance(await inject_service_async(), Service) + + +def test_type_based_injection_rejects_unresolvable_local_annotation() -> None: + class LocalService: + pass + + with pytest.raises(TypeError, match="Cannot resolve annotations for injected function"): + + @FutureAnnotationsContainer.inject + def target(service: LocalService = Provide()) -> LocalService: # pragma: no cover + return service + + +def test_type_based_injection_rejects_non_concrete_annotation() -> None: + with pytest.raises(TypeError, match="Type-based injection for 'service' requires a concrete runtime type"): + + @FutureAnnotationsContainer.inject + def target(service: list[str] = Provide()) -> list[str]: # pragma: no cover + return service + + +def test_direct_provider_injection_does_not_resolve_unrelated_annotations() -> None: + provider = providers.Object(1) + + @FutureAnnotationsContainer.inject + def target(value: int = Provide[provider], unrelated: Unresolvable | None = None) -> int: + _ = unrelated + return value + + assert target() == 1 diff --git a/that_depends/injection.py b/that_depends/injection.py index bd020b73..4d8d076e 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -48,6 +48,7 @@ class _TypedInjectionParameter(typing.NamedTuple): class _InjectionPlan(typing.NamedTuple): + signature: inspect.Signature direct_parameters: tuple[_DirectInjectionParameter, ...] string_parameters: tuple[_StringInjectionParameter, ...] typed_parameters: tuple[_TypedInjectionParameter, ...] @@ -98,10 +99,21 @@ def close(self) -> None: @functools.cache def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionPlan: + signature = inspect.signature(func) + parameters = tuple(signature.parameters.items()) direct_parameters: list[_DirectInjectionParameter] = [] string_parameters: list[_StringInjectionParameter] = [] typed_parameters: list[_TypedInjectionParameter] = [] - for index, (field_name, param) in enumerate(inspect.signature(func).parameters.items()): + if any(isinstance(param.default, _Provide) for _, param in parameters): + try: + resolved_hints = typing.get_type_hints(func) + except (NameError, TypeError) as exc: + msg = f"Cannot resolve annotations for injected function {func.__qualname__}" + raise TypeError(msg) from exc + else: + resolved_hints = {} + + for index, (field_name, param) in enumerate(parameters): default = param.default if isinstance(default, StringProviderDefinition): string_parameters.append(_StringInjectionParameter(index, field_name, default)) @@ -115,14 +127,19 @@ def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionP ) ) elif isinstance(default, _Provide): + annotation = resolved_hints.get(field_name, param.annotation) + if annotation is inspect.Parameter.empty or annotation is typing.Any or not isinstance(annotation, type): + msg = f"Type-based injection for {field_name!r} requires a concrete runtime type" + raise TypeError(msg) typed_parameters.append( _TypedInjectionParameter( index, field_name, - typing.cast(type[typing.Any], param.annotation), + annotation, ) ) return _InjectionPlan( + signature=signature, direct_parameters=tuple(direct_parameters), string_parameters=tuple(string_parameters), typed_parameters=tuple(typed_parameters), From fa0f380262cf12e3c816d5279d76aec24978d2a4 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jul 2026 00:39:30 +0200 Subject: [PATCH 02/20] fix: bind injected arguments by signature --- tests/test_injection.py | 29 ++++++++++++++++++++++++++--- that_depends/injection.py | 38 ++++++++++++++++---------------------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/tests/test_injection.py b/tests/test_injection.py index ac4f3d5b..3f0e02ac 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -99,7 +99,7 @@ def _injected(value: providers.Object[int] = provider) -> providers.Object[int]: assert _injected(provider) is provider assert plan.direct_parameters == ( - _DirectInjectionParameter(0, "value", provider, provider._get_scope_context_init_order()), + _DirectInjectionParameter("value", provider, provider._get_scope_context_init_order()), ) @@ -110,7 +110,7 @@ def _injected(value: float = Provide()) -> float: plan = _build_injection_plan(_injected) assert _injected(1.0) == 1.0 - assert plan.typed_parameters == (_TypedInjectionParameter(0, "value", float),) + assert plan.typed_parameters == (_TypedInjectionParameter("value", float),) def test_build_injection_plan_stores_string_provider_separately() -> None: @@ -121,11 +121,34 @@ def _injected(value: int = Provide["Container.provider"]) -> int: assert _injected(1) == 1 assert len(plan.string_parameters) == 1 - assert plan.string_parameters[0].argument_index == 0 assert plan.string_parameters[0].field_name == "value" assert plan.string_parameters[0].definition._definition == "Container.provider" +def test_injection_handles_keyword_only_parameter_after_varargs() -> None: + injected_value = 42 + override_value = 7 + provider = providers.Object(injected_value) + + @inject + def target(*values: str, dependency: int = Provide[provider]) -> int: + _ = values + return dependency + + assert target("one", "two") == injected_value + assert target("one", dependency=override_value) == override_value + + +def test_injection_rejects_positional_only_parameter() -> None: + provider = providers.Object(42) + + with pytest.raises(TypeError, match="Injected parameter 'dependency' cannot be positional-only"): + + @inject + def target(dependency: int = Provide[provider], /) -> int: # pragma: no cover + return dependency + + async def test_empty_injection() -> None: @inject async def inner(_: int) -> None: diff --git a/that_depends/injection.py b/that_depends/injection.py index 4d8d076e..bb8c1965 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -29,20 +29,17 @@ class ContextProviderError(Exception): class _DirectInjectionParameter(typing.NamedTuple): - argument_index: int field_name: str provider: AbstractProvider[typing.Any] scope_context_init_order: tuple[AbstractProvider[typing.Any], ...] class _StringInjectionParameter(typing.NamedTuple): - argument_index: int field_name: str definition: "StringProviderDefinition" class _TypedInjectionParameter(typing.NamedTuple): - argument_index: int field_name: str annotation: type[typing.Any] @@ -113,14 +110,19 @@ def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionP else: resolved_hints = {} - for index, (field_name, param) in enumerate(parameters): + for field_name, param in parameters: default = param.default + if ( + isinstance(default, (StringProviderDefinition, AbstractProvider, _Provide)) + and param.kind is inspect.Parameter.POSITIONAL_ONLY + ): + msg = f"Injected parameter {field_name!r} cannot be positional-only" + raise TypeError(msg) if isinstance(default, StringProviderDefinition): - string_parameters.append(_StringInjectionParameter(index, field_name, default)) + string_parameters.append(_StringInjectionParameter(field_name, default)) elif isinstance(default, AbstractProvider): direct_parameters.append( _DirectInjectionParameter( - index, field_name, default, default._get_scope_context_init_order(), # noqa: SLF001 @@ -133,7 +135,6 @@ def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionP raise TypeError(msg) typed_parameters.append( _TypedInjectionParameter( - index, field_name, annotation, ) @@ -284,8 +285,9 @@ async def _resolve_arguments_async( return False, kwargs context_providers: set[AbstractProvider[typing.Any]] = set() + provided_names = plan.signature.bind_partial(*args, **kwargs).arguments for direct_parameter in plan.direct_parameters: - if _is_argument_provided(direct_parameter.argument_index, direct_parameter.field_name, args, kwargs): + if direct_parameter.field_name in provided_names: continue if direct_parameter.scope_context_init_order: @@ -298,7 +300,7 @@ async def _resolve_arguments_async( kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() for string_parameter in plan.string_parameters: - if _is_argument_provided(string_parameter.argument_index, string_parameter.field_name, args, kwargs): + if string_parameter.field_name in provided_names: continue kwargs[string_parameter.field_name] = await _resolve_provider_with_scope_async( @@ -309,7 +311,7 @@ async def _resolve_arguments_async( ) for typed_parameter in plan.typed_parameters: - if _is_argument_provided(typed_parameter.argument_index, typed_parameter.field_name, args, kwargs): + if typed_parameter.field_name in provided_names: continue provider = _resolve_typed_provider(typed_parameter.annotation, container) @@ -334,8 +336,9 @@ def _resolve_arguments_sync( return False, kwargs context_providers: set[AbstractProvider[typing.Any]] = set() + provided_names = plan.signature.bind_partial(*args, **kwargs).arguments for direct_parameter in plan.direct_parameters: - if _is_argument_provided(direct_parameter.argument_index, direct_parameter.field_name, args, kwargs): + if direct_parameter.field_name in provided_names: continue if direct_parameter.scope_context_init_order: @@ -348,7 +351,7 @@ def _resolve_arguments_sync( kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() for string_parameter in plan.string_parameters: - if _is_argument_provided(string_parameter.argument_index, string_parameter.field_name, args, kwargs): + if string_parameter.field_name in provided_names: continue kwargs[string_parameter.field_name] = _resolve_provider_with_scope_sync( @@ -359,7 +362,7 @@ def _resolve_arguments_sync( ) for typed_parameter in plan.typed_parameters: - if _is_argument_provided(typed_parameter.argument_index, typed_parameter.field_name, args, kwargs): + if typed_parameter.field_name in provided_names: continue provider = _resolve_typed_provider(typed_parameter.annotation, container) @@ -377,15 +380,6 @@ def _plan_has_injected_parameters(plan: _InjectionPlan) -> bool: return bool(plan.direct_parameters or plan.string_parameters or plan.typed_parameters) -def _is_argument_provided( - argument_index: int, - field_name: str, - args: tuple[typing.Any, ...], - kwargs: dict[str, typing.Any], -) -> bool: - return argument_index < len(args) or field_name in kwargs - - def _resolve_typed_provider( annotation: type[typing.Any], container: BaseContainerMeta | None, From e0fdd6d31022394fd7a254069f5db22dac072aad Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jul 2026 11:39:38 +0200 Subject: [PATCH 03/20] feat: selector context registration. --- tests/providers/test_selector.py | 21 ++++ tests/test_injection.py | 185 ++++++++++++++++++++++++++++- that_depends/injection.py | 144 +++++++++++----------- that_depends/providers/selector.py | 58 +++++++-- 4 files changed, 325 insertions(+), 83 deletions(-) diff --git a/tests/providers/test_selector.py b/tests/providers/test_selector.py index fe7b784c..865d05a2 100644 --- a/tests/providers/test_selector.py +++ b/tests/providers/test_selector.py @@ -140,6 +140,27 @@ async def test_selector_with_provider_selector_async() -> None: assert (await StringProviderSelectorContainer.selector.resolve()) == "Provider 1" +def test_selector_registers_only_its_key_provider() -> None: + def _selector_key() -> typing.Iterator[str]: # pragma: no cover + yield "selected" + + selector_key = providers.ContextResource(_selector_key) + selected = providers.Object("value") + selector = providers.Selector(selector_key, selected=selected) + + selector._register_arguments() + selector._register_arguments() + + assert selector in selector_key._children + assert selected not in selector._parents + assert selector._get_scope_context_init_order() == (selector_key,) + assert selector._get_scope_context_init_order() == (selector_key,) + + selector._deregister_arguments() + + assert selector not in selector_key._children + + class InvalidSelectorContainer(BaseContainer): selector = providers.Selector( None, # type: ignore[arg-type] diff --git a/tests/test_injection.py b/tests/test_injection.py index 3f0e02ac..4b324392 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -98,9 +98,7 @@ def _injected(value: providers.Object[int] = provider) -> providers.Object[int]: plan = _build_injection_plan(_injected) assert _injected(provider) is provider - assert plan.direct_parameters == ( - _DirectInjectionParameter("value", provider, provider._get_scope_context_init_order()), - ) + assert plan.direct_parameters == (_DirectInjectionParameter("value", provider),) def test_build_injection_plan_stores_annotation_for_type_based_injection() -> None: @@ -756,6 +754,187 @@ def injected(repo: DocumentRepository = Provide[_Container._factory_provider]) - assert isinstance(injected(), float) +def test_injection_enters_only_selected_selector_context_sync() -> None: + events: list[str] = [] + + def _sync_creator() -> typing.Iterator[str]: + events.append("sync enter") + try: + yield "sync" + finally: + events.append("sync exit") + + async def _async_creator() -> typing.AsyncIterator[str]: # pragma: no cover + events.append("async enter") + try: + yield "async" + finally: + events.append("async exit") + + selector = providers.Selector( + lambda: "sync", + sync=providers.ContextResource(_sync_creator).with_config(scope=ContextScopes.INJECT), + async_=providers.ContextResource(_async_creator).with_config(scope=ContextScopes.INJECT), + ) + + @inject + def target(value: str = Provide[selector]) -> str: + return value + + assert target() == "sync" + assert events == ["sync enter", "sync exit"] + + +async def test_injection_enters_only_selected_selector_context_async() -> None: + events: list[str] = [] + + def _sync_creator() -> typing.Iterator[str]: # pragma: no cover + events.append("sync enter") + try: + yield "sync" + finally: + events.append("sync exit") + + async def _async_creator() -> typing.AsyncIterator[str]: + events.append("async enter") + try: + yield "async" + finally: + events.append("async exit") + + selector = providers.Selector( + lambda: "async_", + sync=providers.ContextResource(_sync_creator).with_config(scope=ContextScopes.INJECT), + async_=providers.ContextResource(_async_creator).with_config(scope=ContextScopes.INJECT), + ) + + @inject + async def target(value: str = Provide[selector]) -> str: + return value + + assert await target() == "async" + assert events == ["async enter", "async exit"] + + +def test_injection_pins_nested_selector_branch_sync() -> None: + selected_keys: list[str] = [] + events: list[str] = [] + + def _select_key() -> str: + key = "first" if not selected_keys else "second" + selected_keys.append(key) + return key + + def _creator(value: str) -> typing.Iterator[str]: + events.append(f"{value} enter") + try: + yield value + finally: + events.append(f"{value} exit") + + selector = providers.Selector( + _select_key, + first=providers.ContextResource(_creator, "first").with_config(scope=ContextScopes.INJECT), + second=providers.ContextResource(_creator, "second").with_config(scope=ContextScopes.INJECT), + ) + factory = providers.Factory(lambda value: (value, selector.resolve_sync()), selector.cast) + + @inject + def target(value: tuple[str, str] = Provide[factory]) -> tuple[str, str]: + return value + + assert target() == ("first", "first") + assert selected_keys == ["first"] + assert events == ["first enter", "first exit"] + + +async def test_injection_pins_nested_selector_branch_async() -> None: + selected_keys: list[str] = [] + events: list[str] = [] + + def _select_key() -> str: + key = "first" if not selected_keys else "second" + selected_keys.append(key) + return key + + async def _creator(value: str) -> typing.AsyncIterator[str]: + events.append(f"{value} enter") + try: + yield value + finally: + events.append(f"{value} exit") + + selector = providers.Selector( + _select_key, + first=providers.ContextResource(_creator, "first").with_config(scope=ContextScopes.INJECT), + second=providers.ContextResource(_creator, "second").with_config(scope=ContextScopes.INJECT), + ) + + async def _factory(value: str) -> tuple[str, str]: + return value, await selector.resolve() + + factory = providers.AsyncFactory(_factory, selector.cast) + + @inject + async def target(value: tuple[str, str] = Provide[factory]) -> tuple[str, str]: + return value + + assert await target() == ("first", "first") + assert selected_keys == ["first"] + assert events == ["first enter", "first exit"] + + +def test_string_injection_prepares_selected_selector_context() -> None: + def _creator() -> typing.Iterator[str]: + yield "selected" + + class _Container(BaseContainer): + resource = providers.ContextResource(_creator).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector("selected", selected=resource) + + @inject + def target(value: str = Provide["_Container.selector"]) -> str: + return value + + assert target() == "selected" + + +async def test_type_injection_prepares_selected_selector_context() -> None: + async def _creator() -> typing.AsyncIterator[str]: + yield "selected" + + class _Container(BaseContainer): + resource = providers.ContextResource(_creator).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector("selected", selected=resource).bind(str) + + @_Container.inject + async def target(value: str = Provide()) -> str: + return value + + assert await target() == "selected" + + +def test_injection_scope_none_does_not_enter_selector_context() -> None: + events: list[str] = [] + + def _creator() -> typing.Iterator[str]: # pragma: no cover + events.append("entered") + yield "selected" + + selector = providers.Selector( + "selected", + selected=providers.ContextResource(_creator).with_config(scope=ContextScopes.INJECT), + ) + + @inject(scope=None) + def target(value: str = Provide[selector]) -> str: + return value # pragma: no cover + + with pytest.raises(RuntimeError, match="Context is not set"): + target() + assert events == [] + + def test_simple_injection_into_iterator_sync() -> None: class _Container(BaseContainer): sync_resource = providers.Factory(random.random) diff --git a/that_depends/injection.py b/that_depends/injection.py index bb8c1965..21ec6e17 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -12,7 +12,10 @@ from that_depends.exceptions import TypeNotBoundError from that_depends.meta import BaseContainerMeta from that_depends.providers import AbstractProvider -from that_depends.providers.context_resources import ContextScope, ContextScopes, container_context +from that_depends.providers.context_resources import ContextResource, ContextScope, ContextScopes, container_context +from that_depends.providers.mixin import ProviderWithArguments +from that_depends.providers.selector import Selector +from that_depends.utils import is_set class ContextProviderError(Exception): @@ -31,7 +34,6 @@ class ContextProviderError(Exception): class _DirectInjectionParameter(typing.NamedTuple): field_name: str provider: AbstractProvider[typing.Any] - scope_context_init_order: tuple[AbstractProvider[typing.Any], ...] class _StringInjectionParameter(typing.NamedTuple): @@ -125,7 +127,6 @@ def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionP _DirectInjectionParameter( field_name, default, - default._get_scope_context_init_order(), # noqa: SLF001 ) ) elif isinstance(default, _Provide): @@ -290,14 +291,12 @@ async def _resolve_arguments_async( if direct_parameter.field_name in provided_names: continue - if direct_parameter.scope_context_init_order: - await _setup_scope_contexts_async( - direct_parameter.scope_context_init_order, - scope, - stack, - context_providers, - ) - kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() + kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( + direct_parameter.provider, + scope, + stack, + context_providers, + ) for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -341,14 +340,12 @@ def _resolve_arguments_sync( if direct_parameter.field_name in provided_names: continue - if direct_parameter.scope_context_init_order: - _setup_scope_contexts_sync( - direct_parameter.scope_context_init_order, - scope, - stack, - context_providers, - ) - kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() + kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( + direct_parameter.provider, + scope, + stack, + context_providers, + ) for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -401,13 +398,6 @@ def _resolve_sync( *args: P.args, **kwargs: P.kwargs, ) -> T: - if scope is None: - injected, kwargs = _resolve_arguments_sync(plan, scope, container, None, *args, **kwargs) # type: ignore[assignment] - if not injected: - warnings.warn(_INJECTION_WARNING_MESSAGE, RuntimeWarning, stacklevel=3) - - return func(*args, **kwargs) - with _SyncInjectionStack() as stack: injected, kwargs = _resolve_arguments_sync(plan, scope, container, stack, *args, **kwargs) # type: ignore[assignment] @@ -460,33 +450,43 @@ async def _resolve_provider_with_scope_async( ContextProviderError: if the stack is None. """ - scope_context_init_order = provider._get_scope_context_init_order() # noqa: SLF001 - if scope_context_init_order: - await _setup_scope_contexts_async(scope_context_init_order, scope, stack, providers) + await _prepare_provider_contexts_async(provider, scope, stack, providers) return await provider.resolve() -async def _setup_scope_contexts_async( - scope_init_order: tuple[AbstractProvider[typing.Any], ...], +async def _prepare_provider_contexts_async( + provider: AbstractProvider[typing.Any], scope: ContextScope | None, stack: AsyncExitStack | None, - providers: set[AbstractProvider[typing.Any]], + visited: set[AbstractProvider[typing.Any]], ) -> None: - if not scope: + if provider in visited: return - for provider in scope_init_order: - if provider in providers: - continue - providers.add(provider) - provider_scope = provider._scope # noqa: SLF001 - if provider_scope in (ContextScopes.ANY, scope): - if stack is None: - msg = ( - f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" - f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." - ) - raise ContextProviderError(msg) - await stack.enter_async_context(provider.context_async(force=True)) + visited.add(provider) + + if isinstance(provider, ProviderWithArguments): + provider._register_arguments() # noqa: SLF001 + for parent in provider._parents: # noqa: SLF001 + await _prepare_provider_contexts_async(parent, scope, stack, visited) + + if isinstance(provider, Selector) and not is_set(provider._override): # noqa: SLF001 + selected_provider = await provider._select_provider() # noqa: SLF001 + if stack is not None: + stack.enter_context(provider._pin_selected_provider(selected_provider)) # noqa: SLF001 + await _prepare_provider_contexts_async(selected_provider, scope, stack, visited) + + if ( + scope is not None + and isinstance(provider, ContextResource) + and provider.get_scope() in (ContextScopes.ANY, scope) + ): + if stack is None: + msg = ( + f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + await stack.enter_async_context(provider.context_async(force=True)) def _resolve_provider_with_scope_sync( @@ -495,34 +495,44 @@ def _resolve_provider_with_scope_sync( stack: _SyncInjectionStack | None, providers: set[AbstractProvider[typing.Any]], ) -> T: - scope_context_init_order = provider._get_scope_context_init_order() # noqa: SLF001 - if scope_context_init_order: - _setup_scope_contexts_sync(scope_context_init_order, scope, stack, providers) + _prepare_provider_contexts_sync(provider, scope, stack, providers) return provider.resolve_sync() -def _setup_scope_contexts_sync( - scope_init_order: tuple[AbstractProvider[typing.Any], ...], +def _prepare_provider_contexts_sync( + provider: AbstractProvider[typing.Any], scope: ContextScope | None, stack: _SyncInjectionStack | None, - providers: set[AbstractProvider[typing.Any]], + visited: set[AbstractProvider[typing.Any]], ) -> None: - if not scope: + if provider in visited: return - for provider in scope_init_order: - if provider in providers: - continue - providers.add(provider) - provider_scope = provider._scope # noqa: SLF001 - if provider_scope in (ContextScopes.ANY, scope): - if stack is None: - msg = ( - f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" - f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." - ) - raise ContextProviderError(msg) - _, exit_state = provider._enter_injection_context_sync(force=True) # noqa: SLF001 - stack.push_exit_state(exit_state) + visited.add(provider) + + if isinstance(provider, ProviderWithArguments): + provider._register_arguments() # noqa: SLF001 + for parent in provider._parents: # noqa: SLF001 + _prepare_provider_contexts_sync(parent, scope, stack, visited) + + if isinstance(provider, Selector) and not is_set(provider._override): # noqa: SLF001 + selected_provider = provider._select_provider_sync() # noqa: SLF001 + if stack is not None: + stack.enter_context(provider._pin_selected_provider(selected_provider)) # noqa: SLF001 + _prepare_provider_contexts_sync(selected_provider, scope, stack, visited) + + if ( + scope is not None + and isinstance(provider, ContextResource) + and provider.get_scope() in (ContextScopes.ANY, scope) + ): + if stack is None: + msg = ( + f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + _, exit_state = provider._enter_injection_context_sync(force=True) # noqa: SLF001 + stack.push_exit_state(exit_state) class StringProviderDefinition: diff --git a/that_depends/providers/selector.py b/that_depends/providers/selector.py index 9ec2c48b..975869f1 100644 --- a/that_depends/providers/selector.py +++ b/that_depends/providers/selector.py @@ -1,17 +1,20 @@ """Selection based providers.""" import typing +from contextlib import contextmanager +from contextvars import ContextVar from typing_extensions import override from that_depends.providers.base import AbstractProvider -from that_depends.utils import is_set +from that_depends.providers.mixin import ProviderWithArguments +from that_depends.utils import UNSET, Unset, is_set T_co = typing.TypeVar("T_co", covariant=True) -class Selector(AbstractProvider[T_co]): +class Selector(ProviderWithArguments, AbstractProvider[T_co]): """Chooses a provider based on a key returned by a selector function. This class allows you to dynamically select and resolve one of several @@ -35,7 +38,7 @@ def environment_selector(): """ - __slots__ = "_override", "_providers", "_selector" + __slots__ = "_override", "_providers", "_selected_provider", "_selector" def __init__( self, selector: typing.Callable[[], str] | AbstractProvider[str] | str, **providers: AbstractProvider[T_co] @@ -67,34 +70,63 @@ def my_selector(): super().__init__() self._selector: typing.Final[typing.Callable[[], str] | AbstractProvider[str] | str] = selector self._providers: typing.Final = providers + self._selected_provider: typing.Final[ContextVar[AbstractProvider[T_co] | Unset]] = ContextVar( + f"selector-{id(self)}", + default=UNSET, + ) + + def _register_arguments(self) -> None: + if not self._mark_arguments_registered(): + return + self._register((self._selector,)) + + def _deregister_arguments(self) -> None: + self._deregister((self._selector,)) + self._reset_arguments_registration() + + @contextmanager + def _pin_selected_provider(self, provider: AbstractProvider[T_co]) -> typing.Iterator[None]: + token = self._selected_provider.set(provider) + try: + yield + finally: + self._selected_provider.reset(token) @override async def resolve(self) -> T_co: if is_set(self._override): return typing.cast(T_co, self._override) + return await (await self._select_provider()).resolve() + + @override + def resolve_sync(self) -> T_co: + if is_set(self._override): + return typing.cast(T_co, self._override) + return self._select_provider_sync().resolve_sync() + + async def _select_provider(self) -> AbstractProvider[T_co]: + selected_provider = self._selected_provider.get() + if is_set(selected_provider): + return selected_provider if isinstance(self._selector, AbstractProvider): selected_key = await self._selector.resolve() else: selected_key = self._get_selected_key() - self._validate_key(selected_key) + return self._providers[selected_key] - return await self._providers[selected_key].resolve() - - @override - def resolve_sync(self) -> T_co: - if is_set(self._override): - return typing.cast(T_co, self._override) + def _select_provider_sync(self) -> AbstractProvider[T_co]: + selected_provider = self._selected_provider.get() + if is_set(selected_provider): + return selected_provider if isinstance(self._selector, AbstractProvider): selected_key = self._selector.resolve_sync() else: selected_key = self._get_selected_key() - self._validate_key(selected_key) - - return self._providers[selected_key].resolve_sync() + return self._providers[selected_key] def _get_selected_key(self) -> str: if callable(self._selector): From f0afbe3beb3396bae84ace4532cb018fcf6aa494 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 27 Jul 2026 22:41:51 +0200 Subject: [PATCH 04/20] plan: plan for implementation. --- plan.md | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..3e104421 --- /dev/null +++ b/plan.md @@ -0,0 +1,314 @@ +# Implementation Plan: Generic Dynamic Provider Resolution + +## Overview + +Rewrite PR #233 so dependency injection can prepare context resources for the active branch of any dynamic provider without importing or recognizing `Selector`. The implementation will add a small, additive provider-resolution contract, keep runtime-selection state alive only while the injected dependency is being resolved, preserve existing context-resource lifetimes, and restore the repository's 100% coverage baseline. + +The current branch passes all 436 tests but reports 14 uncovered source lines and 99% total coverage. More importantly, its generic injection layer imports `Selector`, reaches into several provider-private attributes, duplicates sync and async graph walking, holds selector pins for the whole injected function in normal callables, and does not pin selector choices at all when generator injection has no resource stack. + +## Goals + +- Initialize only context resources reachable through the selected dynamic-provider branch. +- Evaluate a selector once per provider resolution and reuse that selection until resolution completes. +- Keep injection generic: no `Selector` import, `isinstance(Selector, ...)` branch, or selector-private access in `that_depends/injection.py`. +- Provide a useful additive extension point for future built-in and third-party dynamic providers. +- Preserve sync, async, direct, string-based, type-based, callable, and generator injection behavior. +- Finish with `just lint-ci` passing and `just test` reporting 100% total coverage. + +## Architecture Decisions + +### 1. Use a two-phase provider-resolution contract + +Add default methods to `AbstractProvider` for two distinct dependency categories: + +1. `get_resolution_dependencies()` exposes direct static prerequisites as a read-only `Collection[AbstractProvider[Any]]`. Its default implementation registers provider arguments when needed and returns the provider's registered parents without exposing mutable internal sets. +2. `resolution_context()` and `resolution_context_sync()` are async and sync context-manager hooks. They default to yielding an empty read-only collection. Dynamic providers override them to yield dependencies that can only be known after their static prerequisites are ready. + +Dependency collections have no stable ordering guarantee. Dependency relationships, rather than collection order, determine initialization order. + +`Selector` will keep its selector-key provider as a static prerequisite. Once that prerequisite's contexts are ready, its resolution context will choose one candidate, pin that choice with its private `ContextVar`, and yield only the chosen provider as a runtime dependency. + +### 2. Separate resource lifetime from resolution-state lifetime + +Injection will use two different stacks: + +- The existing resource stack owns `ContextResource` instances and remains open for the injected function call. +- A new local resolution stack owns temporary provider state such as selector pins and closes immediately after the requested provider has resolved. + +This prevents an injected selector choice from leaking into later provider resolutions performed inside the decorated function. A local resolution stack must exist even when the resource stack is `None`, so generator injection can still pin a dynamic choice while resolving providers that do not require context resources. + +### 3. Traverse providers generically + +For each provider resolution, injection will: + +```text +visit provider + -> visit its static resolution dependencies + -> enter its resolution context + -> visit the runtime dependencies yielded by that context + -> initialize matching ContextResources on the resource stack +resolve the root provider while all resolution contexts remain active +close the resolution stack +``` + +Traversal will retain cycle and duplicate protection. Nested dynamic providers must use the same resolution stack as their root so every selected branch remains pinned until the root provider finishes resolving. + +### 4. Keep compatibility additive + +Existing provider subclasses that implement only `resolve()` and `resolve_sync()` continue to work through default no-op behavior. The new methods are supported public subclass extension points and must have complete types and docstrings. No existing public method, exception type, or error message will be removed or changed as part of this PR. + +## Task List + +### Task 1: Replace selector-specific injection traversal with the generic provider contract + +**Description:** Implement the two-phase contract on `AbstractProvider`, adapt `Selector` to it, and rewrite sync and async injection preparation around a dedicated resolution stack. This is the core vertical slice and should replace—not layer on top of—the current PR implementation. + +**Acceptance criteria:** + +- [ ] `AbstractProvider` supplies typed, documented default implementations for static resolution dependencies and sync/async resolution contexts. +- [ ] `Selector` exposes its selector-key provider as a static dependency and yields only its selected candidate from its resolution context while the selection is pinned. +- [ ] `that_depends/injection.py` contains no import or concrete treatment of `Selector`, `ProviderWithArguments`, or selector state. +- [ ] Static and runtime dependency traversal shares cycle protection and works recursively in sync and async resolution. +- [ ] Resolution contexts close immediately after the root provider resolves; context resources retain their existing function-call lifetime. +- [ ] The rewrite does not add `SLF001` suppressions to injection code. Existing unavoidable suppressions unrelated to the new contract are not broadened. + +**Verification:** + +- [ ] Focused tests pass: `just test tests/providers/test_base.py tests/providers/test_selector.py tests/test_injection.py --no-cov` +- [ ] Ruff passes for touched source files: `uv run ruff check that_depends/providers/base.py that_depends/providers/selector.py that_depends/injection.py` +- [ ] Diff inspection confirms injection depends only on the generic provider contract. + +**Dependencies:** None + +**Files likely touched:** + +- `that_depends/providers/base.py` +- `that_depends/providers/selector.py` +- `that_depends/injection.py` +- `tests/providers/test_selector.py` +- `tests/test_injection.py` + +**Estimated scope:** Medium: 5 tightly related files + +### Task 2: Prove the contract supports future dynamic providers + +**Description:** Add a minimal test-only dynamic provider that uses the new contract without inheriting from or referring to `Selector`. Use it to verify that generic injection prepares static and runtime dependencies correctly, including nested dynamic providers. + +**Acceptance criteria:** + +- [ ] A custom `AbstractProvider` subclass can expose a runtime-selected dependency through the new public contract. +- [ ] Direct-provider injection resolves the custom provider in sync and async modes. +- [ ] A dynamic provider nested beneath an ordinary factory/singleton is discovered without concrete type checks. +- [ ] Nested dynamic providers retain every activation context until the root resolution completes. +- [ ] Duplicate and cyclic visits do not initialize or enter the same provider more than once during one root resolution. + +**Verification:** + +- [ ] Focused contract tests pass: `just test tests/providers/test_base.py tests/test_injection.py --no-cov` +- [ ] Type checking accepts a third-party-style subclass without casts or ignores: `uv run mypy tests/providers/test_base.py tests/test_injection.py --disable-error-code=unused-ignore` +- [ ] Pyrefly accepts the contract and test subclass: `uv run pyrefly check --no-progress-bar` + +**Dependencies:** Task 1 + +**Files likely touched:** + +- `tests/providers/test_base.py` +- `tests/test_injection.py` +- `that_depends/providers/base.py` only if the contract needs a type correction + +**Estimated scope:** Small: 2-3 files + +### Checkpoint: Generic resolution foundation + +- [ ] Tasks 1-2 focused tests pass in both sync and async modes. +- [ ] No concrete dynamic-provider type appears in injection traversal. +- [ ] A test-only third-party provider demonstrates that the extension point is genuinely reusable. +- [ ] Review the public method names and lifetime documentation before expanding edge-case coverage. + +### Task 3: Lock down Selector selection and lifetime semantics + +**Description:** Add behavior-level regressions for `Selector` rather than tests of private registration fields. Cover the exact guarantees that motivated the rewrite: active branch only, exact-once selection, nested selection, override behavior, and cleanup. + +**Acceptance criteria:** + +- [ ] Sync and async selector callables are evaluated exactly once for each root provider resolution. +- [ ] Only the selected branch's context resources are entered; unselected sync and async resources remain untouched. +- [ ] A selected branch nested under another selector is prepared and resolved correctly. +- [ ] Selection state is reset after successful resolution and after exceptions. +- [ ] Resolving the same selector inside the decorated function performs a fresh selection, proving that injection did not pin it for the entire function body. +- [ ] An overridden selector does not activate any candidate branch. + +**Verification:** + +- [ ] Selector behavior tests pass: `just test tests/providers/test_selector.py tests/test_injection.py --no-cov` +- [ ] Tests assert observable lifecycle events and values rather than private `_parents`, `_children`, or pin state where possible. + +**Dependencies:** Tasks 1-2 + +**Files likely touched:** + +- `tests/providers/test_selector.py` +- `tests/test_injection.py` +- `that_depends/providers/selector.py` only if a behavior defect is exposed + +**Estimated scope:** Medium: 2-3 files + +### Task 4: Cover every injection surface and generator boundary + +**Description:** Verify the generic mechanism through all supported provider lookup paths and through generator injection, where the resource stack is intentionally unavailable but a resolution stack is still required. + +**Acceptance criteria:** + +- [ ] Direct, string-based, and type-based injection all prepare a selected branch identically. +- [ ] Sync and async generators resolve a dynamic provider without context resources using one pinned selection. +- [ ] Generator injection still raises `ContextProviderError` when the selected branch requires a matching `ContextResource` and no resource stack exists. +- [ ] A context resource on an unselected branch does not cause generator injection to fail. +- [ ] Existing scope filtering remains unchanged: resources with a different scope are not entered or rejected. + +**Verification:** + +- [ ] Injection matrix passes: `just test tests/test_injection.py --no-cov` +- [ ] Existing generator and context-resource tests remain unchanged unless their assertions are strengthened. +- [ ] Coverage report attributes every new contract and traversal branch to a meaningful behavior test. + +**Dependencies:** Task 3 + +**Files likely touched:** + +- `tests/test_injection.py` +- `that_depends/injection.py` only if an uncovered behavior defect is exposed + +**Estimated scope:** Medium: 1-2 files with a broad test matrix + +### Checkpoint: Behavior complete + +- [ ] Active-only traversal works for direct, nested, string, and type-based injection. +- [ ] Sync, async, generator, exception, and override lifetimes are covered. +- [ ] `just test` reports 100% total coverage with no `pragma: no cover` added for reachable behavior. + +### Task 5: Remove the rejected implementation shape + +**Description:** Remove obsolete helpers, imports, private-access suppressions, and tests that only validated the rejected concrete-`Selector` implementation shape. + +**Acceptance criteria:** + +- [ ] Tests no longer depend on the current PR's private registration implementation unless that private invariant has no observable substitute. +- [ ] The final diff removes the concrete `Selector` traversal, duplicated full graph walker, and associated new `# noqa: SLF001` comments from `injection.py`. +- [ ] No unused compatibility shim or redundant sync/async helper remains after the rewrite. + +**Verification:** + +- [ ] Ruff passes on all touched implementation and test files. +- [ ] Diff inspection confirms that every remaining branch implements a documented behavior covered by a test. + +**Dependencies:** Task 4 + +**Files likely touched:** + +- `tests/providers/test_selector.py` +- `that_depends/injection.py` +- `that_depends/providers/selector.py` + +**Estimated scope:** Small: 3 files + +### Task 6: Document the public extension contract + +**Description:** Document the provider-resolution extension points and the observable `Selector` behavior for both provider authors and maintainers. + +**Acceptance criteria:** + +- [ ] `AbstractProvider` docstrings explain when static dependencies and resolution contexts are evaluated, how long contexts remain active, and what custom providers may yield. +- [ ] Selector documentation states that only the selected branch is prepared during injection and that selection is stable only for one provider resolution. +- [ ] The architectural decision is recorded for maintainers, including why resolution state and context-resource lifetime use separate stacks. +- [ ] Documentation does not expose private selector pinning or injection stack implementation details as public guarantees. + +**Verification:** + +- [ ] Documentation builds strictly: `uv run mkdocs build --strict` +- [ ] Public method names, type signatures, docstrings, and narrative documentation describe the same lifecycle. + +**Dependencies:** Task 5 + +**Files likely touched:** + +- `that_depends/providers/base.py` +- `docs/providers/selector.md` +- `docs/dev/main-decisions.md` + +**Estimated scope:** Small: 3 files + +### Task 7: Run authoritative repository gates + +**Description:** Validate the complete rewrite using the repository's full lint, typing, test, and coverage gates. Fix only issues caused by this work and leave unrelated worktree changes untouched. + +**Acceptance criteria:** + +- [ ] Formatting, Ruff, mypy, and Pyrefly all pass without adding suppressions for the new contract. +- [ ] The complete randomized test suite passes. +- [ ] Coverage reports zero missing lines and 100% total coverage. +- [ ] The final PR description explains the generic contract and its semantics rather than presenting the work as a `Selector` special case. + +**Verification:** + +- [ ] `just lint-ci` +- [ ] `just test` +- [ ] Confirm the final coverage table reports `TOTAL ... 0 ... 100%`. +- [ ] Inspect `git diff --check` and `git diff origin/main...HEAD` for accidental or unrelated changes. + +**Dependencies:** Task 6 + +**Files likely touched:** None beyond fixes directly required by the gates + +**Estimated scope:** Small + +### Checkpoint: Ready for review + +- [ ] All task acceptance criteria are satisfied. +- [ ] `just lint-ci` passes. +- [ ] `just test` passes with 100% coverage. +- [ ] Injection has no knowledge of `Selector` or any other concrete dynamic provider. +- [ ] The public extension contract is typed, documented, and demonstrated by a non-Selector test provider. +- [ ] PR #233's review concern and Codecov failure are both resolved by the architecture rather than suppressed. + +## Dependency Graph + +```text +Task 1: provider contract + generic traversal + -> Task 2: third-party extension proof + -> Task 3: Selector semantics + -> Task 4: injection and generator matrix + -> Task 5: implementation cleanup + -> Task 6: contract documentation + -> Task 7: full repository gates +``` + +The tasks are intentionally sequential because they share one public contract. Test cases within Tasks 3 and 4 can be drafted independently after the Task 2 checkpoint, but implementation should not be parallelized until the method names and lifetime semantics are stable. + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| The new methods accidentally promise more ordering or lifecycle behavior than intended | High | Return immutable dependency collections, document ordering explicitly, and test only guaranteed behavior | +| Selector-key resources are needed before a candidate can be selected | High | Traverse static dependencies before entering the provider's runtime resolution context | +| Nested selector pins close before the root provider resolves | High | Enter all provider resolution contexts on one dedicated stack owned by the root resolution | +| Selection state leaks into the decorated function | High | Close the resolution stack immediately after `provider.resolve()` or `resolve_sync()` returns | +| Generator injection repeats selection because it has no resource stack | High | Always create a local resolution stack; keep `None` meaningful only for unavailable context-resource lifetime | +| Sync and async implementations drift | Medium | Use identical traversal structure and a shared behavior matrix with paired tests | +| Public method names collide with methods in third-party subclasses | Medium | Use resolution-specific names, keep defaults additive, and search the repository before finalizing names | +| Cyclic or shared dependency graphs cause repeated activation | Medium | Maintain a per-root-resolution visited set and add cycle/shared-dependency regression coverage | +| Tests reach 100% by asserting internals rather than behavior | Medium | Prefer event logs, returned values, evaluation counts, and resource enter/exit assertions | + +## Not Doing + +- Do not keep a structural or nominal `Selector` special case in injection. +- Do not initialize every selector candidate and filter afterward. +- Do not move general context-resource ownership into provider `resolve()` methods. +- Do not introduce a full `ProviderResolutionPlan` object or conditional-edge graph unless the two-phase contract proves insufficient. +- Do not keep selector pins alive for the full decorated function call. +- Do not add coverage exclusions, unreachable branches, or tests whose only purpose is executing dead defensive code. +- Do not change selector key validation, public error messages, override APIs, or unrelated provider behavior. +- Do not rewrite unrelated commits or files already merged from `main` into the PR branch. + +## Open Questions + +None are blocking. The plan selects `get_resolution_dependencies()`, `resolution_context()`, and `resolution_context_sync()` as supported subclass hooks returning read-only dependency collections with no ordering guarantee. If implementation reveals a concrete naming collision in downstream compatibility testing, rename the hooks before the Task 1 checkpoint and update the plan before proceeding. From 9da6b4bf6536f5bfc454b432ac71fffc913b0f04 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 10:39:30 +0200 Subject: [PATCH 05/20] feat: add generic provider resolution contract --- tests/providers/test_base.py | 19 +++++ tests/providers/test_selector.py | 23 ++++++ that_depends/injection.py | 108 ++++++++++++++++++----------- that_depends/providers/base.py | 40 +++++++++++ that_depends/providers/selector.py | 30 +++++++- 5 files changed, 177 insertions(+), 43 deletions(-) diff --git a/tests/providers/test_base.py b/tests/providers/test_base.py index 7a0601cf..5a67b8fe 100644 --- a/tests/providers/test_base.py +++ b/tests/providers/test_base.py @@ -88,6 +88,25 @@ def test_register_with_mixed_items() -> None: assert parent in child_1._children, "Expected child_1._children to contain parent" +def test_get_resolution_dependencies_registers_provider_arguments() -> None: + dependency = DummyProvider() + provider = Singleton(lambda value: value, dependency) + + resolution_dependencies = provider.get_resolution_dependencies() + + assert resolution_dependencies == frozenset({dependency}) + assert isinstance(resolution_dependencies, frozenset) + + +async def test_default_resolution_contexts_have_no_runtime_dependencies() -> None: + provider = DummyProvider() + + async with provider.resolution_context() as async_dependencies: + assert async_dependencies == () + with provider.resolution_context_sync() as sync_dependencies: + assert sync_dependencies == () + + def test_invalidate_scope_init_order_handles_duplicate_descendants() -> None: root = DummyProvider() left = DummyProvider() diff --git a/tests/providers/test_selector.py b/tests/providers/test_selector.py index 865d05a2..9d6b03b0 100644 --- a/tests/providers/test_selector.py +++ b/tests/providers/test_selector.py @@ -161,6 +161,29 @@ def _selector_key() -> typing.Iterator[str]: # pragma: no cover assert selector not in selector_key._children +def test_selector_resolution_context_exposes_and_pins_selected_provider() -> None: + expected_selection_count = 2 + selected_key = "one" + selection_count = 0 + one = providers.Object("one") + two = providers.Object("two") + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return selected_key + + selector = providers.Selector(_select, one=one, two=two) + + with selector.resolution_context_sync() as dependencies: + assert dependencies == (one,) + selected_key = "two" + assert selector.resolve_sync() == "one" + + assert selector.resolve_sync() == "two" + assert selection_count == expected_selection_count + + class InvalidSelectorContainer(BaseContainer): selector = providers.Selector( None, # type: ignore[arg-type] diff --git a/that_depends/injection.py b/that_depends/injection.py index 67ed574a..42ceff01 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -3,7 +3,7 @@ import re import typing import warnings -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, ExitStack from types import TracebackType from typing_extensions import Self @@ -13,9 +13,6 @@ from that_depends.meta import BaseContainerMeta from that_depends.providers import AbstractProvider from that_depends.providers.context_resources import ContextResource, ContextScope, ContextScopes, container_context -from that_depends.providers.mixin import ProviderWithArguments -from that_depends.providers.selector import Selector -from that_depends.utils import is_set class ContextProviderError(Exception): @@ -53,6 +50,11 @@ class _InjectionPlan(typing.NamedTuple): typed_parameters: tuple[_TypedInjectionParameter, ...] +class _ProviderVisits(typing.NamedTuple): + traversed: set[AbstractProvider[typing.Any]] + initialized_contexts: set[AbstractProvider[typing.Any]] + + class _SyncInjectionStack: __slots__ = ("_exit_states",) @@ -78,9 +80,6 @@ def enter_context(self, context_manager: typing.ContextManager[T]) -> T: self._exit_states.append(_ContextManagerExitState(context_manager)) return value - def push_exit_state(self, exit_state: "_SupportsClose") -> None: - self._exit_states.append(exit_state) - class _SupportsClose(typing.Protocol): def close(self) -> None: ... @@ -455,43 +454,56 @@ async def _resolve_provider_with_scope_async( ContextProviderError: if the stack is None. """ - await _prepare_provider_contexts_async(provider, scope, stack, providers) - return await provider.resolve() + async with AsyncExitStack() as resolution_stack: + visits = _ProviderVisits(set(), providers) + await _prepare_provider_contexts_async(provider, scope, stack, resolution_stack, visits) + return await provider.resolve() async def _prepare_provider_contexts_async( provider: AbstractProvider[typing.Any], scope: ContextScope | None, - stack: AsyncExitStack | None, - visited: set[AbstractProvider[typing.Any]], + resource_stack: AsyncExitStack | None, + resolution_stack: AsyncExitStack, + visits: _ProviderVisits, ) -> None: - if provider in visited: + if provider in visits.traversed: return - visited.add(provider) + visits.traversed.add(provider) - if isinstance(provider, ProviderWithArguments): - provider._register_arguments() # noqa: SLF001 - for parent in provider._parents: # noqa: SLF001 - await _prepare_provider_contexts_async(parent, scope, stack, visited) + for dependency in provider.get_resolution_dependencies(): + await _prepare_provider_contexts_async( + dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) - if isinstance(provider, Selector) and not is_set(provider._override): # noqa: SLF001 - selected_provider = await provider._select_provider() # noqa: SLF001 - if stack is not None: - stack.enter_context(provider._pin_selected_provider(selected_provider)) # noqa: SLF001 - await _prepare_provider_contexts_async(selected_provider, scope, stack, visited) + runtime_dependencies = await resolution_stack.enter_async_context(provider.resolution_context()) + for dependency in runtime_dependencies: + await _prepare_provider_contexts_async( + dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) if ( scope is not None and isinstance(provider, ContextResource) and provider.get_scope() in (ContextScopes.ANY, scope) + and provider not in visits.initialized_contexts ): - if stack is None: + if resource_stack is None: msg = ( f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." ) raise ContextProviderError(msg) - await stack.enter_async_context(provider.context_async(force=True)) + visits.initialized_contexts.add(provider) + await resource_stack.enter_async_context(provider.context_async(force=True)) def _resolve_provider_with_scope_sync( @@ -500,44 +512,56 @@ def _resolve_provider_with_scope_sync( stack: _SyncInjectionStack | None, providers: set[AbstractProvider[typing.Any]], ) -> T: - _prepare_provider_contexts_sync(provider, scope, stack, providers) - return provider.resolve_sync() + with ExitStack() as resolution_stack: + visits = _ProviderVisits(set(), providers) + _prepare_provider_contexts_sync(provider, scope, stack, resolution_stack, visits) + return provider.resolve_sync() def _prepare_provider_contexts_sync( provider: AbstractProvider[typing.Any], scope: ContextScope | None, - stack: _SyncInjectionStack | None, - visited: set[AbstractProvider[typing.Any]], + resource_stack: _SyncInjectionStack | None, + resolution_stack: ExitStack, + visits: _ProviderVisits, ) -> None: - if provider in visited: + if provider in visits.traversed: return - visited.add(provider) + visits.traversed.add(provider) - if isinstance(provider, ProviderWithArguments): - provider._register_arguments() # noqa: SLF001 - for parent in provider._parents: # noqa: SLF001 - _prepare_provider_contexts_sync(parent, scope, stack, visited) + for dependency in provider.get_resolution_dependencies(): + _prepare_provider_contexts_sync( + dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) - if isinstance(provider, Selector) and not is_set(provider._override): # noqa: SLF001 - selected_provider = provider._select_provider_sync() # noqa: SLF001 - if stack is not None: - stack.enter_context(provider._pin_selected_provider(selected_provider)) # noqa: SLF001 - _prepare_provider_contexts_sync(selected_provider, scope, stack, visited) + runtime_dependencies = resolution_stack.enter_context(provider.resolution_context_sync()) + for dependency in runtime_dependencies: + _prepare_provider_contexts_sync( + dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) if ( scope is not None and isinstance(provider, ContextResource) and provider.get_scope() in (ContextScopes.ANY, scope) + and provider not in visits.initialized_contexts ): - if stack is None: + if resource_stack is None: msg = ( f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." ) raise ContextProviderError(msg) - _, exit_state = provider._enter_injection_context_sync(force=True) # noqa: SLF001 - stack.push_exit_state(exit_state) + visits.initialized_contexts.add(provider) + resource_stack.enter_context(provider.context_sync(force=True)) class StringProviderDefinition: diff --git a/that_depends/providers/base.py b/that_depends/providers/base.py index 31f59820..82b82c04 100644 --- a/that_depends/providers/base.py +++ b/that_depends/providers/base.py @@ -262,6 +262,46 @@ def __getattr__(self, attr_name: str) -> typing.Any: # noqa: ANN401 raise AttributeError(msg) return AttrGetter(provider=self, attr_name=attr_name) + def get_resolution_dependencies(self) -> typing.Collection["AbstractProvider[typing.Any]"]: + """Return providers that must be prepared before resolving this provider. + + Injection evaluates these static dependencies before entering this provider's + resolution context. Providers with arguments are registered lazily, and the + returned collection is an immutable snapshot with no ordering guarantee. + + Dynamic providers can expose dependencies selected at runtime from + :meth:`resolution_context` and :meth:`resolution_context_sync` instead. + """ + if isinstance(self, ProviderWithArguments): + self._register_arguments() + return frozenset(self._parents) + + @asynccontextmanager + async def resolution_context( + self, + ) -> typing.AsyncIterator[typing.Collection["AbstractProvider[typing.Any]"]]: + """Yield dependencies known only while resolving this provider asynchronously. + + Injection prepares the yielded providers and keeps this context active until + the root provider has resolved. The default implementation has no runtime + dependencies. Custom providers should yield a read-only collection and must + not rely on its iteration order. + """ + yield () + + @contextmanager + def resolution_context_sync( + self, + ) -> typing.Iterator[typing.Collection["AbstractProvider[typing.Any]"]]: + """Yield dependencies known only while resolving this provider synchronously. + + Injection prepares the yielded providers and keeps this context active until + the root provider has resolved. The default implementation has no runtime + dependencies. Custom providers should yield a read-only collection and must + not rely on its iteration order. + """ + yield () + @abc.abstractmethod async def resolve(self) -> T_co: """Resolve dependency asynchronously.""" diff --git a/that_depends/providers/selector.py b/that_depends/providers/selector.py index 975869f1..66dac95f 100644 --- a/that_depends/providers/selector.py +++ b/that_depends/providers/selector.py @@ -1,7 +1,7 @@ """Selection based providers.""" import typing -from contextlib import contextmanager +from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar from typing_extensions import override @@ -92,6 +92,34 @@ def _pin_selected_provider(self, provider: AbstractProvider[T_co]) -> typing.Ite finally: self._selected_provider.reset(token) + @asynccontextmanager + @override + async def resolution_context( + self, + ) -> typing.AsyncIterator[typing.Collection[AbstractProvider[typing.Any]]]: + """Select and expose the active provider for one asynchronous resolution.""" + if is_set(self._override): + yield () + return + + selected_provider = await self._select_provider() + with self._pin_selected_provider(selected_provider): + yield (selected_provider,) + + @contextmanager + @override + def resolution_context_sync( + self, + ) -> typing.Iterator[typing.Collection[AbstractProvider[typing.Any]]]: + """Select and expose the active provider for one synchronous resolution.""" + if is_set(self._override): + yield () + return + + selected_provider = self._select_provider_sync() + with self._pin_selected_provider(selected_provider): + yield (selected_provider,) + @override async def resolve(self) -> T_co: if is_set(self._override): From 9461023986dd511ebd4dc94d4624c15a51f07877 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 10:43:39 +0200 Subject: [PATCH 06/20] test: prove dynamic provider resolution contract --- tests/test_injection.py | 193 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/tests/test_injection.py b/tests/test_injection.py index 9fa5affe..6328aba0 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -7,6 +7,7 @@ from unittest.mock import Mock import pytest +from typing_extensions import override from tests import container from that_depends import ( @@ -42,6 +43,87 @@ def _sync_creator() -> typing.Iterator[int]: yield 1 +class _TestDynamicProvider(providers.AbstractProvider[str]): + """A third-party-style provider with dependencies chosen at resolution time.""" + + def __init__( + self, + name: str, + events: list[str], + *, + static_dependencies: typing.Collection[providers.AbstractProvider[typing.Any]] = (), + delegate: providers.AbstractProvider[str] | None = None, + ) -> None: + super().__init__() + self._name = name + self._events = events + self._static_dependencies = tuple(static_dependencies) + self._runtime_dependencies: tuple[providers.AbstractProvider[typing.Any], ...] = ( + (delegate,) if delegate is not None else () + ) + self._delegate = delegate + self._active = False + + def set_runtime_dependencies(self, *dependencies: providers.AbstractProvider[typing.Any]) -> None: + self._runtime_dependencies = dependencies + + @override + def get_resolution_dependencies(self) -> typing.Collection[providers.AbstractProvider[typing.Any]]: + return self._static_dependencies + + @asynccontextmanager + @override + async def resolution_context( + self, + ) -> typing.AsyncIterator[typing.Collection[providers.AbstractProvider[typing.Any]]]: + self._events.append(f"enter:{self._name}") + self._active = True + try: + yield self._runtime_dependencies + finally: + self._active = False + self._events.append(f"exit:{self._name}") + + @contextmanager + @override + def resolution_context_sync( + self, + ) -> typing.Iterator[typing.Collection[providers.AbstractProvider[typing.Any]]]: + self._events.append(f"enter:{self._name}") + self._active = True + try: + yield self._runtime_dependencies + finally: + self._active = False + self._events.append(f"exit:{self._name}") + + @override + async def resolve(self) -> str: + assert self._active + self._events.append(f"resolve:{self._name}") + static_value = await self._resolve_static_dependency() + delegate_value = await self._delegate.resolve() if self._delegate is not None else self._name + return f"{static_value}:{delegate_value}" if static_value is not None else delegate_value + + @override + def resolve_sync(self) -> str: + assert self._active + self._events.append(f"resolve:{self._name}") + static_value = self._resolve_static_dependency_sync() + delegate_value = self._delegate.resolve_sync() if self._delegate is not None else self._name + return f"{static_value}:{delegate_value}" if static_value is not None else delegate_value + + async def _resolve_static_dependency(self) -> typing.Any: # noqa: ANN401 + if not self._static_dependencies: + return None + return await next(iter(self._static_dependencies)).resolve() + + def _resolve_static_dependency_sync(self) -> typing.Any: # noqa: ANN401 + if not self._static_dependencies: + return None + return next(iter(self._static_dependencies)).resolve_sync() + + @inject async def test_injection( fixture_one: int, @@ -71,6 +153,117 @@ async def inner( await inner(True, arg2=container.SimpleFactory(dep1="1", dep2=2)) +def test_custom_dynamic_provider_prepares_static_and_runtime_dependencies_sync() -> None: + events: list[str] = [] + + def _resource(value: str) -> typing.Iterator[str]: + events.append(f"resource-enter:{value}") + try: + yield value + finally: + events.append(f"resource-exit:{value}") + + static = providers.ContextResource(_resource, "static").with_config(scope=ContextScopes.INJECT) + runtime = providers.ContextResource(_resource, "runtime").with_config(scope=ContextScopes.INJECT) + dynamic = _TestDynamicProvider("dynamic", events, static_dependencies=(static,), delegate=runtime) + + @inject + def _injected(value: str = Provide[dynamic]) -> str: + events.append("body") + assert static.resolve_sync() == "static" + assert runtime.resolve_sync() == "runtime" + return value + + assert _injected() == "static:runtime" + assert events == [ + "enter:dynamic", + "resolve:dynamic", + "resource-enter:static", + "resource-enter:runtime", + "exit:dynamic", + "body", + "resource-exit:runtime", + "resource-exit:static", + ] + + +async def test_custom_dynamic_provider_prepares_static_and_runtime_dependencies_async() -> None: + events: list[str] = [] + + async def _resource(value: str) -> typing.AsyncIterator[str]: + events.append(f"resource-enter:{value}") + try: + yield value + finally: + events.append(f"resource-exit:{value}") + + static = providers.ContextResource(_resource, "static").with_config(scope=ContextScopes.INJECT) + runtime = providers.ContextResource(_resource, "runtime").with_config(scope=ContextScopes.INJECT) + dynamic = _TestDynamicProvider("dynamic", events, static_dependencies=(static,), delegate=runtime) + + @inject + async def _injected(value: str = Provide[dynamic]) -> str: + events.append("body") + assert await static.resolve() == "static" + assert await runtime.resolve() == "runtime" + return value + + assert await _injected() == "static:runtime" + assert events == [ + "enter:dynamic", + "resolve:dynamic", + "resource-enter:static", + "resource-enter:runtime", + "exit:dynamic", + "body", + "resource-exit:runtime", + "resource-exit:static", + ] + + +def test_nested_custom_dynamic_providers_remain_active_until_root_resolution() -> None: + events: list[str] = [] + leaf = providers.Object("value") + inner = _TestDynamicProvider("inner", events, delegate=leaf) + outer = _TestDynamicProvider("outer", events, delegate=inner) + root = providers.Factory(lambda value: value, outer.cast) + + @inject + def _injected(value: str = Provide[root]) -> str: + return value + + assert _injected() == "value" + assert events == [ + "enter:outer", + "enter:inner", + "resolve:outer", + "resolve:inner", + "exit:inner", + "exit:outer", + ] + + +def test_dynamic_provider_traversal_handles_duplicate_and_cyclic_dependencies() -> None: + events: list[str] = [] + root = _TestDynamicProvider("root", events) + dependency = _TestDynamicProvider("dependency", events) + root.set_runtime_dependencies(root, dependency, dependency) + dependency.set_runtime_dependencies(root) + + @inject + def _injected(value: str = Provide[root]) -> str: + return value + + assert _injected() == "root" + assert events == [ + "enter:root", + "enter:dependency", + "resolve:root", + "exit:dependency", + "exit:root", + ] + + def test_sync_injection_stack_closes_entered_context_managers() -> None: events: list[str] = [] From 987e5ac80914b261218aa407f48c6640760875f6 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 10:48:40 +0200 Subject: [PATCH 07/20] test: lock down selector resolution semantics --- tests/test_injection.py | 219 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/tests/test_injection.py b/tests/test_injection.py index 6328aba0..08a2f6dc 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -264,6 +264,225 @@ def _injected(value: str = Provide[root]) -> str: ] +def test_selector_injection_prepares_only_active_branch_and_reselects_in_body_sync() -> None: + expected_selection_count = 2 + events: list[str] = [] + selection_count = 0 + + def _resource(name: str) -> typing.Iterator[str]: + events.append(f"enter:{name}") + try: + yield name + finally: + events.append(f"exit:{name}") + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return "selected" + + selected = providers.ContextResource(_resource, "selected").with_config(scope=ContextScopes.INJECT) + unselected = providers.ContextResource(_resource, "unselected").with_config(scope=ContextScopes.INJECT) + selector = providers.Selector(_select, selected=selected, unselected=unselected) + + @inject + def _injected(value: str = Provide[selector]) -> str: + assert selection_count == 1 + assert selector.resolve_sync() == "selected" + with pytest.raises(RuntimeError): + unselected.resolve_sync() + return value + + assert _injected() == "selected" + assert selection_count == expected_selection_count + assert events == ["enter:selected", "exit:selected"] + + +async def test_selector_injection_prepares_only_active_branch_and_reselects_in_body_async() -> None: + expected_selection_count = 2 + events: list[str] = [] + selection_count = 0 + + async def _resource(name: str) -> typing.AsyncIterator[str]: + events.append(f"enter:{name}") + try: + yield name + finally: + events.append(f"exit:{name}") + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return "selected" + + selected = providers.ContextResource(_resource, "selected").with_config(scope=ContextScopes.INJECT) + unselected = providers.ContextResource(_resource, "unselected").with_config(scope=ContextScopes.INJECT) + selector = providers.Selector(_select, selected=selected, unselected=unselected) + + @inject + async def _injected(value: str = Provide[selector]) -> str: + assert selection_count == 1 + assert await selector.resolve() == "selected" + with pytest.raises(RuntimeError): + await unselected.resolve() + return value + + assert await _injected() == "selected" + assert selection_count == expected_selection_count + assert events == ["enter:selected", "exit:selected"] + + +def test_nested_selectors_prepare_the_innermost_selected_branch() -> None: + selection_events: list[str] = [] + + def _resource() -> typing.Iterator[str]: + yield "value" + + def _select_outer() -> str: + selection_events.append("outer") + return "inner" + + def _select_inner() -> str: + selection_events.append("inner") + return "resource" + + resource = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + inner = providers.Selector(_select_inner, resource=resource) + outer = providers.Selector(_select_outer, inner=inner, unused=providers.Object("unused")) + + @inject + def _injected(value: str = Provide[outer]) -> str: + return value + + assert _injected() == "value" + assert selection_events == ["outer", "inner"] + + +def test_selector_selection_state_is_reset_after_resolution_error_sync() -> None: + expected_selection_count = 2 + selected_key = "failing" + selection_count = 0 + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return selected_key + + def _fail() -> typing.NoReturn: + msg = "resolution failed" + raise RuntimeError(msg) + + selector = providers.Selector[str]( + _select, + failing=providers.Factory(_fail), + successful=providers.Object("value"), + ) + + @inject + def _injected(value: str = Provide[selector]) -> str: + return value + + with pytest.raises(RuntimeError, match="resolution failed"): + _injected() + + selected_key = "successful" + assert _injected() == "value" + assert selection_count == expected_selection_count + + +async def test_selector_selection_state_is_reset_after_resolution_error_async() -> None: + expected_selection_count = 2 + selected_key = "failing" + selection_count = 0 + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return selected_key + + async def _fail() -> typing.NoReturn: + msg = "resolution failed" + raise RuntimeError(msg) + + selector = providers.Selector[str]( + _select, + failing=providers.AsyncFactory(_fail), + successful=providers.Object("value"), + ) + + @inject + async def _injected(value: str = Provide[selector]) -> str: + return value + + with pytest.raises(RuntimeError, match="resolution failed"): + await _injected() + + selected_key = "successful" + assert await _injected() == "value" + assert selection_count == expected_selection_count + + +def test_overridden_selector_does_not_prepare_candidate_branch_sync() -> None: + def _resource() -> typing.Iterator[str]: + yield "candidate" + + candidate = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector("candidate", candidate=candidate) + selector.override_sync("override") + + @inject + def _injected(value: str = Provide[selector]) -> str: + with pytest.raises(RuntimeError): + candidate.resolve_sync() + return value + + try: + assert _injected() == "override" + finally: + selector.reset_override_sync() + + +async def test_overridden_selector_does_not_prepare_candidate_branch_async() -> None: + async def _resource() -> typing.AsyncIterator[str]: + yield "candidate" + + candidate = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector("candidate", candidate=candidate) + selector.override_sync("override") + + @inject + async def _injected(value: str = Provide[selector]) -> str: + with pytest.raises(RuntimeError): + await candidate.resolve() + return value + + try: + assert await _injected() == "override" + finally: + selector.reset_override_sync() + + +async def test_dynamic_provider_async_traversal_handles_duplicate_and_cyclic_dependencies() -> None: + events: list[str] = [] + root = _TestDynamicProvider("root", events) + dependency = _TestDynamicProvider("dependency", events) + root.set_runtime_dependencies(root, dependency, dependency) + dependency.set_runtime_dependencies(root) + + @inject + async def _injected(value: str = Provide[root]) -> str: + return value + + assert await _injected() == "root" + assert events == [ + "enter:root", + "enter:dependency", + "resolve:root", + "exit:dependency", + "exit:root", + ] + + def test_sync_injection_stack_closes_entered_context_managers() -> None: events: list[str] = [] From 66d866b906ed7859b658d8db1e9f3604453e709c Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:02:38 +0200 Subject: [PATCH 08/20] test: cover dynamic injection surfaces --- tests/test_injection.py | 152 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 138 insertions(+), 14 deletions(-) diff --git a/tests/test_injection.py b/tests/test_injection.py index 08a2f6dc..336b0ae9 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -423,41 +423,37 @@ async def _injected(value: str = Provide[selector]) -> str: def test_overridden_selector_does_not_prepare_candidate_branch_sync() -> None: - def _resource() -> typing.Iterator[str]: - yield "candidate" - - candidate = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + override_value = 2 + candidate = providers.ContextResource(_sync_creator).with_config(scope=ContextScopes.INJECT) selector = providers.Selector("candidate", candidate=candidate) - selector.override_sync("override") + selector.override_sync(override_value) @inject - def _injected(value: str = Provide[selector]) -> str: + def _injected(value: int = Provide[selector]) -> int: with pytest.raises(RuntimeError): candidate.resolve_sync() return value try: - assert _injected() == "override" + assert _injected() == override_value finally: selector.reset_override_sync() async def test_overridden_selector_does_not_prepare_candidate_branch_async() -> None: - async def _resource() -> typing.AsyncIterator[str]: - yield "candidate" - - candidate = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + override_value = 2 + candidate = providers.ContextResource(_async_creator).with_config(scope=ContextScopes.INJECT) selector = providers.Selector("candidate", candidate=candidate) - selector.override_sync("override") + selector.override_sync(override_value) @inject - async def _injected(value: str = Provide[selector]) -> str: + async def _injected(value: int = Provide[selector]) -> int: with pytest.raises(RuntimeError): await candidate.resolve() return value try: - assert await _injected() == "override" + assert await _injected() == override_value finally: selector.reset_override_sync() @@ -483,6 +479,134 @@ async def _injected(value: str = Provide[root]) -> str: ] +def test_selector_branch_preparation_supports_every_provider_lookup_surface() -> None: + events: list[str] = [] + + def _resource() -> typing.Iterator[str]: + events.append("enter") + try: + yield "value" + finally: + events.append("exit") + + selected = providers.ContextResource(_resource).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector("selected", selected=selected).bind(str) + + class _ResolutionSurfaceContainer(BaseContainer): + dynamic = selector + + @inject + def _direct(value: str = Provide[selector]) -> str: + return value + + @inject + def _string(value: str = Provide["_ResolutionSurfaceContainer.dynamic"]) -> str: + return value + + @inject(container=_ResolutionSurfaceContainer) + def _typed(value: str = Provide()) -> str: + return value + + assert _direct() == "value" + assert _string() == "value" + assert _typed() == "value" + assert events == ["enter", "exit", "enter", "exit", "enter", "exit"] + + +def test_sync_generator_pins_dynamic_selection_without_resource_stack() -> None: + selection_count = 0 + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return "one" if selection_count == 1 else "two" + + selector = providers.Selector(_select, one=providers.Object("one"), two=providers.Object("two")) + + @inject + def _injected(value: str = Provide[selector]) -> typing.Generator[str, None, None]: + yield value + + assert next(_injected()) == "one" + assert selection_count == 1 + + +async def test_async_generator_pins_dynamic_selection_without_resource_stack() -> None: + selection_count = 0 + + def _select() -> str: + nonlocal selection_count + selection_count += 1 + return "one" if selection_count == 1 else "two" + + selector = providers.Selector(_select, one=providers.Object("one"), two=providers.Object("two")) + + @inject + async def _injected(value: str = Provide[selector]) -> typing.AsyncGenerator[str, None]: + yield value + + assert await anext(_injected()) == "one" + assert selection_count == 1 + + +def test_sync_generator_rejects_only_selected_context_resource_branch() -> None: + selected_key = "plain" + resource = providers.ContextResource(_sync_creator).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector( + lambda: selected_key, + plain=providers.Object(1), + resource=resource, + ) + + @inject + def _injected(value: int = Provide[selector]) -> typing.Generator[int, None, None]: + yield value + + assert next(_injected()) == 1 + + selected_key = "resource" + with pytest.raises(ContextProviderError): + next(_injected()) + + +async def test_async_generator_rejects_only_selected_context_resource_branch() -> None: + selected_key = "plain" + resource = providers.ContextResource(_async_creator).with_config(scope=ContextScopes.INJECT) + selector = providers.Selector( + lambda: selected_key, + plain=providers.Object(1), + resource=resource, + ) + + @inject + async def _injected(value: int = Provide[selector]) -> typing.AsyncGenerator[int, None]: + yield value + + assert await anext(_injected()) == 1 + + selected_key = "resource" + with pytest.raises(ContextProviderError): + await anext(_injected()) + + +def test_selector_branch_preserves_context_resource_scope_filtering() -> None: + def _resource() -> typing.Iterator[str]: + yield "value" + + resource = providers.ContextResource(_resource).with_config(scope=ContextScopes.REQUEST) + selector = providers.Selector("resource", resource=resource) + + @inject(scope=ContextScopes.APP) + def _injected(value: str = Provide[selector]) -> str: + return value + + with pytest.raises(RuntimeError): + _injected() + + with resource.context_sync(force=True): + assert _injected() == "value" + + def test_sync_injection_stack_closes_entered_context_managers() -> None: events: list[str] = [] From 282690ce55f58731ba381456980be3f2966e4a4f Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:03:37 +0200 Subject: [PATCH 09/20] test: assert selector public resolution contract --- tests/providers/test_selector.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/providers/test_selector.py b/tests/providers/test_selector.py index 9d6b03b0..4246200b 100644 --- a/tests/providers/test_selector.py +++ b/tests/providers/test_selector.py @@ -140,7 +140,7 @@ async def test_selector_with_provider_selector_async() -> None: assert (await StringProviderSelectorContainer.selector.resolve()) == "Provider 1" -def test_selector_registers_only_its_key_provider() -> None: +def test_selector_exposes_only_its_key_provider_as_static_dependency() -> None: def _selector_key() -> typing.Iterator[str]: # pragma: no cover yield "selected" @@ -148,17 +148,12 @@ def _selector_key() -> typing.Iterator[str]: # pragma: no cover selected = providers.Object("value") selector = providers.Selector(selector_key, selected=selected) - selector._register_arguments() - selector._register_arguments() - - assert selector in selector_key._children - assert selected not in selector._parents - assert selector._get_scope_context_init_order() == (selector_key,) - assert selector._get_scope_context_init_order() == (selector_key,) + assert selector.get_resolution_dependencies() == frozenset({selector_key}) + assert selector.get_resolution_dependencies() == frozenset({selector_key}) selector._deregister_arguments() - assert selector not in selector_key._children + assert selector.get_resolution_dependencies() == frozenset({selector_key}) def test_selector_resolution_context_exposes_and_pins_selected_provider() -> None: From 6d9d8740052b4b626da7907ef9d908021c3844dd Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:05:06 +0200 Subject: [PATCH 10/20] docs: explain dynamic provider resolution lifecycle --- docs/dev/main-decisions.md | 5 +++++ docs/providers/selector.md | 6 ++++++ that_depends/providers/selector.py | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/dev/main-decisions.md b/docs/dev/main-decisions.md index c0549975..2e741d0e 100644 --- a/docs/dev/main-decisions.md +++ b/docs/dev/main-decisions.md @@ -8,3 +8,8 @@ 3. Focus on maximum compatibility with mypy: - no need for `# type: ignore` - no need for `typing.cast` +4. Provider preparation supports static and runtime dependencies: + - `AbstractProvider.get_resolution_dependencies()` exposes prerequisites known before resolution; + - `resolution_context()` and `resolution_context_sync()` expose dependencies selected only at runtime; + - dependency collections are read-only and have no ordering guarantee beyond their dependency relationships; + - injection keeps temporary provider-resolution state on a separate stack from `ContextResource` ownership. Resolution state ends as soon as the root provider resolves, preventing a dynamic choice from leaking into the decorated function, while context resources retain their existing function-call lifetime. diff --git a/docs/providers/selector.md b/docs/providers/selector.md index 9fc4a66c..2ada9f9d 100644 --- a/docs/providers/selector.md +++ b/docs/providers/selector.md @@ -4,6 +4,12 @@ The Selector provider chooses between provider based on a key. This resolves int The selector can be a callable that returns a string, an instance of `AbstractProvider` or a string. +## Injection behavior + +When a `Selector` is injected, That Depends prepares context resources only for the selected provider branch. Context resources belonging exclusively to other candidates are not entered. + +The selected provider stays consistent while the requested dependency is resolving. Once that resolution finishes, later calls evaluate the selector again. Context resources prepared for the injected dependency keep their normal lifetime and remain available for the decorated function call. + ## Callable selectors ```python diff --git a/that_depends/providers/selector.py b/that_depends/providers/selector.py index 66dac95f..6fd728af 100644 --- a/that_depends/providers/selector.py +++ b/that_depends/providers/selector.py @@ -19,7 +19,9 @@ class Selector(ProviderWithArguments, AbstractProvider[T_co]): This class allows you to dynamically select and resolve one of several named providers at runtime. The provider key is determined by a - user-supplied selector function. + user-supplied selector function. During injection, only the selected + provider branch is prepared. That selection remains stable for one root + provider resolution and is evaluated again by later resolutions. Examples: ```python From b9cdc68636cc9598614fc10a4e2f96a92ca66c0c Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:06:19 +0200 Subject: [PATCH 11/20] refactor: remove obsolete context order cache --- tests/providers/test_base.py | 2 -- that_depends/providers/base.py | 24 ------------------------ 2 files changed, 26 deletions(-) diff --git a/tests/providers/test_base.py b/tests/providers/test_base.py index 5a67b8fe..28c972bf 100644 --- a/tests/providers/test_base.py +++ b/tests/providers/test_base.py @@ -119,13 +119,11 @@ def test_invalidate_scope_init_order_handles_duplicate_descendants() -> None: right.add_child_provider(shared) for provider in (root, left, right, shared): - provider._scope_context_init_order = () provider._scope_init_order = () root._invalidate_scope_init_order() for provider in (root, left, right, shared): - assert provider._scope_context_init_order is None assert provider._scope_init_order is None diff --git a/that_depends/providers/base.py b/that_depends/providers/base.py index 82b82c04..26318a4d 100644 --- a/that_depends/providers/base.py +++ b/that_depends/providers/base.py @@ -95,7 +95,6 @@ def __init__(self) -> None: self._children: set[AbstractProvider[typing.Any]] = set() self._parents: set[AbstractProvider[typing.Any]] = set() self._is_context_resource = False - self._scope_context_init_order: tuple[AbstractProvider[typing.Any], ...] | None = None self._scope_init_order: tuple[AbstractProvider[typing.Any], ...] | None = None self._override: typing.Any = UNSET self._bindings: set[type] = set() @@ -166,7 +165,6 @@ def _invalidate_scope_init_order(self) -> None: if provider in visited: continue visited.add(provider) - provider._scope_context_init_order = None # noqa: SLF001 provider._scope_init_order = None # noqa: SLF001 stack.extend(provider._children) # noqa: SLF001 @@ -192,28 +190,6 @@ def _get_scope_init_order(self) -> tuple["AbstractProvider[typing.Any]", ...]: self._scope_init_order = tuple(ordered) return self._scope_init_order - def _get_scope_context_init_order(self) -> tuple["AbstractProvider[typing.Any]", ...]: - if self._scope_context_init_order is not None: - return self._scope_context_init_order - - if isinstance(self, ProviderWithArguments): - self._register_arguments() - - ordered: list[AbstractProvider[typing.Any]] = [] - seen: set[AbstractProvider[typing.Any]] = set() - - for parent in self._parents: - for ancestor in parent._get_scope_context_init_order(): # noqa: SLF001 - if ancestor not in seen: - seen.add(ancestor) - ordered.append(ancestor) - - if self._is_context_resource and self not in seen: - ordered.append(self) - - self._scope_context_init_order = tuple(ordered) - return self._scope_context_init_order - def add_child_provider(self, provider: "AbstractProvider[typing.Any]") -> None: """Add a child provider to the current provider. From ee1ed72df0da2384dc03ed066148271d21fa5fbc Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:26:01 +0200 Subject: [PATCH 12/20] perf: preserve static injection fast path --- tests/test_injection.py | 15 ++++- that_depends/injection.py | 130 ++++++++++++++++++++++++++++++++++---- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/tests/test_injection.py b/tests/test_injection.py index 336b0ae9..86711d10 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -513,6 +513,19 @@ def _typed(value: str = Provide()) -> str: assert events == ["enter", "exit", "enter", "exit", "enter", "exit"] +def test_static_resolution_fast_path_handles_dependency_cycle() -> None: + root = providers.Object("value") + dependency = providers.Object("unused") + root._register((dependency,)) + dependency._register((root,)) + + @inject + def _injected(value: str = Provide[root]) -> str: + return value + + assert _injected() == "value" + + def test_sync_generator_pins_dynamic_selection_without_resource_stack() -> None: selection_count = 0 @@ -634,7 +647,7 @@ def _injected(value: providers.Object[int] = provider) -> providers.Object[int]: plan = _build_injection_plan(_injected) assert _injected(provider) is provider - assert plan.direct_parameters == (_DirectInjectionParameter("value", provider),) + assert plan.direct_parameters == (_DirectInjectionParameter("value", provider, ()),) def test_build_injection_plan_stores_annotation_for_type_based_injection() -> None: diff --git a/that_depends/injection.py b/that_depends/injection.py index 42ceff01..583f0274 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -31,6 +31,7 @@ class ContextProviderError(Exception): class _DirectInjectionParameter(typing.NamedTuple): field_name: str provider: AbstractProvider[typing.Any] + static_context_resources: tuple[ContextResource[typing.Any], ...] | None class _StringInjectionParameter(typing.NamedTuple): @@ -80,6 +81,9 @@ def enter_context(self, context_manager: typing.ContextManager[T]) -> T: self._exit_states.append(_ContextManagerExitState(context_manager)) return value + def push_exit_state(self, exit_state: "_SupportsClose") -> None: + self._exit_states.append(exit_state) + class _SupportsClose(typing.Protocol): def close(self) -> None: ... @@ -95,6 +99,32 @@ def close(self) -> None: self._context_manager.__exit__(None, None, None) +@functools.cache +def _get_static_context_resources( + provider: AbstractProvider[typing.Any], +) -> tuple[ContextResource[typing.Any], ...] | None: + resources: list[ContextResource[typing.Any]] = [] + visited: set[AbstractProvider[typing.Any]] = set() + + def _visit(dependency: AbstractProvider[typing.Any]) -> bool: + if dependency in visited: + return True + visited.add(dependency) + + if ( + type(dependency).resolution_context is not AbstractProvider.resolution_context + or type(dependency).resolution_context_sync is not AbstractProvider.resolution_context_sync + ): + return False + if not all(_visit(parent) for parent in dependency.get_resolution_dependencies()): + return False + if isinstance(dependency, ContextResource): + resources.append(dependency) + return True + + return tuple(resources) if _visit(provider) else None + + @functools.cache def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionPlan: signature = inspect.signature(func) @@ -126,6 +156,7 @@ def _build_injection_plan(func: typing.Callable[..., typing.Any]) -> _InjectionP _DirectInjectionParameter( field_name, default, + _get_static_context_resources(default), ) ) elif isinstance(default, _Provide): @@ -295,12 +326,22 @@ async def _resolve_arguments_async( if direct_parameter.field_name in provided_names: continue - kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( - direct_parameter.provider, - scope, - stack, - context_providers, - ) + if direct_parameter.static_context_resources is None: + kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( + direct_parameter.provider, + scope, + stack, + context_providers, + ) + else: + if direct_parameter.static_context_resources: + await _prepare_static_context_resources_async( + direct_parameter.static_context_resources, + scope, + stack, + context_providers, + ) + kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -344,12 +385,22 @@ def _resolve_arguments_sync( if direct_parameter.field_name in provided_names: continue - kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( - direct_parameter.provider, - scope, - stack, - context_providers, - ) + if direct_parameter.static_context_resources is None: + kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( + direct_parameter.provider, + scope, + stack, + context_providers, + ) + else: + if direct_parameter.static_context_resources: + _prepare_static_context_resources_sync( + direct_parameter.static_context_resources, + scope, + stack, + context_providers, + ) + kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -454,12 +505,39 @@ async def _resolve_provider_with_scope_async( ContextProviderError: if the stack is None. """ + static_resources = _get_static_context_resources(provider) + if static_resources is not None: + if static_resources: + await _prepare_static_context_resources_async(static_resources, scope, stack, providers) + return await provider.resolve() + async with AsyncExitStack() as resolution_stack: visits = _ProviderVisits(set(), providers) await _prepare_provider_contexts_async(provider, scope, stack, resolution_stack, visits) return await provider.resolve() +async def _prepare_static_context_resources_async( + resources: tuple[ContextResource[typing.Any], ...], + scope: ContextScope | None, + stack: AsyncExitStack | None, + initialized: set[AbstractProvider[typing.Any]], +) -> None: + if scope is None: + return + for resource in resources: + if resource in initialized or resource._scope not in (ContextScopes.ANY, scope): # noqa: SLF001 + continue + if stack is None: + msg = ( + f"No stack exists, cannot initialize context for {resource} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + initialized.add(resource) + await stack.enter_async_context(resource.context_async(force=True)) + + async def _prepare_provider_contexts_async( provider: AbstractProvider[typing.Any], scope: ContextScope | None, @@ -512,12 +590,40 @@ def _resolve_provider_with_scope_sync( stack: _SyncInjectionStack | None, providers: set[AbstractProvider[typing.Any]], ) -> T: + static_resources = _get_static_context_resources(provider) + if static_resources is not None: + if static_resources: + _prepare_static_context_resources_sync(static_resources, scope, stack, providers) + return provider.resolve_sync() + with ExitStack() as resolution_stack: visits = _ProviderVisits(set(), providers) _prepare_provider_contexts_sync(provider, scope, stack, resolution_stack, visits) return provider.resolve_sync() +def _prepare_static_context_resources_sync( + resources: tuple[ContextResource[typing.Any], ...], + scope: ContextScope | None, + stack: _SyncInjectionStack | None, + initialized: set[AbstractProvider[typing.Any]], +) -> None: + if scope is None: + return + for resource in resources: + if resource in initialized or resource._scope not in (ContextScopes.ANY, scope): # noqa: SLF001 + continue + if stack is None: + msg = ( + f"No stack exists, cannot initialize context for {resource} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + initialized.add(resource) + _, exit_state = resource._enter_injection_context_sync(force=True) # noqa: SLF001 + stack.push_exit_state(exit_state) + + def _prepare_provider_contexts_sync( provider: AbstractProvider[typing.Any], scope: ContextScope | None, From 51d2ef17d518541341d9148f2e89ecdcbeaa0e24 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:30:24 +0200 Subject: [PATCH 13/20] docs: remove the main decisions change. --- docs/dev/main-decisions.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/dev/main-decisions.md b/docs/dev/main-decisions.md index 2e741d0e..c0549975 100644 --- a/docs/dev/main-decisions.md +++ b/docs/dev/main-decisions.md @@ -8,8 +8,3 @@ 3. Focus on maximum compatibility with mypy: - no need for `# type: ignore` - no need for `typing.cast` -4. Provider preparation supports static and runtime dependencies: - - `AbstractProvider.get_resolution_dependencies()` exposes prerequisites known before resolution; - - `resolution_context()` and `resolution_context_sync()` expose dependencies selected only at runtime; - - dependency collections are read-only and have no ordering guarantee beyond their dependency relationships; - - injection keeps temporary provider-resolution state on a separate stack from `ContextResource` ownership. Resolution state ends as soon as the root provider resolves, preventing a dynamic choice from leaking into the decorated function, while context resources retain their existing function-call lifetime. From 05ebd18f3168fa433146503ac99506d3d824a33e Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:31:09 +0200 Subject: [PATCH 14/20] docs: removed selector change. --- docs/providers/selector.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/providers/selector.md b/docs/providers/selector.md index 2ada9f9d..9fc4a66c 100644 --- a/docs/providers/selector.md +++ b/docs/providers/selector.md @@ -4,12 +4,6 @@ The Selector provider chooses between provider based on a key. This resolves int The selector can be a callable that returns a string, an instance of `AbstractProvider` or a string. -## Injection behavior - -When a `Selector` is injected, That Depends prepares context resources only for the selected provider branch. Context resources belonging exclusively to other candidates are not entered. - -The selected provider stays consistent while the requested dependency is resolving. Once that resolution finishes, later calls evaluate the selector again. Context resources prepared for the injected dependency keep their normal lifetime and remain available for the decorated function call. - ## Callable selectors ```python From 16287d874d697aa9663954a1b95a4f2f0421b981 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:31:38 +0200 Subject: [PATCH 15/20] misc: removed plan.md --- plan.md | 314 -------------------------------------------------------- 1 file changed, 314 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 3e104421..00000000 --- a/plan.md +++ /dev/null @@ -1,314 +0,0 @@ -# Implementation Plan: Generic Dynamic Provider Resolution - -## Overview - -Rewrite PR #233 so dependency injection can prepare context resources for the active branch of any dynamic provider without importing or recognizing `Selector`. The implementation will add a small, additive provider-resolution contract, keep runtime-selection state alive only while the injected dependency is being resolved, preserve existing context-resource lifetimes, and restore the repository's 100% coverage baseline. - -The current branch passes all 436 tests but reports 14 uncovered source lines and 99% total coverage. More importantly, its generic injection layer imports `Selector`, reaches into several provider-private attributes, duplicates sync and async graph walking, holds selector pins for the whole injected function in normal callables, and does not pin selector choices at all when generator injection has no resource stack. - -## Goals - -- Initialize only context resources reachable through the selected dynamic-provider branch. -- Evaluate a selector once per provider resolution and reuse that selection until resolution completes. -- Keep injection generic: no `Selector` import, `isinstance(Selector, ...)` branch, or selector-private access in `that_depends/injection.py`. -- Provide a useful additive extension point for future built-in and third-party dynamic providers. -- Preserve sync, async, direct, string-based, type-based, callable, and generator injection behavior. -- Finish with `just lint-ci` passing and `just test` reporting 100% total coverage. - -## Architecture Decisions - -### 1. Use a two-phase provider-resolution contract - -Add default methods to `AbstractProvider` for two distinct dependency categories: - -1. `get_resolution_dependencies()` exposes direct static prerequisites as a read-only `Collection[AbstractProvider[Any]]`. Its default implementation registers provider arguments when needed and returns the provider's registered parents without exposing mutable internal sets. -2. `resolution_context()` and `resolution_context_sync()` are async and sync context-manager hooks. They default to yielding an empty read-only collection. Dynamic providers override them to yield dependencies that can only be known after their static prerequisites are ready. - -Dependency collections have no stable ordering guarantee. Dependency relationships, rather than collection order, determine initialization order. - -`Selector` will keep its selector-key provider as a static prerequisite. Once that prerequisite's contexts are ready, its resolution context will choose one candidate, pin that choice with its private `ContextVar`, and yield only the chosen provider as a runtime dependency. - -### 2. Separate resource lifetime from resolution-state lifetime - -Injection will use two different stacks: - -- The existing resource stack owns `ContextResource` instances and remains open for the injected function call. -- A new local resolution stack owns temporary provider state such as selector pins and closes immediately after the requested provider has resolved. - -This prevents an injected selector choice from leaking into later provider resolutions performed inside the decorated function. A local resolution stack must exist even when the resource stack is `None`, so generator injection can still pin a dynamic choice while resolving providers that do not require context resources. - -### 3. Traverse providers generically - -For each provider resolution, injection will: - -```text -visit provider - -> visit its static resolution dependencies - -> enter its resolution context - -> visit the runtime dependencies yielded by that context - -> initialize matching ContextResources on the resource stack -resolve the root provider while all resolution contexts remain active -close the resolution stack -``` - -Traversal will retain cycle and duplicate protection. Nested dynamic providers must use the same resolution stack as their root so every selected branch remains pinned until the root provider finishes resolving. - -### 4. Keep compatibility additive - -Existing provider subclasses that implement only `resolve()` and `resolve_sync()` continue to work through default no-op behavior. The new methods are supported public subclass extension points and must have complete types and docstrings. No existing public method, exception type, or error message will be removed or changed as part of this PR. - -## Task List - -### Task 1: Replace selector-specific injection traversal with the generic provider contract - -**Description:** Implement the two-phase contract on `AbstractProvider`, adapt `Selector` to it, and rewrite sync and async injection preparation around a dedicated resolution stack. This is the core vertical slice and should replace—not layer on top of—the current PR implementation. - -**Acceptance criteria:** - -- [ ] `AbstractProvider` supplies typed, documented default implementations for static resolution dependencies and sync/async resolution contexts. -- [ ] `Selector` exposes its selector-key provider as a static dependency and yields only its selected candidate from its resolution context while the selection is pinned. -- [ ] `that_depends/injection.py` contains no import or concrete treatment of `Selector`, `ProviderWithArguments`, or selector state. -- [ ] Static and runtime dependency traversal shares cycle protection and works recursively in sync and async resolution. -- [ ] Resolution contexts close immediately after the root provider resolves; context resources retain their existing function-call lifetime. -- [ ] The rewrite does not add `SLF001` suppressions to injection code. Existing unavoidable suppressions unrelated to the new contract are not broadened. - -**Verification:** - -- [ ] Focused tests pass: `just test tests/providers/test_base.py tests/providers/test_selector.py tests/test_injection.py --no-cov` -- [ ] Ruff passes for touched source files: `uv run ruff check that_depends/providers/base.py that_depends/providers/selector.py that_depends/injection.py` -- [ ] Diff inspection confirms injection depends only on the generic provider contract. - -**Dependencies:** None - -**Files likely touched:** - -- `that_depends/providers/base.py` -- `that_depends/providers/selector.py` -- `that_depends/injection.py` -- `tests/providers/test_selector.py` -- `tests/test_injection.py` - -**Estimated scope:** Medium: 5 tightly related files - -### Task 2: Prove the contract supports future dynamic providers - -**Description:** Add a minimal test-only dynamic provider that uses the new contract without inheriting from or referring to `Selector`. Use it to verify that generic injection prepares static and runtime dependencies correctly, including nested dynamic providers. - -**Acceptance criteria:** - -- [ ] A custom `AbstractProvider` subclass can expose a runtime-selected dependency through the new public contract. -- [ ] Direct-provider injection resolves the custom provider in sync and async modes. -- [ ] A dynamic provider nested beneath an ordinary factory/singleton is discovered without concrete type checks. -- [ ] Nested dynamic providers retain every activation context until the root resolution completes. -- [ ] Duplicate and cyclic visits do not initialize or enter the same provider more than once during one root resolution. - -**Verification:** - -- [ ] Focused contract tests pass: `just test tests/providers/test_base.py tests/test_injection.py --no-cov` -- [ ] Type checking accepts a third-party-style subclass without casts or ignores: `uv run mypy tests/providers/test_base.py tests/test_injection.py --disable-error-code=unused-ignore` -- [ ] Pyrefly accepts the contract and test subclass: `uv run pyrefly check --no-progress-bar` - -**Dependencies:** Task 1 - -**Files likely touched:** - -- `tests/providers/test_base.py` -- `tests/test_injection.py` -- `that_depends/providers/base.py` only if the contract needs a type correction - -**Estimated scope:** Small: 2-3 files - -### Checkpoint: Generic resolution foundation - -- [ ] Tasks 1-2 focused tests pass in both sync and async modes. -- [ ] No concrete dynamic-provider type appears in injection traversal. -- [ ] A test-only third-party provider demonstrates that the extension point is genuinely reusable. -- [ ] Review the public method names and lifetime documentation before expanding edge-case coverage. - -### Task 3: Lock down Selector selection and lifetime semantics - -**Description:** Add behavior-level regressions for `Selector` rather than tests of private registration fields. Cover the exact guarantees that motivated the rewrite: active branch only, exact-once selection, nested selection, override behavior, and cleanup. - -**Acceptance criteria:** - -- [ ] Sync and async selector callables are evaluated exactly once for each root provider resolution. -- [ ] Only the selected branch's context resources are entered; unselected sync and async resources remain untouched. -- [ ] A selected branch nested under another selector is prepared and resolved correctly. -- [ ] Selection state is reset after successful resolution and after exceptions. -- [ ] Resolving the same selector inside the decorated function performs a fresh selection, proving that injection did not pin it for the entire function body. -- [ ] An overridden selector does not activate any candidate branch. - -**Verification:** - -- [ ] Selector behavior tests pass: `just test tests/providers/test_selector.py tests/test_injection.py --no-cov` -- [ ] Tests assert observable lifecycle events and values rather than private `_parents`, `_children`, or pin state where possible. - -**Dependencies:** Tasks 1-2 - -**Files likely touched:** - -- `tests/providers/test_selector.py` -- `tests/test_injection.py` -- `that_depends/providers/selector.py` only if a behavior defect is exposed - -**Estimated scope:** Medium: 2-3 files - -### Task 4: Cover every injection surface and generator boundary - -**Description:** Verify the generic mechanism through all supported provider lookup paths and through generator injection, where the resource stack is intentionally unavailable but a resolution stack is still required. - -**Acceptance criteria:** - -- [ ] Direct, string-based, and type-based injection all prepare a selected branch identically. -- [ ] Sync and async generators resolve a dynamic provider without context resources using one pinned selection. -- [ ] Generator injection still raises `ContextProviderError` when the selected branch requires a matching `ContextResource` and no resource stack exists. -- [ ] A context resource on an unselected branch does not cause generator injection to fail. -- [ ] Existing scope filtering remains unchanged: resources with a different scope are not entered or rejected. - -**Verification:** - -- [ ] Injection matrix passes: `just test tests/test_injection.py --no-cov` -- [ ] Existing generator and context-resource tests remain unchanged unless their assertions are strengthened. -- [ ] Coverage report attributes every new contract and traversal branch to a meaningful behavior test. - -**Dependencies:** Task 3 - -**Files likely touched:** - -- `tests/test_injection.py` -- `that_depends/injection.py` only if an uncovered behavior defect is exposed - -**Estimated scope:** Medium: 1-2 files with a broad test matrix - -### Checkpoint: Behavior complete - -- [ ] Active-only traversal works for direct, nested, string, and type-based injection. -- [ ] Sync, async, generator, exception, and override lifetimes are covered. -- [ ] `just test` reports 100% total coverage with no `pragma: no cover` added for reachable behavior. - -### Task 5: Remove the rejected implementation shape - -**Description:** Remove obsolete helpers, imports, private-access suppressions, and tests that only validated the rejected concrete-`Selector` implementation shape. - -**Acceptance criteria:** - -- [ ] Tests no longer depend on the current PR's private registration implementation unless that private invariant has no observable substitute. -- [ ] The final diff removes the concrete `Selector` traversal, duplicated full graph walker, and associated new `# noqa: SLF001` comments from `injection.py`. -- [ ] No unused compatibility shim or redundant sync/async helper remains after the rewrite. - -**Verification:** - -- [ ] Ruff passes on all touched implementation and test files. -- [ ] Diff inspection confirms that every remaining branch implements a documented behavior covered by a test. - -**Dependencies:** Task 4 - -**Files likely touched:** - -- `tests/providers/test_selector.py` -- `that_depends/injection.py` -- `that_depends/providers/selector.py` - -**Estimated scope:** Small: 3 files - -### Task 6: Document the public extension contract - -**Description:** Document the provider-resolution extension points and the observable `Selector` behavior for both provider authors and maintainers. - -**Acceptance criteria:** - -- [ ] `AbstractProvider` docstrings explain when static dependencies and resolution contexts are evaluated, how long contexts remain active, and what custom providers may yield. -- [ ] Selector documentation states that only the selected branch is prepared during injection and that selection is stable only for one provider resolution. -- [ ] The architectural decision is recorded for maintainers, including why resolution state and context-resource lifetime use separate stacks. -- [ ] Documentation does not expose private selector pinning or injection stack implementation details as public guarantees. - -**Verification:** - -- [ ] Documentation builds strictly: `uv run mkdocs build --strict` -- [ ] Public method names, type signatures, docstrings, and narrative documentation describe the same lifecycle. - -**Dependencies:** Task 5 - -**Files likely touched:** - -- `that_depends/providers/base.py` -- `docs/providers/selector.md` -- `docs/dev/main-decisions.md` - -**Estimated scope:** Small: 3 files - -### Task 7: Run authoritative repository gates - -**Description:** Validate the complete rewrite using the repository's full lint, typing, test, and coverage gates. Fix only issues caused by this work and leave unrelated worktree changes untouched. - -**Acceptance criteria:** - -- [ ] Formatting, Ruff, mypy, and Pyrefly all pass without adding suppressions for the new contract. -- [ ] The complete randomized test suite passes. -- [ ] Coverage reports zero missing lines and 100% total coverage. -- [ ] The final PR description explains the generic contract and its semantics rather than presenting the work as a `Selector` special case. - -**Verification:** - -- [ ] `just lint-ci` -- [ ] `just test` -- [ ] Confirm the final coverage table reports `TOTAL ... 0 ... 100%`. -- [ ] Inspect `git diff --check` and `git diff origin/main...HEAD` for accidental or unrelated changes. - -**Dependencies:** Task 6 - -**Files likely touched:** None beyond fixes directly required by the gates - -**Estimated scope:** Small - -### Checkpoint: Ready for review - -- [ ] All task acceptance criteria are satisfied. -- [ ] `just lint-ci` passes. -- [ ] `just test` passes with 100% coverage. -- [ ] Injection has no knowledge of `Selector` or any other concrete dynamic provider. -- [ ] The public extension contract is typed, documented, and demonstrated by a non-Selector test provider. -- [ ] PR #233's review concern and Codecov failure are both resolved by the architecture rather than suppressed. - -## Dependency Graph - -```text -Task 1: provider contract + generic traversal - -> Task 2: third-party extension proof - -> Task 3: Selector semantics - -> Task 4: injection and generator matrix - -> Task 5: implementation cleanup - -> Task 6: contract documentation - -> Task 7: full repository gates -``` - -The tasks are intentionally sequential because they share one public contract. Test cases within Tasks 3 and 4 can be drafted independently after the Task 2 checkpoint, but implementation should not be parallelized until the method names and lifetime semantics are stable. - -## Risks and Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| The new methods accidentally promise more ordering or lifecycle behavior than intended | High | Return immutable dependency collections, document ordering explicitly, and test only guaranteed behavior | -| Selector-key resources are needed before a candidate can be selected | High | Traverse static dependencies before entering the provider's runtime resolution context | -| Nested selector pins close before the root provider resolves | High | Enter all provider resolution contexts on one dedicated stack owned by the root resolution | -| Selection state leaks into the decorated function | High | Close the resolution stack immediately after `provider.resolve()` or `resolve_sync()` returns | -| Generator injection repeats selection because it has no resource stack | High | Always create a local resolution stack; keep `None` meaningful only for unavailable context-resource lifetime | -| Sync and async implementations drift | Medium | Use identical traversal structure and a shared behavior matrix with paired tests | -| Public method names collide with methods in third-party subclasses | Medium | Use resolution-specific names, keep defaults additive, and search the repository before finalizing names | -| Cyclic or shared dependency graphs cause repeated activation | Medium | Maintain a per-root-resolution visited set and add cycle/shared-dependency regression coverage | -| Tests reach 100% by asserting internals rather than behavior | Medium | Prefer event logs, returned values, evaluation counts, and resource enter/exit assertions | - -## Not Doing - -- Do not keep a structural or nominal `Selector` special case in injection. -- Do not initialize every selector candidate and filter afterward. -- Do not move general context-resource ownership into provider `resolve()` methods. -- Do not introduce a full `ProviderResolutionPlan` object or conditional-edge graph unless the two-phase contract proves insufficient. -- Do not keep selector pins alive for the full decorated function call. -- Do not add coverage exclusions, unreachable branches, or tests whose only purpose is executing dead defensive code. -- Do not change selector key validation, public error messages, override APIs, or unrelated provider behavior. -- Do not rewrite unrelated commits or files already merged from `main` into the PR branch. - -## Open Questions - -None are blocking. The plan selects `get_resolution_dependencies()`, `resolution_context()`, and `resolution_context_sync()` as supported subclass hooks returning read-only dependency collections with no ordering guarantee. If implementation reveals a concrete naming collision in downstream compatibility testing, rename the hooks before the Task 1 checkpoint and update the plan before proceeding. From b94dfb88786669a5696ccb14382be6df8388c4be Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:36:40 +0200 Subject: [PATCH 16/20] docs: explain resolution dependency hooks --- that_depends/providers/base.py | 12 ++++++ that_depends/providers/selector.py | 63 +++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/that_depends/providers/base.py b/that_depends/providers/base.py index 26318a4d..6da7e2b6 100644 --- a/that_depends/providers/base.py +++ b/that_depends/providers/base.py @@ -247,6 +247,10 @@ def get_resolution_dependencies(self) -> typing.Collection["AbstractProvider[typ Dynamic providers can expose dependencies selected at runtime from :meth:`resolution_context` and :meth:`resolution_context_sync` instead. + + Returns: + An immutable snapshot of the provider's static dependencies. + """ if isinstance(self, ProviderWithArguments): self._register_arguments() @@ -262,6 +266,10 @@ async def resolution_context( the root provider has resolved. The default implementation has no runtime dependencies. Custom providers should yield a read-only collection and must not rely on its iteration order. + + Yields: + The dependencies discovered for the current asynchronous resolution. + """ yield () @@ -275,6 +283,10 @@ def resolution_context_sync( the root provider has resolved. The default implementation has no runtime dependencies. Custom providers should yield a read-only collection and must not rely on its iteration order. + + Yields: + The dependencies discovered for the current synchronous resolution. + """ yield () diff --git a/that_depends/providers/selector.py b/that_depends/providers/selector.py index 6fd728af..5e5277ad 100644 --- a/that_depends/providers/selector.py +++ b/that_depends/providers/selector.py @@ -78,16 +78,31 @@ def my_selector(): ) def _register_arguments(self) -> None: + """Register the provider-valued selector as a static dependency. + + Registration is idempotent because providers attach their arguments lazily + when the dependency graph is first inspected. + """ if not self._mark_arguments_registered(): return self._register((self._selector,)) def _deregister_arguments(self) -> None: + """Detach the provider-valued selector from this provider's dependency graph.""" self._deregister((self._selector,)) self._reset_arguments_registration() @contextmanager def _pin_selected_provider(self, provider: AbstractProvider[T_co]) -> typing.Iterator[None]: + """Keep a selected provider stable for the current resolution context. + + Args: + provider: Provider selected for the active root resolution. + + Yields: + Control while the provider is pinned in the current context. + + """ token = self._selected_provider.set(provider) try: yield @@ -99,7 +114,16 @@ def _pin_selected_provider(self, provider: AbstractProvider[T_co]) -> typing.Ite async def resolution_context( self, ) -> typing.AsyncIterator[typing.Collection[AbstractProvider[typing.Any]]]: - """Select and expose the active provider for one asynchronous resolution.""" + """Expose the selected provider for one asynchronous root resolution. + + Overrides bypass provider selection because resolving the selector returns + the override directly. Otherwise, the selected provider is pinned so the + injection traversal and final resolution use the same branch. + + Yields: + The selected provider, or an empty collection while overridden. + + """ if is_set(self._override): yield () return @@ -113,7 +137,16 @@ async def resolution_context( def resolution_context_sync( self, ) -> typing.Iterator[typing.Collection[AbstractProvider[typing.Any]]]: - """Select and expose the active provider for one synchronous resolution.""" + """Expose the selected provider for one synchronous root resolution. + + Overrides bypass provider selection because resolving the selector returns + the override directly. Otherwise, the selected provider is pinned so the + injection traversal and final resolution use the same branch. + + Yields: + The selected provider, or an empty collection while overridden. + + """ if is_set(self._override): yield () return @@ -135,6 +168,19 @@ def resolve_sync(self) -> T_co: return self._select_provider_sync().resolve_sync() async def _select_provider(self) -> AbstractProvider[T_co]: + """Return the provider selected for asynchronous resolution. + + A provider pinned by :meth:`resolution_context` takes precedence over + evaluating the selector again. + + Returns: + The provider associated with the selected key. + + Raises: + TypeError: If the selector is not a supported type. + KeyError: If the selected key has no associated provider. + + """ selected_provider = self._selected_provider.get() if is_set(selected_provider): return selected_provider @@ -147,6 +193,19 @@ async def _select_provider(self) -> AbstractProvider[T_co]: return self._providers[selected_key] def _select_provider_sync(self) -> AbstractProvider[T_co]: + """Return the provider selected for synchronous resolution. + + A provider pinned by :meth:`resolution_context_sync` takes precedence over + evaluating the selector again. + + Returns: + The provider associated with the selected key. + + Raises: + TypeError: If the selector is not a supported type. + KeyError: If the selected key has no associated provider. + + """ selected_provider = self._selected_provider.get() if is_set(selected_provider): return selected_provider From 5ac184c19412aed0b0dedb632c9814dc081c031d Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:37:51 +0200 Subject: [PATCH 17/20] docs: document provider context traversal --- that_depends/injection.py | 137 +++++++++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 8 deletions(-) diff --git a/that_depends/injection.py b/that_depends/injection.py index 583f0274..cf5215e8 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -52,6 +52,14 @@ class _InjectionPlan(typing.NamedTuple): class _ProviderVisits(typing.NamedTuple): + """Track traversal state shared by one provider resolution. + + Attributes: + traversed: Providers whose dependency contexts have already been visited. + initialized_contexts: Context resources already entered by the injection call. + + """ + traversed: set[AbstractProvider[typing.Any]] initialized_contexts: set[AbstractProvider[typing.Any]] @@ -103,10 +111,34 @@ def close(self) -> None: def _get_static_context_resources( provider: AbstractProvider[typing.Any], ) -> tuple[ContextResource[typing.Any], ...] | None: + """Collect context resources from a provider's static dependency graph. + + The result is cached with the injection plan. A ``None`` result signals that at + least one provider overrides a resolution-context hook, so injection must walk + the graph at runtime to discover dynamic dependencies. + + Args: + provider: Root provider whose dependency graph should be inspected. + + Returns: + The statically reachable context resources, or ``None`` when the graph + requires runtime traversal. + + """ resources: list[ContextResource[typing.Any]] = [] visited: set[AbstractProvider[typing.Any]] = set() def _visit(dependency: AbstractProvider[typing.Any]) -> bool: + """Visit a dependency while the graph remains statically discoverable. + + Args: + dependency: Provider whose static dependencies should be inspected. + + Returns: + Whether the dependency and all of its descendants use the default + resolution-context hooks. + + """ if dependency in visited: return True visited.add(dependency) @@ -488,21 +520,25 @@ async def _resolve_provider_with_scope_async( stack: AsyncExitStack | None, providers: set[AbstractProvider[typing.Any]], ) -> T: - """Resolve a provider with given scope and stack. + """Resolve a provider and initialize its matching asynchronous resources. - Use `stack=None` to ensure ContextResource providers are not allowed. + Static graphs use their cached resource list. Graphs with runtime dependencies + are traversed while their resolution contexts remain active. Passing ``None`` + as the stack explicitly disallows context-resource initialization. Args: - provider: provider to resolve. - scope: scope to resolve provider in. - stack: stack to use for context resources. - providers: providers traversed. + provider: Provider to resolve. + scope: Scope in which matching context resources should be initialized. + stack: Stack that owns initialized context resources, or ``None`` to reject + resources that require initialization. + providers: Context resources already initialized by the injection call. Returns: - resolved value for the provider. + The value resolved by the provider. Raises: - ContextProviderError: if the stack is None. + ContextProviderError: If a matching context resource requires initialization + but no stack was supplied. """ static_resources = _get_static_context_resources(provider) @@ -523,6 +559,19 @@ async def _prepare_static_context_resources_async( stack: AsyncExitStack | None, initialized: set[AbstractProvider[typing.Any]], ) -> None: + """Enter statically discovered asynchronous context resources once. + + Args: + resources: Context resources reachable from the root provider. + scope: Scope in which matching resources should be initialized. + stack: Stack that owns initialized resources, or ``None`` to reject them. + initialized: Context resources already entered by the injection call. + + Raises: + ContextProviderError: If a matching resource requires initialization but no + stack was supplied. + + """ if scope is None: return for resource in resources: @@ -545,6 +594,25 @@ async def _prepare_provider_contexts_async( resolution_stack: AsyncExitStack, visits: _ProviderVisits, ) -> None: + """Prepare one provider's asynchronous static and runtime dependencies. + + Dependencies are visited before the provider itself. Resolution contexts are + kept open on ``resolution_stack`` until the root provider has resolved, while + context resources live on ``resource_stack`` for the entire injection call. + + Args: + provider: Provider whose dependency contexts should be prepared. + scope: Scope in which matching context resources should be initialized. + resource_stack: Stack that owns context resources, or ``None`` to reject + resources that require initialization. + resolution_stack: Stack that owns provider resolution contexts. + visits: Traversal and resource-initialization state for this resolution. + + Raises: + ContextProviderError: If a matching context resource requires initialization + but no resource stack was supplied. + + """ if provider in visits.traversed: return visits.traversed.add(provider) @@ -590,6 +658,27 @@ def _resolve_provider_with_scope_sync( stack: _SyncInjectionStack | None, providers: set[AbstractProvider[typing.Any]], ) -> T: + """Resolve a provider and initialize its matching synchronous resources. + + Static graphs use their cached resource list. Graphs with runtime dependencies + are traversed while their resolution contexts remain active. Passing ``None`` + as the stack explicitly disallows context-resource initialization. + + Args: + provider: Provider to resolve. + scope: Scope in which matching context resources should be initialized. + stack: Stack that owns initialized context resources, or ``None`` to reject + resources that require initialization. + providers: Context resources already initialized by the injection call. + + Returns: + The value resolved by the provider. + + Raises: + ContextProviderError: If a matching context resource requires initialization + but no stack was supplied. + + """ static_resources = _get_static_context_resources(provider) if static_resources is not None: if static_resources: @@ -608,6 +697,19 @@ def _prepare_static_context_resources_sync( stack: _SyncInjectionStack | None, initialized: set[AbstractProvider[typing.Any]], ) -> None: + """Enter statically discovered synchronous context resources once. + + Args: + resources: Context resources reachable from the root provider. + scope: Scope in which matching resources should be initialized. + stack: Stack that owns initialized resources, or ``None`` to reject them. + initialized: Context resources already entered by the injection call. + + Raises: + ContextProviderError: If a matching resource requires initialization but no + stack was supplied. + + """ if scope is None: return for resource in resources: @@ -631,6 +733,25 @@ def _prepare_provider_contexts_sync( resolution_stack: ExitStack, visits: _ProviderVisits, ) -> None: + """Prepare one provider's synchronous static and runtime dependencies. + + Dependencies are visited before the provider itself. Resolution contexts are + kept open on ``resolution_stack`` until the root provider has resolved, while + context resources live on ``resource_stack`` for the entire injection call. + + Args: + provider: Provider whose dependency contexts should be prepared. + scope: Scope in which matching context resources should be initialized. + resource_stack: Stack that owns context resources, or ``None`` to reject + resources that require initialization. + resolution_stack: Stack that owns provider resolution contexts. + visits: Traversal and resource-initialization state for this resolution. + + Raises: + ContextProviderError: If a matching context resource requires initialization + but no resource stack was supplied. + + """ if provider in visits.traversed: return visits.traversed.add(provider) From b3ac2cda826ee40af3b074d898b13e0c1cbf4b80 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:45:03 +0200 Subject: [PATCH 18/20] perf: minimize static injection branching --- that_depends/injection.py | 88 ++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/that_depends/injection.py b/that_depends/injection.py index cf5215e8..218ba36a 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -28,10 +28,18 @@ class ContextProviderError(Exception): ) +class _RuntimeContextResources: + """Mark a provider graph whose context resources require runtime discovery.""" + + +_RUNTIME_CONTEXT_RESOURCES = _RuntimeContextResources() +_ContextResources = tuple[ContextResource[typing.Any], ...] | _RuntimeContextResources + + class _DirectInjectionParameter(typing.NamedTuple): field_name: str provider: AbstractProvider[typing.Any] - static_context_resources: tuple[ContextResource[typing.Any], ...] | None + context_resources: _ContextResources class _StringInjectionParameter(typing.NamedTuple): @@ -110,10 +118,10 @@ def close(self) -> None: @functools.cache def _get_static_context_resources( provider: AbstractProvider[typing.Any], -) -> tuple[ContextResource[typing.Any], ...] | None: +) -> _ContextResources: """Collect context resources from a provider's static dependency graph. - The result is cached with the injection plan. A ``None`` result signals that at + The result is cached with the injection plan. A private marker signals that at least one provider overrides a resolution-context hook, so injection must walk the graph at runtime to discover dynamic dependencies. @@ -121,8 +129,8 @@ def _get_static_context_resources( provider: Root provider whose dependency graph should be inspected. Returns: - The statically reachable context resources, or ``None`` when the graph - requires runtime traversal. + The statically reachable context resources, or a marker requesting runtime + traversal. """ resources: list[ContextResource[typing.Any]] = [] @@ -154,7 +162,7 @@ def _visit(dependency: AbstractProvider[typing.Any]) -> bool: resources.append(dependency) return True - return tuple(resources) if _visit(provider) else None + return tuple(resources) if _visit(provider) else _RUNTIME_CONTEXT_RESOURCES @functools.cache @@ -358,22 +366,23 @@ async def _resolve_arguments_async( if direct_parameter.field_name in provided_names: continue - if direct_parameter.static_context_resources is None: - kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( - direct_parameter.provider, - scope, - stack, - context_providers, - ) - else: - if direct_parameter.static_context_resources: - await _prepare_static_context_resources_async( - direct_parameter.static_context_resources, + context_resources = direct_parameter.context_resources + if context_resources: + if context_resources is _RUNTIME_CONTEXT_RESOURCES: + kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( + direct_parameter.provider, scope, stack, context_providers, ) - kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() + continue + await _prepare_static_context_resources_async( + typing.cast(tuple[ContextResource[typing.Any], ...], context_resources), + scope, + stack, + context_providers, + ) + kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -417,22 +426,23 @@ def _resolve_arguments_sync( if direct_parameter.field_name in provided_names: continue - if direct_parameter.static_context_resources is None: - kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( - direct_parameter.provider, - scope, - stack, - context_providers, - ) - else: - if direct_parameter.static_context_resources: - _prepare_static_context_resources_sync( - direct_parameter.static_context_resources, + context_resources = direct_parameter.context_resources + if context_resources: + if context_resources is _RUNTIME_CONTEXT_RESOURCES: + kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( + direct_parameter.provider, scope, stack, context_providers, ) - kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() + continue + _prepare_static_context_resources_sync( + typing.cast(tuple[ContextResource[typing.Any], ...], context_resources), + scope, + stack, + context_providers, + ) + kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() for string_parameter in plan.string_parameters: if string_parameter.field_name in provided_names: @@ -542,9 +552,14 @@ async def _resolve_provider_with_scope_async( """ static_resources = _get_static_context_resources(provider) - if static_resources is not None: + if static_resources is not _RUNTIME_CONTEXT_RESOURCES: if static_resources: - await _prepare_static_context_resources_async(static_resources, scope, stack, providers) + await _prepare_static_context_resources_async( + typing.cast(tuple[ContextResource[typing.Any], ...], static_resources), + scope, + stack, + providers, + ) return await provider.resolve() async with AsyncExitStack() as resolution_stack: @@ -680,9 +695,14 @@ def _resolve_provider_with_scope_sync( """ static_resources = _get_static_context_resources(provider) - if static_resources is not None: + if static_resources is not _RUNTIME_CONTEXT_RESOURCES: if static_resources: - _prepare_static_context_resources_sync(static_resources, scope, stack, providers) + _prepare_static_context_resources_sync( + typing.cast(tuple[ContextResource[typing.Any], ...], static_resources), + scope, + stack, + providers, + ) return provider.resolve_sync() with ExitStack() as resolution_stack: From d011d56040d54af690508914924ee76a47711386 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 28 Jul 2026 14:50:32 +0200 Subject: [PATCH 19/20] misc: add site to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 246b803b..8f17a87b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dist/ .venv uv.lock .agents +site From e6a63726e08324142a1e650dfc6435b748f0fdbd Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 6 Aug 2026 14:03:07 +0200 Subject: [PATCH 20/20] feat: re-write to ProviderWithResolutionContext --- tests/providers/test_base.py | 19 -- tests/providers/test_selector.py | 9 +- tests/test_injection.py | 40 ++-- that_depends/injection.py | 228 ++++++++------------ that_depends/providers/__init__.py | 3 + that_depends/providers/base.py | 53 ----- that_depends/providers/context_resources.py | 1 - that_depends/providers/mixin.py | 22 ++ that_depends/providers/selector.py | 9 +- 9 files changed, 148 insertions(+), 236 deletions(-) diff --git a/tests/providers/test_base.py b/tests/providers/test_base.py index 28c972bf..b895a8b6 100644 --- a/tests/providers/test_base.py +++ b/tests/providers/test_base.py @@ -88,25 +88,6 @@ def test_register_with_mixed_items() -> None: assert parent in child_1._children, "Expected child_1._children to contain parent" -def test_get_resolution_dependencies_registers_provider_arguments() -> None: - dependency = DummyProvider() - provider = Singleton(lambda value: value, dependency) - - resolution_dependencies = provider.get_resolution_dependencies() - - assert resolution_dependencies == frozenset({dependency}) - assert isinstance(resolution_dependencies, frozenset) - - -async def test_default_resolution_contexts_have_no_runtime_dependencies() -> None: - provider = DummyProvider() - - async with provider.resolution_context() as async_dependencies: - assert async_dependencies == () - with provider.resolution_context_sync() as sync_dependencies: - assert sync_dependencies == () - - def test_invalidate_scope_init_order_handles_duplicate_descendants() -> None: root = DummyProvider() left = DummyProvider() diff --git a/tests/providers/test_selector.py b/tests/providers/test_selector.py index 4246200b..f42aa767 100644 --- a/tests/providers/test_selector.py +++ b/tests/providers/test_selector.py @@ -140,7 +140,7 @@ async def test_selector_with_provider_selector_async() -> None: assert (await StringProviderSelectorContainer.selector.resolve()) == "Provider 1" -def test_selector_exposes_only_its_key_provider_as_static_dependency() -> None: +def test_selector_registers_only_its_key_provider_as_static_dependency() -> None: def _selector_key() -> typing.Iterator[str]: # pragma: no cover yield "selected" @@ -148,12 +148,12 @@ def _selector_key() -> typing.Iterator[str]: # pragma: no cover selected = providers.Object("value") selector = providers.Selector(selector_key, selected=selected) - assert selector.get_resolution_dependencies() == frozenset({selector_key}) - assert selector.get_resolution_dependencies() == frozenset({selector_key}) + assert selector._get_scope_init_order() == (selector_key, selector) + assert selector._get_scope_init_order() == (selector_key, selector) selector._deregister_arguments() - assert selector.get_resolution_dependencies() == frozenset({selector_key}) + assert selector._get_scope_init_order() == (selector_key, selector) def test_selector_resolution_context_exposes_and_pins_selected_provider() -> None: @@ -170,6 +170,7 @@ def _select() -> str: selector = providers.Selector(_select, one=one, two=two) + assert isinstance(selector, providers.ProviderWithResolutionContext) with selector.resolution_context_sync() as dependencies: assert dependencies == (one,) selected_key = "two" diff --git a/tests/test_injection.py b/tests/test_injection.py index 86711d10..19a152aa 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -43,7 +43,11 @@ def _sync_creator() -> typing.Iterator[int]: yield 1 -class _TestDynamicProvider(providers.AbstractProvider[str]): +class _TestDynamicProvider( + providers.ProviderWithArguments, + providers.ProviderWithResolutionContext, + providers.AbstractProvider[str], +): """A third-party-style provider with dependencies chosen at resolution time.""" def __init__( @@ -67,9 +71,13 @@ def __init__( def set_runtime_dependencies(self, *dependencies: providers.AbstractProvider[typing.Any]) -> None: self._runtime_dependencies = dependencies - @override - def get_resolution_dependencies(self) -> typing.Collection[providers.AbstractProvider[typing.Any]]: - return self._static_dependencies + def _register_arguments(self) -> None: + if self._mark_arguments_registered(): + self._register(self._static_dependencies) + + def _deregister_arguments(self) -> None: + self._deregister(self._static_dependencies) + self._reset_arguments_registration() @asynccontextmanager @override @@ -187,6 +195,17 @@ def _injected(value: str = Provide[dynamic]) -> str: ] +def test_custom_dynamic_provider_registers_static_dependencies() -> None: + dependency = providers.Object("static") + dynamic = _TestDynamicProvider("dynamic", [], static_dependencies=(dependency,)) + + assert dynamic._get_scope_init_order() == (dependency, dynamic) + + dynamic._deregister_arguments() + + assert dynamic._get_scope_init_order() == (dependency, dynamic) + + async def test_custom_dynamic_provider_prepares_static_and_runtime_dependencies_async() -> None: events: list[str] = [] @@ -513,19 +532,6 @@ def _typed(value: str = Provide()) -> str: assert events == ["enter", "exit", "enter", "exit", "enter", "exit"] -def test_static_resolution_fast_path_handles_dependency_cycle() -> None: - root = providers.Object("value") - dependency = providers.Object("unused") - root._register((dependency,)) - dependency._register((root,)) - - @inject - def _injected(value: str = Provide[root]) -> str: - return value - - assert _injected() == "value" - - def test_sync_generator_pins_dynamic_selection_without_resource_stack() -> None: selection_count = 0 diff --git a/that_depends/injection.py b/that_depends/injection.py index 218ba36a..75e832be 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -11,7 +11,7 @@ from that_depends.container import BaseContainer from that_depends.exceptions import TypeNotBoundError from that_depends.meta import BaseContainerMeta -from that_depends.providers import AbstractProvider +from that_depends.providers import AbstractProvider, ProviderWithResolutionContext from that_depends.providers.context_resources import ContextResource, ContextScope, ContextScopes, container_context @@ -28,12 +28,12 @@ class ContextProviderError(Exception): ) -class _RuntimeContextResources: - """Mark a provider graph whose context resources require runtime discovery.""" +class _RuntimeContextTraversalRequired: + """Mark a provider graph that requires runtime context traversal.""" -_RUNTIME_CONTEXT_RESOURCES = _RuntimeContextResources() -_ContextResources = tuple[ContextResource[typing.Any], ...] | _RuntimeContextResources +_RUNTIME_CONTEXT_TRAVERSAL_REQUIRED = _RuntimeContextTraversalRequired() +_ContextResources = tuple[ContextResource[typing.Any], ...] | _RuntimeContextTraversalRequired class _DirectInjectionParameter(typing.NamedTuple): @@ -122,8 +122,8 @@ def _get_static_context_resources( """Collect context resources from a provider's static dependency graph. The result is cached with the injection plan. A private marker signals that at - least one provider overrides a resolution-context hook, so injection must walk - the graph at runtime to discover dynamic dependencies. + least one provider exposes a resolution context, so injection must walk the graph + at runtime to discover dynamic dependencies. Args: provider: Root provider whose dependency graph should be inspected. @@ -133,36 +133,10 @@ def _get_static_context_resources( traversal. """ - resources: list[ContextResource[typing.Any]] = [] - visited: set[AbstractProvider[typing.Any]] = set() - - def _visit(dependency: AbstractProvider[typing.Any]) -> bool: - """Visit a dependency while the graph remains statically discoverable. - - Args: - dependency: Provider whose static dependencies should be inspected. - - Returns: - Whether the dependency and all of its descendants use the default - resolution-context hooks. - - """ - if dependency in visited: - return True - visited.add(dependency) - - if ( - type(dependency).resolution_context is not AbstractProvider.resolution_context - or type(dependency).resolution_context_sync is not AbstractProvider.resolution_context_sync - ): - return False - if not all(_visit(parent) for parent in dependency.get_resolution_dependencies()): - return False - if isinstance(dependency, ContextResource): - resources.append(dependency) - return True - - return tuple(resources) if _visit(provider) else _RUNTIME_CONTEXT_RESOURCES + provider_order = provider._get_scope_init_order() # noqa: SLF001 + if any(isinstance(dependency, ProviderWithResolutionContext) for dependency in provider_order): + return _RUNTIME_CONTEXT_TRAVERSAL_REQUIRED + return tuple(dependency for dependency in provider_order if isinstance(dependency, ContextResource)) @functools.cache @@ -368,7 +342,14 @@ async def _resolve_arguments_async( context_resources = direct_parameter.context_resources if context_resources: - if context_resources is _RUNTIME_CONTEXT_RESOURCES: + if isinstance(context_resources, tuple): + await _prepare_static_context_resources_async( + context_resources, + scope, + stack, + context_providers, + ) + else: kwargs[direct_parameter.field_name] = await _resolve_provider_with_scope_async( direct_parameter.provider, scope, @@ -376,12 +357,6 @@ async def _resolve_arguments_async( context_providers, ) continue - await _prepare_static_context_resources_async( - typing.cast(tuple[ContextResource[typing.Any], ...], context_resources), - scope, - stack, - context_providers, - ) kwargs[direct_parameter.field_name] = await direct_parameter.provider.resolve() for string_parameter in plan.string_parameters: @@ -428,7 +403,14 @@ def _resolve_arguments_sync( context_resources = direct_parameter.context_resources if context_resources: - if context_resources is _RUNTIME_CONTEXT_RESOURCES: + if isinstance(context_resources, tuple): + _prepare_static_context_resources_sync( + context_resources, + scope, + stack, + context_providers, + ) + else: kwargs[direct_parameter.field_name] = _resolve_provider_with_scope_sync( direct_parameter.provider, scope, @@ -436,12 +418,6 @@ def _resolve_arguments_sync( context_providers, ) continue - _prepare_static_context_resources_sync( - typing.cast(tuple[ContextResource[typing.Any], ...], context_resources), - scope, - stack, - context_providers, - ) kwargs[direct_parameter.field_name] = direct_parameter.provider.resolve_sync() for string_parameter in plan.string_parameters: @@ -552,14 +528,10 @@ async def _resolve_provider_with_scope_async( """ static_resources = _get_static_context_resources(provider) - if static_resources is not _RUNTIME_CONTEXT_RESOURCES: - if static_resources: - await _prepare_static_context_resources_async( - typing.cast(tuple[ContextResource[typing.Any], ...], static_resources), - scope, - stack, - providers, - ) + if not static_resources: + return await provider.resolve() + if isinstance(static_resources, tuple): + await _prepare_static_context_resources_async(static_resources, scope, stack, providers) return await provider.resolve() async with AsyncExitStack() as resolution_stack: @@ -628,43 +600,36 @@ async def _prepare_provider_contexts_async( but no resource stack was supplied. """ - if provider in visits.traversed: - return - visits.traversed.add(provider) - - for dependency in provider.get_resolution_dependencies(): - await _prepare_provider_contexts_async( - dependency, - scope, - resource_stack, - resolution_stack, - visits, - ) + for dependency in provider._get_scope_init_order(): # noqa: SLF001 + if dependency in visits.traversed: + continue + visits.traversed.add(dependency) - runtime_dependencies = await resolution_stack.enter_async_context(provider.resolution_context()) - for dependency in runtime_dependencies: - await _prepare_provider_contexts_async( - dependency, - scope, - resource_stack, - resolution_stack, - visits, - ) + if isinstance(dependency, ProviderWithResolutionContext): + runtime_dependencies = await resolution_stack.enter_async_context(dependency.resolution_context()) + for runtime_dependency in runtime_dependencies: + await _prepare_provider_contexts_async( + runtime_dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) - if ( - scope is not None - and isinstance(provider, ContextResource) - and provider.get_scope() in (ContextScopes.ANY, scope) - and provider not in visits.initialized_contexts - ): - if resource_stack is None: - msg = ( - f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" - f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." - ) - raise ContextProviderError(msg) - visits.initialized_contexts.add(provider) - await resource_stack.enter_async_context(provider.context_async(force=True)) + if ( + scope is not None + and isinstance(dependency, ContextResource) + and dependency.get_scope() in (ContextScopes.ANY, scope) + and dependency not in visits.initialized_contexts + ): + if resource_stack is None: + msg = ( + f"No stack exists, cannot initialize context for {dependency} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + visits.initialized_contexts.add(dependency) + await resource_stack.enter_async_context(dependency.context_async(force=True)) def _resolve_provider_with_scope_sync( @@ -695,14 +660,10 @@ def _resolve_provider_with_scope_sync( """ static_resources = _get_static_context_resources(provider) - if static_resources is not _RUNTIME_CONTEXT_RESOURCES: - if static_resources: - _prepare_static_context_resources_sync( - typing.cast(tuple[ContextResource[typing.Any], ...], static_resources), - scope, - stack, - providers, - ) + if not static_resources: + return provider.resolve_sync() + if isinstance(static_resources, tuple): + _prepare_static_context_resources_sync(static_resources, scope, stack, providers) return provider.resolve_sync() with ExitStack() as resolution_stack: @@ -772,43 +733,36 @@ def _prepare_provider_contexts_sync( but no resource stack was supplied. """ - if provider in visits.traversed: - return - visits.traversed.add(provider) - - for dependency in provider.get_resolution_dependencies(): - _prepare_provider_contexts_sync( - dependency, - scope, - resource_stack, - resolution_stack, - visits, - ) + for dependency in provider._get_scope_init_order(): # noqa: SLF001 + if dependency in visits.traversed: + continue + visits.traversed.add(dependency) - runtime_dependencies = resolution_stack.enter_context(provider.resolution_context_sync()) - for dependency in runtime_dependencies: - _prepare_provider_contexts_sync( - dependency, - scope, - resource_stack, - resolution_stack, - visits, - ) + if isinstance(dependency, ProviderWithResolutionContext): + runtime_dependencies = resolution_stack.enter_context(dependency.resolution_context_sync()) + for runtime_dependency in runtime_dependencies: + _prepare_provider_contexts_sync( + runtime_dependency, + scope, + resource_stack, + resolution_stack, + visits, + ) - if ( - scope is not None - and isinstance(provider, ContextResource) - and provider.get_scope() in (ContextScopes.ANY, scope) - and provider not in visits.initialized_contexts - ): - if resource_stack is None: - msg = ( - f"No stack exists, cannot initialize context for {provider} using scope {scope}.\n" - f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." - ) - raise ContextProviderError(msg) - visits.initialized_contexts.add(provider) - resource_stack.enter_context(provider.context_sync(force=True)) + if ( + scope is not None + and isinstance(dependency, ContextResource) + and dependency.get_scope() in (ContextScopes.ANY, scope) + and dependency not in visits.initialized_contexts + ): + if resource_stack is None: + msg = ( + f"No stack exists, cannot initialize context for {dependency} using scope {scope}.\n" + f"Note: @inject cannot initialize context for ContextResources when wrapping a generator." + ) + raise ContextProviderError(msg) + visits.initialized_contexts.add(dependency) + resource_stack.enter_context(dependency.context_sync(force=True)) class StringProviderDefinition: diff --git a/that_depends/providers/__init__.py b/that_depends/providers/__init__.py index b45b1054..13b55a07 100644 --- a/that_depends/providers/__init__.py +++ b/that_depends/providers/__init__.py @@ -9,6 +9,7 @@ ) from that_depends.providers.factories import AsyncFactory, Factory from that_depends.providers.local_singleton import ThreadLocalSingleton +from that_depends.providers.mixin import ProviderWithArguments, ProviderWithResolutionContext from that_depends.providers.object import Object from that_depends.providers.resources import Resource from that_depends.providers.selector import Selector @@ -27,6 +28,8 @@ "Factory", "List", "Object", + "ProviderWithArguments", + "ProviderWithResolutionContext", "Resource", "Selector", "Singleton", diff --git a/that_depends/providers/base.py b/that_depends/providers/base.py index 6da7e2b6..3a526b6c 100644 --- a/that_depends/providers/base.py +++ b/that_depends/providers/base.py @@ -94,7 +94,6 @@ def __init__(self) -> None: super().__init__() self._children: set[AbstractProvider[typing.Any]] = set() self._parents: set[AbstractProvider[typing.Any]] = set() - self._is_context_resource = False self._scope_init_order: tuple[AbstractProvider[typing.Any], ...] | None = None self._override: typing.Any = UNSET self._bindings: set[type] = set() @@ -238,58 +237,6 @@ def __getattr__(self, attr_name: str) -> typing.Any: # noqa: ANN401 raise AttributeError(msg) return AttrGetter(provider=self, attr_name=attr_name) - def get_resolution_dependencies(self) -> typing.Collection["AbstractProvider[typing.Any]"]: - """Return providers that must be prepared before resolving this provider. - - Injection evaluates these static dependencies before entering this provider's - resolution context. Providers with arguments are registered lazily, and the - returned collection is an immutable snapshot with no ordering guarantee. - - Dynamic providers can expose dependencies selected at runtime from - :meth:`resolution_context` and :meth:`resolution_context_sync` instead. - - Returns: - An immutable snapshot of the provider's static dependencies. - - """ - if isinstance(self, ProviderWithArguments): - self._register_arguments() - return frozenset(self._parents) - - @asynccontextmanager - async def resolution_context( - self, - ) -> typing.AsyncIterator[typing.Collection["AbstractProvider[typing.Any]"]]: - """Yield dependencies known only while resolving this provider asynchronously. - - Injection prepares the yielded providers and keeps this context active until - the root provider has resolved. The default implementation has no runtime - dependencies. Custom providers should yield a read-only collection and must - not rely on its iteration order. - - Yields: - The dependencies discovered for the current asynchronous resolution. - - """ - yield () - - @contextmanager - def resolution_context_sync( - self, - ) -> typing.Iterator[typing.Collection["AbstractProvider[typing.Any]"]]: - """Yield dependencies known only while resolving this provider synchronously. - - Injection prepares the yielded providers and keeps this context active until - the root provider has resolved. The default implementation has no runtime - dependencies. Custom providers should yield a read-only collection and must - not rely on its iteration order. - - Yields: - The dependencies discovered for the current synchronous resolution. - - """ - yield () - @abc.abstractmethod async def resolve(self) -> T_co: """Resolve dependency asynchronously.""" diff --git a/that_depends/providers/context_resources.py b/that_depends/providers/context_resources.py index aef60913..177e7774 100644 --- a/that_depends/providers/context_resources.py +++ b/that_depends/providers/context_resources.py @@ -334,7 +334,6 @@ def __init__( """ super().__init__(creator, *args, **kwargs) self._from_creator: typing.Callable[..., typing.Iterator[T_co] | typing.AsyncIterator[T_co]] = creator - self._is_context_resource = True self._context: ContextVar[ResourceContext[T_co]] = ContextVar(f"{self._creator.__name__}-context") self._token: Token[ResourceContext[T_co]] | None = None self._async_lock: Final = asyncio.Lock() diff --git a/that_depends/providers/mixin.py b/that_depends/providers/mixin.py index 99597af0..b2b82f85 100644 --- a/that_depends/providers/mixin.py +++ b/that_depends/providers/mixin.py @@ -1,4 +1,10 @@ import abc +import contextlib +import typing + + +if typing.TYPE_CHECKING: + from that_depends.providers.base import AbstractProvider class CannotTearDownSyncError(RuntimeError): @@ -49,3 +55,19 @@ def _register_arguments(self) -> None: @abc.abstractmethod def _deregister_arguments(self) -> None: """Deregister arguments for the provider.""" + + +class ProviderWithResolutionContext(abc.ABC): + """Interface for providers with dependencies chosen at resolution time.""" + + @abc.abstractmethod + def resolution_context( + self, + ) -> contextlib.AbstractAsyncContextManager[typing.Collection["AbstractProvider[typing.Any]"]]: + """Expose dependencies for one asynchronous root resolution.""" + + @abc.abstractmethod + def resolution_context_sync( + self, + ) -> contextlib.AbstractContextManager[typing.Collection["AbstractProvider[typing.Any]"]]: + """Expose dependencies for one synchronous root resolution.""" diff --git a/that_depends/providers/selector.py b/that_depends/providers/selector.py index 5e5277ad..68363857 100644 --- a/that_depends/providers/selector.py +++ b/that_depends/providers/selector.py @@ -7,14 +7,14 @@ from typing_extensions import override from that_depends.providers.base import AbstractProvider -from that_depends.providers.mixin import ProviderWithArguments +from that_depends.providers.mixin import ProviderWithArguments, ProviderWithResolutionContext from that_depends.utils import UNSET, Unset, is_set T_co = typing.TypeVar("T_co", covariant=True) -class Selector(ProviderWithArguments, AbstractProvider[T_co]): +class Selector(ProviderWithArguments, ProviderWithResolutionContext, AbstractProvider[T_co]): """Chooses a provider based on a key returned by a selector function. This class allows you to dynamically select and resolve one of several @@ -83,9 +83,8 @@ def _register_arguments(self) -> None: Registration is idempotent because providers attach their arguments lazily when the dependency graph is first inspected. """ - if not self._mark_arguments_registered(): - return - self._register((self._selector,)) + if self._mark_arguments_registered(): + self._register((self._selector,)) def _deregister_arguments(self) -> None: """Detach the provider-valued selector from this provider's dependency graph."""