diff --git a/docs/changes.rst b/docs/changes.rst index 5732af8c..5f7b6633 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,27 @@ Release Notes ============= +Version 2.5.2 +------------- + +**Bugs Fixed** + +* When the object wrapped by ``wrapt.AutoObjectProxy``, or created by the + factory of a ``wrapt.LazyObjectProxy``, was a class, the special methods + the class defines for its instances were added to the generated proxy + class, even though Python looks up special methods on the type of an + object, which for a class is its metaclass. A proxy for a class defining + ``__set_name__()`` therefore raised ``TypeError`` when assigned to an + attribute in a class body, a proxy for a class defining ``__get__()`` + was bound as a descriptor on attribute access, and a proxy for a class + defining ``__await__()`` or ``__iter__()`` was reported as awaitable or + iterable by ``inspect.isawaitable()`` and ``collections.abc`` checks. + Conversely, special methods provided by a custom metaclass, such as + ``__iter__()``, were not added when an ``AutoObjectProxy`` was created + for the class. For a class, the special methods of its + metaclass are now used, so the proxy behaves the same as the class it + wraps. + Version 2.5.1 ------------- diff --git a/src/wrapt/proxies.py b/src/wrapt/proxies.py index 72266974..4b649305 100644 --- a/src/wrapt/proxies.py +++ b/src/wrapt/proxies.py @@ -83,6 +83,20 @@ def __wrapper_set_name__(self, owner, name): return self.__wrapped__.__set_name__(owner, name) +def _special_method_source(wrapped): + """Returns the object to check for the special dunder methods which apply + to `wrapped`. Python looks these up on the type of an object. For most + objects checking the object itself gives the same answer, but a class + lists the methods it defines for its instances, which do not apply to the + class itself. For a class the methods of its metaclass are used instead. + """ + + if isinstance(wrapped, type): + return type(wrapped) + + return wrapped + + class AutoObjectProxy(BaseObjectProxy): """An object proxy which can automatically adjust to the wrapped object and add special dunder methods as needed. Note that this creates a new @@ -101,7 +115,7 @@ def __new__(cls, wrapped, *args, **kwargs): namespace = {} - wrapped_attrs = dir(wrapped) + wrapped_attrs = dir(_special_method_source(wrapped)) class_attrs = set(dir(cls)) if callable(wrapped) and "__call__" not in class_attrs: @@ -162,73 +176,75 @@ def __wrapped_setattr_fixups__(self): cls = type(self) class_attrs = set(dir(cls)) + wrapped = _special_method_source(self.__wrapped__) + if callable(self.__wrapped__): if "__call__" not in class_attrs: cls.__call__ = __wrapper_call__ elif getattr(cls, "__call__", None) is __wrapper_call__: delattr(cls, "__call__") - if hasattr(self.__wrapped__, "__iter__"): + if hasattr(wrapped, "__iter__"): if "__iter__" not in class_attrs: cls.__iter__ = __wrapper_iter__ elif getattr(cls, "__iter__", None) is __wrapper_iter__: delattr(cls, "__iter__") - if hasattr(self.__wrapped__, "__next__"): + if hasattr(wrapped, "__next__"): if "__next__" not in class_attrs: cls.__next__ = __wrapper_next__ elif getattr(cls, "__next__", None) is __wrapper_next__: delattr(cls, "__next__") - if hasattr(self.__wrapped__, "__aiter__"): + if hasattr(wrapped, "__aiter__"): if "__aiter__" not in class_attrs: cls.__aiter__ = __wrapper_aiter__ elif getattr(cls, "__aiter__", None) is __wrapper_aiter__: delattr(cls, "__aiter__") - if hasattr(self.__wrapped__, "__anext__"): + if hasattr(wrapped, "__anext__"): if "__anext__" not in class_attrs: cls.__anext__ = __wrapper_anext__ elif getattr(cls, "__anext__", None) is __wrapper_anext__: delattr(cls, "__anext__") - if hasattr(self.__wrapped__, "__length_hint__"): + if hasattr(wrapped, "__length_hint__"): if "__length_hint__" not in class_attrs: cls.__length_hint__ = __wrapper_length_hint__ elif getattr(cls, "__length_hint__", None) is __wrapper_length_hint__: delattr(cls, "__length_hint__") - if hasattr(self.__wrapped__, "__fspath__"): + if hasattr(wrapped, "__fspath__"): if "__fspath__" not in class_attrs: cls.__fspath__ = __wrapper_fspath__ elif getattr(cls, "__fspath__", None) is __wrapper_fspath__: delattr(cls, "__fspath__") - if hasattr(self.__wrapped__, "__await__"): + if hasattr(wrapped, "__await__"): if "__await__" not in class_attrs: cls.__await__ = __wrapper_await__ elif getattr(cls, "__await__", None) is __wrapper_await__: delattr(cls, "__await__") - if hasattr(self.__wrapped__, "__get__"): + if hasattr(wrapped, "__get__"): if "__get__" not in class_attrs: cls.__get__ = __wrapper_get__ elif getattr(cls, "__get__", None) is __wrapper_get__: delattr(cls, "__get__") - if hasattr(self.__wrapped__, "__set__"): + if hasattr(wrapped, "__set__"): if "__set__" not in class_attrs: cls.__set__ = __wrapper_set__ elif getattr(cls, "__set__", None) is __wrapper_set__: delattr(cls, "__set__") - if hasattr(self.__wrapped__, "__delete__"): + if hasattr(wrapped, "__delete__"): if "__delete__" not in class_attrs: cls.__delete__ = __wrapper_delete__ elif getattr(cls, "__delete__", None) is __wrapper_delete__: delattr(cls, "__delete__") - if hasattr(self.__wrapped__, "__set_name__"): + if hasattr(wrapped, "__set_name__"): if "__set_name__" not in class_attrs: cls.__set_name__ = __wrapper_set_name__ elif getattr(cls, "__set_name__", None) is __wrapper_set_name__: diff --git a/tests/core/test_auto_object_proxy.py b/tests/core/test_auto_object_proxy.py index da6d498e..4cc5e0b4 100644 --- a/tests/core/test_auto_object_proxy.py +++ b/tests/core/test_auto_object_proxy.py @@ -255,6 +255,73 @@ class Owner: del owner.attr self.assertRaises(AttributeError, lambda: owner.attr) + def test_wrapped_class_is_not_descriptor(self): + class Descriptor: + def __get__(self, instance, owner=None): + return 42 + + def __set__(self, instance, value): + pass + + def __delete__(self, instance): + pass + + def __set_name__(self, owner, name): + pass + + # Only instances of the class are descriptors, not the class itself, + # so a proxy for the class stored in a class body must not apply the + # descriptor protocol methods the class defines for its instances. + + class Owner: + attr = wrapt.AutoObjectProxy(Descriptor) + + owner = Owner() + + self.assertIs(Owner.attr.__wrapped__, Descriptor) + self.assertIs(owner.attr.__wrapped__, Descriptor) + + owner.attr = 100 + self.assertEqual(owner.attr, 100) + + del owner.attr + self.assertIs(owner.attr.__wrapped__, Descriptor) + + def test_wrapped_class_is_not_awaitable_or_iterable(self): + import collections.abc + import inspect + + class Awaitable: + def __await__(self): + yield + return 42 + + class Iterable: + def __iter__(self): + return iter([1, 2, 3]) + + self.assertFalse(inspect.isawaitable(Awaitable)) + self.assertFalse(inspect.isawaitable(wrapt.AutoObjectProxy(Awaitable))) + + self.assertNotIsInstance(Iterable, collections.abc.Iterable) + self.assertNotIsInstance( + wrapt.AutoObjectProxy(Iterable), collections.abc.Iterable + ) + + def test_wrapped_class_metaclass_iter(self): + class Meta(type): + def __iter__(cls): + return iter([1, 2, 3]) + + class Iterable(metaclass=Meta): + pass + + self.assertEqual(list(Iterable), [1, 2, 3]) + + proxy = wrapt.AutoObjectProxy(Iterable) + + self.assertEqual(list(proxy), [1, 2, 3]) + class TestAutoObjectProxyReassignment(unittest.TestCase): @@ -499,3 +566,31 @@ def __set_name__(self, owner, name): self.assertTrue(hasattr(proxy, "__set_name__")) proxy.__wrapped__ = object() self.assertFalse(hasattr(proxy, "__set_name__")) + + def test_wrapped_class_is_not_descriptor_on_reassignment(self): + class Descriptor: + def __get__(self, instance, owner=None): + return 42 + + def __set_name__(self, owner, name): + pass + + proxy = wrapt.AutoObjectProxy(object()) + proxy.__wrapped__ = Descriptor + + class Owner: + attr = proxy + + self.assertIs(Owner().attr, proxy) + + def test_wrapped_class_metaclass_iter_on_reassignment(self): + class Meta(type): + def __iter__(cls): + return iter([1, 2, 3]) + + class Iterable(metaclass=Meta): + pass + + proxy = wrapt.AutoObjectProxy(object()) + proxy.__wrapped__ = Iterable + self.assertEqual(list(proxy), [1, 2, 3]) diff --git a/tests/core/test_lazy_object_proxy.py b/tests/core/test_lazy_object_proxy.py index 41305294..3b4cf0eb 100644 --- a/tests/core/test_lazy_object_proxy.py +++ b/tests/core/test_lazy_object_proxy.py @@ -151,6 +151,23 @@ def test_lazy_import_iterable(self): self.assertEqual("".join(digits2), "0123456789") + def test_lazy_class_is_not_descriptor(self): + class Descriptor: + def __get__(self, instance, owner=None): + return 42 + + def __set_name__(self, owner, name): + pass + + proxy = wrapt.LazyObjectProxy(lambda: Descriptor) + + self.assertIs(proxy.__wrapped__, Descriptor) + + class Owner: + attr = proxy + + self.assertIs(Owner().attr, proxy) + def test_pathlike_interface_is_lazy(self): for interface in (os.PathLike, Path): with self.subTest(interface=interface):