python-injector / injector

Python dependency injection framework, inspired by Guice
BSD 3-Clause "New" or "Revised" License
1.29k stars 81 forks source link

Nonresolvable Type Hint on Non-Injected Argument Causes Error #258

Open macdjord opened 2 months ago

macdjord commented 2 months ago

Steps To Reproduce

test.py:

#!/usr/bin/env python3
import injector as _injector
import test2

@_injector.inject
@_injector.noninjectable('body')
def patch(body: test2.RecursiveDict) -> None:
    ...

injector = _injector.Injector()
injector.call_with_injection(patch, kwargs={"body": {}})

test2.py:

#!/usr/bin/env python3
import typing as _tp
RecursiveDict: _tp.TypeAlias = dict[str, _tp.Optional["RecursiveDict"]]

Run test.py

Expected Results

Call proceeds without error

Actual Results

Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/injector/__init__.py", line 1213, in _infer_injected_bindings
    bindings = get_type_hints(cast(Callable, _NoReturnAnnotationProxy(callable)), include_extras=True)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 2342, in get_type_hints
    hints[name] = _eval_type(value, globalns, localns)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 373, in _eval_type
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 373, in <genexpr>
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 373, in _eval_type
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 373, in <genexpr>
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 359, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/typing.py", line 857, in _evaluate
    eval(self.__forward_code__, globalns, localns),
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
NameError: name 'RecursiveDict' is not defined

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/src/app/test.py", line 11, in <module>
    injector.call_with_injection(patch, kwargs={"body": {}})
  File "/usr/local/lib/python3.11/site-packages/injector/__init__.py", line 1020, in call_with_injection
    bindings = get_bindings(callable)
               ^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/injector/__init__.py", line 1173, in get_bindings
    callable, _infer_injected_bindings(callable, only_explicit_bindings=look_for_explicit_bindings)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/injector/__init__.py", line 1215, in _infer_injected_bindings
    raise _BindingNotYetAvailable(e)
injector._BindingNotYetAvailable: name 'RecursiveDict' is not defined

Notes

The error raised in get_type_hints() is not Injector's fault; that is a bug in Python itself. However, the fact that Injector fails due to an issue with the type hint of a parameter I explicitly told it not to even try to inject is an Injector issue.

davidparsson commented 2 months ago

Does it solve your issue if you use body: NoInject[test2.RecursiveDict]? This is now the recommended way to mark arguments not to be injected.

Does it also work if you flip the order of your decorators? Currently the inject decorator is executed first, so it has to try to interact everything.

macdjord commented 2 months ago

@davidparsson: No, using NoInject[] does not help.

Does it also work if you flip the order of your decorators? Currently the inject decorator is executed first, so it has to try to interact everything.

You are incorrect. Python decorators are applied bottom-to-top, so @inject is applied after noninjectable() in the example.

The way Python decorators work is that this:

@foo_decorator
def bar():
    ...

Is a syntactic short for for this:

def bar():
    ...
bar = foo_decorator(bar)

Thus:

@_injector.inject
@_injector.noninjectable('body')
def patch(body: test2.RecursiveDict) -> None:
    ...

Becomes:

@_injector.noninjectable('body')
def patch(body: test2.RecursiveDict) -> None:
    ...
patch = _injector.inject(patch)

Becomes:

def patch(body: test2.RecursiveDict) -> None:
    ...
patch = _injector.noninjectable('body')(patch)
patch = _injector.inject(patch)
davidparsson commented 2 months ago

You're right, my bad!