Skip to content

Look up special methods on the metaclass when an auto proxy wraps a class - #361

Closed
ybai08 wants to merge 1 commit into
GrahamDumpleton:developfrom
ybai08:auto-proxy-wrapped-class-dunders
Closed

ybai08 wants to merge 1 commit into
GrahamDumpleton:developfrom
ybai08:auto-proxy-wrapped-class-dunders

Conversation

@ybai08

@ybai08 ybai08 commented Oct 17, 2026

Copy link
Copy Markdown

When AutoObjectProxy wraps a class, it decides which special methods to add to its generated class by checking dir(wrapped). For a class, that lists the methods the class defines for its instances. Python looks special methods up on the type of an object, and the type of a class is its metaclass. So the proxy picks up methods that don't apply to the class it wraps, and misses ones that do. __wrapped_setattr_fixups__() has the same problem through hasattr(self.__wrapped__, ...), and that path also affects a LazyObjectProxy whose factory returns a class.

What goes wrong, compared with using the class directly:

import functools, inspect, asyncio, wrapt

class Owner:
    attr = wrapt.AutoObjectProxy(functools.cached_property)
# TypeError: cached_property.__set_name__() missing 1 required positional argument: 'name'
# Error calling __set_name__ on 'ObjectProxy' instance 'attr' in 'Owner'

inspect.isawaitable(asyncio.Future)                        # False
inspect.isawaitable(wrapt.AutoObjectProxy(asyncio.Future)) # True, but awaiting it raises TypeError
  • A proxy for a class that defines __get__() gets bound as a descriptor on attribute access, so reading the attribute calls the unbound __get__().
  • A proxy for a class that defines __iter__() passes isinstance(proxy, collections.abc.Iterable), while the class itself doesn't.
  • A class whose metaclass defines __iter__() can be iterated, but list(AutoObjectProxy(cls)) raises TypeError because the metaclass method is never added. On Python 3.9 this includes enum.Enum subclasses, since dir() of an enum class there doesn't list __iter__.

The fix adds a small helper, _special_method_source(). For a class it returns type(wrapped), and for anything else it returns the object unchanged. AutoObjectProxy.__new__() and __wrapped_setattr_fixups__() now check that object. Non-class objects take the same path as before, including proxies, whose dir() is forwarded. The __call__ check still uses callable() on the wrapped object. LazyObjectProxy.__new__() already checks dir(interface) against a type, so it is unchanged.

Tests added:

  • tests/core/test_auto_object_proxy.py:
    • a wrapped descriptor class in a class body: set, get and delete of the attribute all behave as for the bare class;
    • a wrapped class that defines __await__/__iter__ is not reported as awaitable or iterable;
    • iteration through a metaclass __iter__;
    • the same descriptor and metaclass cases on __wrapped__ reassignment.
  • tests/core/test_lazy_object_proxy.py: a LazyObjectProxy whose factory returns a descriptor class, stored in a class body after it has been resolved.

I added a change note under a new Version 2.5.2 heading in docs/changes.rst. Please move or retitle it as you see fit.

