Search code examples
pythondecoratorpython-decoratorsexponential-backoff

Attribute access on a decorated callable class


I have a callable class:

class CallMeMaybe:

    __name__ = 'maybe'

    def __init__(self):
        self.n_calls = 0

    def __call__(self):
        self.n_calls += 1
        raise Exception

That seems to work as advertised:

>>> f = CallMeMaybe()
>>> f.n_calls
0
>>> for i in range(7):
...     try:
...         f()
...     except Exception:
...         pass
...     
>>> f.n_calls
7

I want to decorate it with an exponential backoff:

from backoff import on_exception, expo
dec = on_exception(expo, Exception, max_tries=3, on_backoff=print)
f = CallMeMaybe()
f2 = dec(f)

Now it looks like attribute access stopped working:

>>> f2.n_calls
0
>>> f2()
{'target': <__main__.CallMeMaybe object at 0xcafef00d>, 'args': (), 'kwargs': {}, 'tries': 1, 'elapsed': 2.1e-05, 'wait': 0.4843249208229148}
{'target': <__main__.CallMeMaybe object at 0xcafef00d>, 'args': (), 'kwargs': {}, 'tries': 2, 'elapsed': 0.484935, 'wait': 1.6524016553598126}
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
... blah blah blah
>>> f2.n_calls
0

My question: who copied n_calls name into f2's namespace, and why? Now it holds a stale value - the correct value should be 3:

>>> f2.__wrapped__.n_calls
3

Solution

  • The backoff module in its implementation uses functools.wraps which calls functools.update_wrapper and you can see from the source code that by default it updates the __dict__ of the wrapper:

    WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
                           '__annotations__')
    WRAPPER_UPDATES = ('__dict__',)
    def update_wrapper(wrapper,
                       wrapped,
                       assigned = WRAPPER_ASSIGNMENTS,
                       updated = WRAPPER_UPDATES):
        """Update a wrapper function to look like the wrapped function
    
           wrapper is the function to be updated
           wrapped is the original function
           assigned is a tuple naming the attributes assigned directly
           from the wrapped function to the wrapper function (defaults to
           functools.WRAPPER_ASSIGNMENTS)
           updated is a tuple naming the attributes of the wrapper that
           are updated with the corresponding attribute from the wrapped
           function (defaults to functools.WRAPPER_UPDATES)
        """
        for attr in assigned:
            try:
                value = getattr(wrapped, attr)
            except AttributeError:
                pass
            else:
                setattr(wrapper, attr, value)
        for attr in updated:
            getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
        +
    −# Issue #17482: set __wrapped__ last so we don't inadvertently copy it
        # from the wrapped function when updating __dict__
        wrapper.__wrapped__ = wrapped
        # Return the wrapper so this can be used as a decorator via partial()
        return wrapper
    
    def wraps(wrapped,
              assigned = WRAPPER_ASSIGNMENTS,
              updated = WRAPPER_UPDATES):
        """Decorator factory to apply update_wrapper() to a wrapper function
    
           Returns a decorator that invokes update_wrapper() with the decorated
           function as the wrapper argument and the arguments to wraps() as the
           remaining arguments. Default arguments are as for update_wrapper().
           This is a convenience function to simplify applying partial() to
           update_wrapper().
        """
        return partial(update_wrapper, wrapped=wrapped,
                       assigned=assigned, updated=updated)
    

    Unfortunately it seems impossible to achieve what you want. The backoff module could allow optional assigned/updated attributes lists to be passed to wraps to avoid the copy of the attribute. However this would really solve the issue because at that point you would not have access to n_calls.

    You probably need to use a mutable object instead of a plain int.