diff --git a/Lib/functools.py b/Lib/functools.py --- a/Lib/functools.py +++ b/Lib/functools.py @@ -30,7 +30,8 @@ function (defaults to functools.WRAPPER_UPDATES) """ for attr in assigned: - setattr(wrapper, attr, getattr(wrapped, attr)) + if hasattr(wrapped, attr): + setattr(wrapper, attr, getattr(wrapped, attr)) for attr in updated: getattr(wrapper, attr).update(getattr(wrapped, attr, {})) # Return the wrapper so this can be used as a decorator via partial() diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -172,13 +172,19 @@ updated=functools.WRAPPER_UPDATES): # Check attributes were assigned for name in assigned: - self.assertTrue(getattr(wrapper, name) is getattr(wrapped, name)) + self.assertTrue( + getattr(wrapper, name) is getattr(wrapped, name), + msg = "'{}' is not wrapped correctly".format(name) + ) # Check attributes were updated for name in updated: wrapper_attr = getattr(wrapper, name) wrapped_attr = getattr(wrapped, name) for key in wrapped_attr: - self.assertTrue(wrapped_attr[key] is wrapper_attr[key]) + self.assertTrue( + wrapped_attr[key] is wrapper_attr[key], + msg = "'{}' is not wrapped correctly".format(name) + ) def _default_update(self): def f(): @@ -298,6 +304,83 @@ self.assertEqual(wrapper.dict_attr, f.dict_attr) + def test_wraps_missing_attributes(self): + def testme(): + pass + + # sanity check + self.assertFalse(hasattr(testme, 'contrived_attribute')) + + default_assigned = set(functools.WRAPPER_ASSIGNMENTS) + assigned_with_attr = default_assigned | {'contrived_attrbute'} + + @functools.wraps(testme, assigned=assigned_with_attr) + def wrapper(): + testme() + wrapper() + + # if everything worked as expected, @functools.wraps will have copied + # over the usual attribute assignments, and silently skipped the missing + # "contrived_attribute" attribute + self.check_wrapper(wrapper, testme, assigned=default_assigned) + + # real-world examples + + # user callable + class Callable(): + def __call__(self): + pass + def another_method(self): + "test docstring" + pass + callable = Callable() + + @functools.wraps(callable) + def wrapper(): + callable() + wrapper() + + # All but '__name__' should be copied + assigned_but_name = default_assigned - {'__name__'} + self.check_wrapper(wrapper, callable, assigned=assigned_but_name) + + # unbound method + unbound_method = Callable.another_method + @functools.wraps(unbound_method) + def wrapper(instance): + return unbound_method(instance) + wrapper(callable) + + # All but '__module__' should be copied + assigned_but_module = default_assigned - {'__module__'} + self.check_wrapper( + wrapper, unbound_method, + assigned=assigned_but_module + ) + + # partial object + the_partial = functools.partial(unbound_method, callable) + @functools.wraps(the_partial) + def wrapper(): + return the_partial() + wrapper() + + # All but '__name__' and '__module__' should be copied + self.check_wrapper( + wrapper, the_partial, + assigned=default_assigned - {'__name__', '__module__'} + ) + + # bound method + bound_method = callable.another_method + @functools.wraps(bound_method) + def wrapper(): + return bound_method() + wrapper() + + # All should be copied + self.check_wrapper(wrapper, bound_method, assigned=default_assigned) + class TestReduce(unittest.TestCase): def test_reduce(self):