Search code examples
pythonpython-3.xpython-decoratorsdefault-parameters

Python3 'repeat' decorator with argument: @repeat(n)


I have seen (a great) many tutorials and snippets of decorators w/ and w/o arguments, including those two I would look consider as canonical answers: Decorators with arguments, python decorator arguments with @ syntax, but I don't see why I get an error in my code.

The code below lives in the file decorators.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Description: decorators
"""
import functools

def repeat(nbrTimes=2):
    '''
    Define parametrized decorator with arguments
    Default nbr of repeats is 2
    '''
    def real_repeat(func):
        """
        Repeats execution 'nbrTimes' times
        """
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            while nbrTimes != 0:
                nbrTimes -= 1
                return func(*args, **kwargs)
        return wrapper_repeat
    return real_repeat

The first warning I get from my syntax-checker is that nbrTimes is an "unused argument".

I tested the above in python3 interactive console with:

>>> from decorators import repeat

>>> @repeat(nbrTimes=3)
>>> def greetings():
>>>     print("Howdy")
>>>
>>> greetings()
Traceback (most recent call last):
  File "<stdin>", line 1 in <module>
  File path/to/decorators.py, line xx in wrapper_repeat
   '''
UnboundLocalError: local variable 'nbrTimes' referenced before assignment.

I just don't see where I'm bungling it. In other examples the passed parameter (here nbrTimes) was not "used" until later in the inner function, so the "unused argument" warning and error upon execution leave me kind of high and dry. Still relatively new to Python. Help much appreciated.

Edit: (in response to duplicate flag by @recnac) It is not clear at all what OP in your purported duplicate wanted to achieve. I can only surmise that he/she intended to have access to a counter defined inside a decorator's wrapper, from global scope, and failed to declare it as nonlocal. Fact is we don't even know whether OP dealt with Python 2 or 3, although it is largely irrelevant here. I concede to you that the error messages were very similar, if not equivalent, if not the same. However my intent was not to access a in-wrapper-defined counter from global scope. I intended to make this counter purely local, and did. My coding errors were elsewhere altogether. It turns out the excellent discussion and solution provided by Kevin (below) are of a nature, totally different from just adding a nonlocal <var> inside the wrapper definition block (in case of Python 3.x). I won't be repeating Kevin's arguments. They are limpid and available to all.

Finally I go out on a limb and will say that the error message is perhaps the least important of all here, even though it is clearly a consequence of my bad code. For that I make amends, but this post is definitely not a rehash of the proposed "duplicate".


Solution

  • The proposed duplicate question, Scope of variables in python decorators - changing parameters gives useful information that explains why wrapper_repeat considers nbrTimes to be a local variable, and how nonlocal might be used to make it recognize the nbrTimes defined by repeat. This would fix the exception, but I don't think it's a complete solution in your case. Your decorated function will still not repeat.

    import functools
    
    def repeat(nbrTimes=2):
        '''
        Define parametrized decorator with arguments
        Default nbr of repeats is 2
        '''
        def real_repeat(func):
            """
            Repeats execution 'nbrTimes' times
            """
            @functools.wraps(func)
            def wrapper_repeat(*args, **kwargs):
                nonlocal nbrTimes
                while nbrTimes != 0:
                    nbrTimes -= 1
                    return func(*args, **kwargs)
            return wrapper_repeat
        return real_repeat
    
    @repeat(2)
    def display(x):
        print("displaying:", x)
    
    display("foo")
    display("bar")
    display("baz")
    

    Result:

    displaying: foo
    displaying: bar
    

    "foo" and "bar" are each displayed only one time, and "baz" is displayed zero times. I assume this is not the desired behavior.

    The first two calls to display fail to repeat because of the return func(*args, **kwargs) inside your while loop. The return statement causes wrapper_repeat to terminate immediately, and no further iterations of the while will occur. So no decorated function will repeat more than once. One possible solution is to remove the return and just call the function.

    import functools
    
    def repeat(nbrTimes=2):
        '''
        Define parametrized decorator with arguments
        Default nbr of repeats is 2
        '''
        def real_repeat(func):
            """
            Repeats execution 'nbrTimes' times
            """
            @functools.wraps(func)
            def wrapper_repeat(*args, **kwargs):
                nonlocal nbrTimes
                while nbrTimes != 0:
                    nbrTimes -= 1
                    func(*args, **kwargs)
            return wrapper_repeat
        return real_repeat
    
    @repeat(2)
    def display(x):
        print("displaying:", x)
    
    display("foo")
    display("bar")
    display("baz")
    

    Result:

    displaying: foo
    displaying: foo
    

    "foo" is being displayed twice, but now neither "bar" nor "baz" appear. This is because nbrTimes is shared across all instances of your decorator, thanks to nonlocal. once display("foo") decrements nbrTimes to zero, it remains at zero even after the call completes. display("bar") and display("baz") will execute their decorators, see that nbrTimes is zero, and terminate without calling the decorated function at all.

    So it turns out that you don't want your loop counter to be nonlocal. But this means you can't use nbrTimes for this purpose. Try creating a local variable based on nbrTimes' value, and decrement that instead.

    import functools
    
    def repeat(nbrTimes=2):
        '''
        Define parametrized decorator with arguments
        Default nbr of repeats is 2
        '''
        def real_repeat(func):
            """
            Repeats execution 'nbrTimes' times
            """
            @functools.wraps(func)
            def wrapper_repeat(*args, **kwargs):
                times = nbrTimes
                while times != 0:
                    times -= 1
                    func(*args, **kwargs)
            return wrapper_repeat
        return real_repeat
    
    @repeat(2)
    def display(x):
        print("displaying:", x)
    
    display("foo")
    display("bar")
    display("baz")
    

    Result:

    displaying: foo
    displaying: foo
    displaying: bar
    displaying: bar
    displaying: baz
    displaying: baz
    

    ... And while you're at it, you may as well use a for loop instead of a while.

    import functools
    
    def repeat(nbrTimes=2):
        '''
        Define parametrized decorator with arguments
        Default nbr of repeats is 2
        '''
        def real_repeat(func):
            """
            Repeats execution 'nbrTimes' times
            """
            @functools.wraps(func)
            def wrapper_repeat(*args, **kwargs):
                for _ in range(nbrTimes):
                    func(*args, **kwargs)
            return wrapper_repeat
        return real_repeat
    
    @repeat(2)
    def display(x):
        print("displaying:", x)
    
    display("foo")
    display("bar")
    display("baz")