From 361f7bc02ddb2c7355ba70e3e4df958c601f8ccd Mon Sep 17 00:00:00 2001 From: Edson Henrique Date: Fri, 23 Oct 2026 20:15:00 -0400 Subject: [PATCH] Skip type-hint resolution for noninjectable parameters --- CHANGES | 1 + injector/__init__.py | 14 +++++--- injector_test.py | 77 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index 3794d42..02167fd 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,7 @@ Injector Change Log - Dropped support for Python 3.8 and 3.9. Python 3.10+ is now required. - Use the SPDX license identifier `BSD-3-Clause` in package metadata. +- Skip annotation resolution for parameters excluded with ``@noninjectable``. 0.24.0 ------ diff --git a/injector/__init__.py b/injector/__init__.py index 402c47c..cf8e81b 100644 --- a/injector/__init__.py +++ b/injector/__init__.py @@ -1232,7 +1232,7 @@ def get_bindings(callable: Callable) -> Dict[str, type]: """ look_for_explicit_bindings = False if not hasattr(callable, '__bindings__'): - type_hints = get_type_hints(callable, include_extras=True) + type_hints = get_type_hints(cast(Callable, _NoReturnAnnotationProxy(callable)), include_extras=True) has_injectable_parameters = any( _is_specialization(v, Annotated) and _inject_marker in getattr(v, "__metadata__", ()) for v in type_hints.values() @@ -1268,7 +1268,12 @@ def __getattribute__(self, name: str) -> Any: callable = object.__getattribute__(self, 'callable') if name == '__annotations__': annotations = callable.__annotations__ - return {name: value for (name, value) in annotations.items() if name != 'return'} + noninjectables: Set[str] = getattr(callable, '__noninjectables__', set()) + return { + name: value + for (name, value) in annotations.items() + if name != 'return' and name not in noninjectables + } return getattr(callable, name) @@ -1297,9 +1302,8 @@ def _recreate_annotated_origin(annotated_type: Any) -> Any: try: # Return types don't matter for the purpose of dependency injection so instead of # obtaining type hints of the callable directly let's wrap it in _NoReturnAnnotationProxy. - # The proxy removes the return type annotation (if present) from the annotations so that - # get_type_hints() works even if the return type is a forward reference that can't be - # resolved. + # The proxy removes the return type and explicitly noninjectable parameters so that + # get_type_hints() does not try to resolve forward references we will never inject. bindings = get_type_hints(cast(Callable, _NoReturnAnnotationProxy(callable)), include_extras=True) except NameError as e: raise _BindingNotYetAvailable(e) diff --git a/injector_test.py b/injector_test.py index 917f34e..eca2ee8 100644 --- a/injector_test.py +++ b/injector_test.py @@ -1892,6 +1892,83 @@ def function2(a: int, b: str) -> None: assert get_bindings(function1) == {'a': int} == get_bindings(function2) +@pytest.mark.parametrize('inject_first', [False, True]) +@pytest.mark.parametrize('body_type', ['UnresolvableBody', dict[str, Optional['RecursiveDict']]]) +def test_noninjectable_annotations_need_not_resolve(inject_first, body_type) -> None: + def function(value: int, body: body_type): + return value, body + + if inject_first: + function = noninjectable('body')(inject(function)) + else: + function = inject(noninjectable('body')(function)) + + injector = Injector() + injector.binder.bind(int, to=42) + body = {'value': None} + + assert get_bindings(function) == {'value': int} + assert injector.call_with_injection(function, kwargs={'body': body}) == (42, body) + with pytest.raises(CallError): + injector.call_with_injection(function) + + +def test_noninjectable_keyword_only_annotations_need_not_resolve() -> None: + @inject + @noninjectable('first') + @noninjectable('second') + def function(value: int, *, first: 'UnresolvableFirst', second: 'UnresolvableSecond'): + return value, first, second + + assert get_bindings(function) == {'value': int} + assert Injector().call_with_injection(function, kwargs={'first': 'a', 'second': 'b'}) == (0, 'a', 'b') + + +def test_noninjectable_constructor_annotations_need_not_resolve() -> None: + class Service: + @inject + @noninjectable('body') + def __init__(self, value: int, body: 'UnresolvableBody'): + self.value = value + self.body = body + + service = Injector().create_object(Service, additional_kwargs={'body': 'provided'}) + assert service.value == 0 + assert service.body == 'provided' + + +def test_noninjectable_does_not_hide_unresolved_injected_annotations() -> None: + from injector import _BindingNotYetAvailable + + @inject + @noninjectable('body') + def function(value: 'UnresolvableDependency', body: 'UnresolvableBody'): + pass + + with pytest.raises(_BindingNotYetAvailable, match='UnresolvableDependency'): + get_bindings(function) + + +def test_noninjectable_annotations_need_not_resolve_with_explicit_inject() -> None: + @noninjectable('body') + def function(value: Inject[int], body: 'UnresolvableBody'): + return value, body + + injector = Injector() + injector.binder.bind(int, to=42) + assert get_bindings(function) == {'value': int} + assert injector.call_with_injection(function, kwargs={'body': 'provided'}) == (42, 'provided') + + +def test_noninjectable_annotations_need_not_resolve_without_injected_parameters() -> None: + @noninjectable('body') + def function(body: 'UnresolvableBody'): + return body + + assert get_bindings(function) == {} + assert Injector().call_with_injection(function, kwargs={'body': 'provided'}) == 'provided' + + def test_gets_bindings_with_inject_annotation() -> None: def function(a: Inject[int], b: str) -> None: pass