Attribute access on a decorated callable class

2019-06-06 16:37发布

问题:

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

回答1:

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.