Search code examples
pythondecoratorpython-decorators

Decorator returns function object instead of output of wrapped function


Apologies, I can only imagine that StackOverflow is full of people who are almost there, but still don't quite grasp decorators.

I am trying to decorate a series of os-related functions so that if there are any exceptions such as FileNotFoundError or PermissionError, the user can fix the problem on their side and try again.

So I've created this toy function and decorator, and I don't understand where I'm not following properly the example decorators I've been reading, and I'm having trouble reasoning my way through it:

from functools import wraps

def continual_retry(func):
    def retry_decorated(*args, **kwargs):
        @wraps(func)
        def func_wrapper(*args, **kwargs):
            while not stop:
                try:
                    func(*args)
                    stop = True
                except Exception as e:
                    print(f'Could not perform function {func.__name__}')
                    print(f' with args {repr(args)}')
                    print(f' due to error {e.class__}')    
                    redo = input('Retry (y/n)? ')      
                    if redo.lower() != 'y':
                        print('Exiting program due to error and user input')
                        sys.exit(0)
        return func_wrapper
    return retry_decorated

@continual_retry
def divide(a, b):
    return a/b

When I run the function divide, this is the result:

>>> divide(1, 2)
<function __main__.divide(a, b)>

Where I was expecting a result of

0.5

(Then I was going to test divide(1, 0))


Solution

  • Your decorator is a decorator factory that returns another decorator. You don't need a factory here, remove one layer:

    def continual_retry(func):
        @wraps(func)
        def func_wrapper(*args, **kwargs):
            while True:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f'Could not perform function {func.__name__}')
                    print(f' with args {repr(args)}')
                    print(f' due to error {e.class__}')    
                    redo = input('Retry (y/n)? ')      
                    if redo.lower() != 'y':
                        print('Exiting program due to error and user input')
                        sys.exit(0)
        return func_wrapper
    

    You also need to return the function result, and I changed the while loop to an endless one with while True:, as a successful return will exit the loop. I also updated the call to func() to pass along keyword arguments (return func(*args, **kwargs)).

    When Python encounters @continual_retry, it passes in the function object to the continual_retry() callable to replace the function with the result, as if you expected divide = continual_retry(divide), but in your version continual_retry(divide) returns the retry_decorated() function, which itself, when called, finally returns the func_wrapper object. You want func_wrapper to be used to be the replacement.

    Your double-layer approach is great when you want to configure a decorator, where the outer decorator factory function accepts arguments other than the function. The goal is to then use that as @continual_retry(config_arg1, config_arg2, ...) so that Python first calls that function to get a return value, and the decoration then happens by calling that return value.

    You could, for example, add an option to limit the number of retries:

    def continual_retry(limit=None):
        def retry_decorator(func):
            @wraps(func)
            def func_wrapper(*args, **kwargs):
                retries = 0
                while True
                    try:
                        return func(*args, **kwargs)
                    except Exception as e:
                        print(f'Could not perform function {func.__name__}')
                        print(f' with args {repr(args)}')
                        print(f' due to error {e.class__}')    
                        redo = input('Retry (y/n)? ')      
                        if redo.lower() != 'y':
                            print('Exiting program due to error and user input')
                            sys.exit(0)
                        retries += 1
                        if limit is not None and retries > limit:
                            # reached the limit, re-raise the exception
                            raise
            return func_wrapper
        return retry_decorator
    

    Now you must use @continual_retry() or @continual_retry(<integer>) when decorating, e.g.:

    @continual_retry(3)
    def divide(a, b):
        return a / b
    

    because it is continual_retry() that produces the decorator, and continual_retry(3)(divide) produces the wrapper that replaces the original function.