ets-labs / python-dependency-injector

Dependency injection framework for Python
https://python-dependency-injector.ets-labs.org/
BSD 3-Clause "New" or "Revised" License
3.89k stars 304 forks source link

Injection on a decorator #454

Open platipo opened 3 years ago

platipo commented 3 years ago

Hi, I really like this package and am using it in production. I was tying to mess around with decorators because I want to add side effects to a function, adding a secret_number in the example. I was expecting decorated_function_1 to work but it didn't and I can't wrap my head around it. Is this an expected behavior? Could injection be supported like in my_decorator_1?

I also added few examples of things I tried and only decorated_function_4 actually works.

from functools import wraps

from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()

@inject
def my_decorator_1(func, secret_number: int = Provide[Container.config.secret_number]):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result + secret_number
    return wrapper

@inject
def my_decorator_2(secret_number: int = Provide[Container.config.secret_number]):
    def inner_decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + secret_number
        return wrapper
    return inner_decorator

def my_decorator_3():
    @inject
    def inner_decorator(func, secret_number: int = Provide[Container.config.secret_number]):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + secret_number
        return wrapper
    return inner_decorator

def my_decorator_4(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        secret_number = kwargs['secret_number']
        return result + secret_number
    return wrapper

@my_decorator_1
def decorated_function_1():
    return 42

@inject
@my_decorator_1
def decorated_function_1a():
    return 42

@my_decorator_2()
def decorated_function_2():
    return 42

@my_decorator_3()
def decorated_function_3():
    return 42

@inject
@my_decorator_3()
def decorated_function_3a():
    return 42

@inject
@my_decorator_4
def decorated_function_4(secret_number: int = Provide[Container.config.secret_number]):
    return 42

@my_decorator_4
@inject
def decorated_function_4a(secret_number: int = Provide[Container.config.secret_number]):
    return 42

def main():
    test_funcs = [
        decorated_function_1,
        decorated_function_1a,
        decorated_function_2, 
        decorated_function_3, 
        decorated_function_3a,
        decorated_function_4, 
        decorated_function_4a, 
    ]
    for test_f in test_funcs:
        try:
            result = test_f()
            print(f"Function {test_f} returned {result}")
        except Exception as exc:
            print(f"Function {test_f} raised {exc.__class__.__name__} '{exc}'")

if __name__ == '__main__':
    import sys

    container = Container()
    container.init_resources()
    container.config.secret_number.from_env("SECRET_INT", 24)
    container.wire(modules=[sys.modules[__name__]])

    main()

The output is:

Function <function decorated_function_1 at 0x7f93d8a4d310> raised TypeError 'unsupported operand type(s) for +: 'int' and 'Provide''
Function <function decorated_function_1a at 0x7f93d8a4d4c0> raised TypeError 'unsupported operand type(s) for +: 'int' and 'Provide''
Function <function decorated_function_2 at 0x7f93d8a4d670> raised TypeError 'unsupported operand type(s) for +: 'int' and 'Provide''
Function <function decorated_function_3 at 0x7f93d8a4d820> raised TypeError 'unsupported operand type(s) for +: 'int' and 'Provide''
Function <function decorated_function_3a at 0x7f93d8a4daf0> raised TypeError 'unsupported operand type(s) for +: 'int' and 'Provide''
Function <function decorated_function_4 at 0x7f93d8a4dca0> returned 66
Function <function decorated_function_4a at 0x7f93d8a4de50> raised KeyError ''secret_number''
MatthieuMoreau0 commented 3 years ago

Hi, I noticed the same issue on my project. The container doesn't inject properly in the arguments of my decorator function.

Thanks for your experimentations, I'll use the workaround presented in your my_decorator_4 for now.

Are there any plans to fix this issue ? I'm wondering why this even occurs specifically on decorator among all functions..

rmk135 commented 3 years ago

Hey @platipo and @MatthieuMoreau0 ,

Found a solution after some debugging:

def my_decorator_1(func):
    @wraps(func)
    @inject
    def wrapper(
            *args,
            secret_number: int = Provide[Container.config.secret_number],
            **kwargs,
    ):
        result = func(*args, **kwargs)
        return result + secret_number
    return wrapper

def my_decorator_2():
    def inner_decorator(func):
        @wraps(func)
        @inject
        def wrapper(
                *args,
                secret_number: int = Provide[Container.config.secret_number],
                **kwargs,
        ):
            result = func(*args, **kwargs)
            return result + secret_number
        return wrapper
    return inner_decorator

def my_decorator_3():
    def inner_decorator(func, ):
        @wraps(func)
        @inject
        def wrapper(
                *args,
                secret_number: int = Provide[Container.config.secret_number],
                **kwargs,
        ):
            result = func(*args, **kwargs)
            return result + secret_number
        return wrapper
    return inner_decorator

def my_decorator_4(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        secret_number = kwargs['secret_number']
        return result + secret_number
    return wrapper

This way it produces expected output for all functions (except decorated_function_4a that seems to be not working anyway):

(venv) ➜  issue454 git:(develop) ✗ python example.py
Function <function decorated_function_1 at 0x106518d30> returned 66
Function <function decorated_function_1a at 0x106518f70> returned 66
Function <function decorated_function_2 at 0x10651e1f0> returned 66
Function <function decorated_function_3 at 0x10651e430> returned 66
Function <function decorated_function_3a at 0x10651e040> returned 66
Function <function decorated_function_4 at 0x10651e790> returned 66
Function <function decorated_function_4a at 0x10651e940> raised KeyError ''secret_number''

To make injections work with decorators you need to inject dependencies into a decorating closure, but not a decorator itself. Otherwise the actual call of the decorator happens before the config value is defined.

PS: My apologies it took that long to answer.

rmk135 commented 3 years ago

Closing this issue for now. Please re-open or comment if needed.

fabiocerqueira commented 2 years ago
from functools import wraps

from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject

class Reporter:
    def info(self, msg):
        print(f"*** [INFO] {msg}")

class Container(containers.DeclarativeContainer):
    conf = providers.Configuration()
    reporter = providers.Singleton(Reporter)

def tagger(tag):
    def inner(func):
        # @wraps(func)  # <-- using wraps break inject
        @inject
        def tagger_wrapper(text, sep=Provide[Container.conf.sep], **kwargs):
            result = func(text, **kwargs)
            return f"{result}{sep}({tag}[{func.__name__}])"

        return tagger_wrapper

    return inner

def logger(prefix):
    def inner(func):
        # @wraps(func)  # <-- using wraps break inject
        @inject
        def logger_wrapper(text, reporter=Provide[Container.reporter], **kwargs):
            result = func(text, **kwargs)
            reporter.info(f"{prefix} - {func.__name__}('{text}') was called")
            return result

        return logger_wrapper

    return inner

def helper(tag, prefix):
    def inner(func):
        @tagger(tag)
        @wraps(func)  # <-- if remove this double wraps it breaks the function name
        @logger(prefix)
        @inject
        @wraps(func)
        def helper_wrapper(text, **kwargs):
            result = func(text, **kwargs)
            return result

        return helper_wrapper

    return inner

@helper("my_tag", "|my_prefix|")
def my_example(text):
    return text

if __name__ == "__main__":
    container = Container()
    container.wire(modules=[__name__])
    container.conf.from_dict({"sep": " / "})
    print(my_example("hi!"))

Running the example above we get the expected behaviour

*** [INFO] |my_prefix| - my_example('hi!') was called
hi! / (my_tag[my_example])

If you try to remove this double @wraps call in helper the function name breaks:

*** [INFO] |my_prefix| - my_example('hi!') was called
hi! / (my_tag[logger_wrapper])
              ^^^^^^^^^^^^^^^^

if I move the @wraps to the decorator(this I think should be the correct way) it breaks @inject

Traceback (most recent call last):
  File "example.py", line 69, in <module>
    print(my_example("hi!"))
  File "/home/fabio/.pyenv/versions/.../dependency_injector/wiring.py", line 612, in _patched
    result = fn(*args, **to_inject)
  File "example.py", line 22, in tagger_wrapper
    result = func(text, **kwargs)
  File "/home/fabio/.pyenv/versions/.../dependency_injector/wiring.py", line 612, in _patched
    result = fn(*args, **to_inject)
  File "example.py", line 36, in logger_wrapper
    reporter.info(f"{prefix} - {func.__name__}('{text}') was called")
AttributeError: 'Provide' object has no attribute 'info'
Jitesh-Khuttan commented 2 years ago

@fabiocerqueira I faced the same issue as well, adding wraps is breaking the inject.

@rmk135 could you please have a look?

My use case is similar to what Fabio mentioned:

def tagger(tag):
    def inner(func):
        # @wraps(func)  # <-- using wraps break inject
        @inject
        def tagger_wrapper(text, sep=Provide[Container.conf.sep], **kwargs):
            result = func(text, **kwargs)
            return f"{result}{sep}({tag}[{func.__name__}])"

        return tagger_wrapper

    return inner
rmk135 commented 2 years ago

Hey @Jitesh-Khuttan @fabiocerqueira ,

First, thanks a lot for finding the issue and providing steps to reproduce it. It's uneasy to catch. After 3 rounds of debugging, I've localized the root cause. The problem is that the second @wraps(func) where func is already @inject-patched function, overrides previously parsed injections.

There is no easy fix, but I have one refactoring in mind that theoretically could help. I'll proceed with it and get back when I have any news.

rmk135 commented 2 years ago

Linking this with #597

rmk135 commented 2 years ago

I have the fix! Working on merging it to develop.

rmk135 commented 2 years ago

Merged the fix to develop. Added some docs on the @inject decorator. Will be releasing to PyPI by the end of the week.

ganggas95 commented 1 year ago

What next of this discussion? Are this issue has been fixed? I'm still faced the issue. using version 4.41.0.

File "src/dependency_injector/_cwiring.pyx", line 28, in dependency_injector._cwiring._get_sync_patched._patched
  File "/Users/nizar/ProjectBima/simpeg_bima/simpeg/internal/decorators.py", line 147, in wrapped
    if application_service.get_by_app_key(app_key):
AttributeError: 'Provide' object has no attribute 'get_by_app_key'

And this is my decorators:


def jwt_or_app_key_required(optional=False,
                            fresh=False,
                            refresh=False,
                            locations=None,):

    def wrapper(f):
        @wraps(f)
        @inject
        @wraps(f)
        def wrapped(*args,
                    application_service: ApplicationService = Provide[
                        DependencyContainer.application_service
                    ], **kwargs):
            app_key = request.args.get('app_key', default=None, type=str)
            if application_service.get_by_app_key(app_key):
                return f(*args, **kwargs)
            try:
                verify_jwt_in_request(optional, fresh, refresh, locations)
            except BaseException:
                return create_response(403, msg="Forbidden access")
            return f(*args, **kwargs)
        return wrapped
    return wrapper
shaariqch-carma commented 7 months ago

Bumping this, also coming across this issue. Not ideal at all since the docs claim that this is supposed to work.