From 98c91ff671581725c849c38343ebe0c13fc75cf3 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jul 2026 00:38:12 +0200 Subject: [PATCH 1/3] 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 2/3] 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 0d70ccdfc08151ccacc4d30eb906f827dfdb519b Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jul 2026 00:41:35 +0200 Subject: [PATCH 3/3] fix: infer direct Provide value types --- tests/test_typing.py | 15 +++++++++++++++ that_depends/injection.py | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/test_typing.py diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 00000000..01e0e96d --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,15 @@ +import typing + +from typing_extensions import assert_type + +from that_depends import Provide, providers +from that_depends.experimental.providers import LazyProvider + + +int_provider = providers.Object(1) +lazy_provider: LazyProvider[str] = LazyProvider("tests.container.DIContainer.simple_factory") + + +assert_type(Provide[int_provider], int) +assert_type(Provide[lazy_provider], str) +assert_type(Provide["Container.provider"], typing.Any) diff --git a/that_depends/injection.py b/that_depends/injection.py index bb8c1965..6045a28a 100644 --- a/that_depends/injection.py +++ b/that_depends/injection.py @@ -574,10 +574,16 @@ def provider(self) -> AbstractProvider[typing.Any]: class _Provide: - def __getitem__(self, provider: AbstractProvider[T] | str) -> T | typing.Any: # noqa: ANN401 + @typing.overload + def __getitem__(self, provider: AbstractProvider[T]) -> T: ... + + @typing.overload + def __getitem__(self, provider: str) -> typing.Any: ... # noqa: ANN401 + + def __getitem__(self, provider: AbstractProvider[T] | str) -> typing.Any: if isinstance(provider, str): return StringProviderDefinition(provider) # will be resolved later - return typing.cast(T, provider) + return provider def __call__(self) -> typing.Any: # noqa: ANN401 """Marker for automatic dependency injection."""