Validation (local, macOS arm64):

  • Before the fix, 5 of the new tests fail with the C extension enabled and with it disabled at runtime. The metaclass reassignment test already passes, because hasattr() on a class does reach its metaclass, and it stays in as a guard. After the fix all 51 tests in the two files pass in both modes.
  • I ran the steps of just test-version by hand (just isn't installed here; the venv was kept outside the repo) for Python 3.9, 3.11, 3.12, 3.13 and 3.14. Each version ran pure-Python install, C extension enabled and C extension disabled at runtime, with the pinned mypy, and all 15 runs passed. Python 3.10, 3.14t, 3.15 and 3.15t were not run because those interpreters aren't installed here, so the full just test matrix has not been run.
  • mypy.stubtest with the allowlist passes for both the C extension and pure Python.
  • black --check and isort --check-only pass on the changed files.

@GrahamDumpleton

Copy link
Copy Markdown
Owner

@ybai08 Thanks for the pull request, and for the thorough analysis and tests. You are correct that this is a bug.

Working through it, though, what is reported turns out to be one half of a broader problem. The special methods added to the proxy class should never have been looked up on the wrapped object itself, but always on its type. Special methods are only ever looked up on the type of an object, never the instance, so:

  • For a class, that means the metaclass, as you describe: dir() of the class lists the methods it defines for its instances, and the metaclass methods which apply to the class itself are missed. __wrapped_setattr_fixups__() had the same problem through its hasattr(self.__wrapped__, ...) checks.
  • For an instance, looking on the instance is equally wrong, in two ways:
    • instance attributes which shadow the dunder names were picked up as special methods, even though special method lookup ignores them. For example, an instance carrying an __await__ attribute made inspect.isawaitable() report the proxy as awaitable even though the wrapped instance was not, and likewise __iter__ with isinstance(..., collections.abc.Iterable).
    • a __dir__() method on the wrapped object which filters or omits names could hide special methods really defined on its type, in which case they were never added to the proxy class at all.

That means the general fix is not a special case for classes, but simply:

wrapped_attrs = dir(type(wrapped))

in AutoObjectProxy.__new__(), and the equivalent type(self.__wrapped__) in __wrapped_setattr_fixups__(), which also covers a LazyObjectProxy whose factory returns a class, since resolving the wrapped object reassigns __wrapped__. With that, _special_method_source() is not needed. As you note, the __call__ handling already goes through callable() and is unaffected, and LazyObjectProxy.__new__() already checks dir() of the interface, which is a type.

Fixing both halves changes the required code changes, and needs additional tests on top of the ones here: instance attributes shadowing dunder names, on both the __new__() path and the __wrapped__ reassignment path, plus a wrapped object whose __dir__() hides its type's special methods. I have made those changes myself, along with a changelog entry, and they will supersede this pull request.

Thanks again for reporting the issue and for the investigation which identified it. You will be acknowledged in the changelog entry for the fix.

@ybai08

ybai08 commented Oct 19, 2026

Copy link
Copy Markdown
Author

Thanks for working through it. Looking up the special methods on type(wrapped) covers both halves much more cleanly than special-casing classes, and the instance-shadowing and __dir__() cases are good catches. Fine to close this in favour of your change.

@GrahamDumpleton

Copy link
Copy Markdown
Owner

By the way. What are you doing that you caught this issue in the first place? In the last couple of weeks I have had a few other obscure corner cases reported that needed fixes and has me wondering whether is AI just being asked to find any problem in the code, or whether, although all reported by different users, you are all somehow connected and working on some hyper complex monkey patching.

GrahamDumpleton added a commit that referenced this pull request Oct 19, 2026
…ect.

The special methods added to the class generated for an AutoObjectProxy
were determined from dir() of the wrapped object, and the fixups applied
when __wrapped__ was reassigned from hasattr() on the wrapped object. As
special methods are only ever looked up on the type of an object, this
gave the wrong result when the wrapped object was a class, in which case
the special methods the class defines for its instances were added to
the proxy class, and the special methods of its metaclass were missed.
It also gave the wrong result when the wrapped object was an instance,
in which case instance attributes shadowing the dunder names were
treated as special methods of the wrapped object, and special methods
not reported by a __dir__() method were missed. The special methods are
now always determined from the type of the wrapped object, which also
covers a LazyObjectProxy whose factory returns an object, as resolving
the wrapped object reassigns __wrapped__.

Supersedes pull request #361.
@GrahamDumpleton

Copy link
Copy Markdown
Owner

I have released 2.5.2rc1 (https://pypi.org/project/wrapt/2.5.2rc1/) with the changes. The wrapt package gets a lot of use even as a pre-release, so I will let it sit out there as an RC for a few days at least before releasing it as 2.5.2 final version. Thanks for your help again. I will close this PR now since changes were superseded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants