Skip to content

Commit 0d20013

Browse files
author
Zo Bot
committed
recurse into decorator-wrapped methods when rewriting closure cells for slotted classes
1 parent fb2d7d0 commit 0d20013

3 files changed

Lines changed: 117 additions & 22 deletions

File tree

changelog.d/1038.bugfix.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Slotted classes now correctly handle `super()` and `__class__` references inside methods that are wrapped by a decorator.
2+
The closure-cell rewrite that keeps `super()` and `__class__` working after the slot-based class is rebuilt now recurses into decorator-wrapped methods instead of stopping at the outer wrapper, so the inner method's baked class reference points at the rebuilt class. Without the fix, calling a decorated method on a slotted attrs class that used `super()` raised `TypeError: super(type, obj): obj must be an instance or subtype of type`.

src/attr/_make.py

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,63 @@ def _make_cached_property_getattr(cached_properties, original_getattr, cls):
556556
)["__getattr__"]
557557

558558

559+
def _rewrite_closure_cells(item, old_cls, new_cls):
560+
"""
561+
Rewrite ``__class__`` / ``super()`` closure cells in *item* to point at
562+
*new_cls* instead of *old_cls*.
563+
564+
This is invoked on the class dict of the cloned slotted attrs class so
565+
that a method which references ``__class__`` or calls the no-arg
566+
``super()`` keeps working — the compiler bakes a reference to the
567+
surrounding class into the method's ``__closure__``, and we just
568+
replaced that class with a clone.
569+
570+
A single pass through the class dict is not enough: a method may be
571+
wrapped by a decorator whose closure cell holds the *original* method,
572+
and the closure cell that actually points at the class lives on that
573+
inner method (see `GH-1038 <https://github.com/python-attrs/attrs/issues/1038>`_).
574+
We recurse into cell contents so decorator-wrapped methods get their
575+
inner ``__class__`` cell rewritten too.
576+
577+
Classmethod, staticmethod, and property descriptors are unwrapped
578+
before their ``__closure__`` is inspected. The recursion descends
579+
into any cell whose contents are themselves a function (the typical
580+
decorator-wraps-function case) but does not look inside arbitrary
581+
callables — we only want to rewrite closure cells the compiler put
582+
there as part of a ``super()`` / ``__class__`` reference, not user
583+
data that happens to live in a cell.
584+
"""
585+
if isinstance(item, (classmethod, staticmethod)):
586+
# Class- and staticmethods hide their functions inside.
587+
# These might need to be rewritten as well.
588+
target = item.__func__
589+
elif isinstance(item, property):
590+
# Workaround for property `super()` shortcut (PY3-only).
591+
# There is no universal way for other descriptors.
592+
target = item.fget
593+
else:
594+
target = item
595+
596+
closure_cells = getattr(target, "__closure__", None)
597+
if not closure_cells: # Catch None or the empty list.
598+
return
599+
600+
for cell in closure_cells:
601+
try:
602+
contents = cell.cell_contents
603+
except ValueError: # noqa: PERF203
604+
# ValueError: Cell is empty
605+
continue
606+
607+
if contents is old_cls:
608+
cell.cell_contents = new_cls
609+
elif isinstance(contents, types.FunctionType):
610+
# Decorator wrapping: the wrapper's cell points at the
611+
# underlying function, whose own closure may carry the
612+
# ``__class__`` / ``super()`` reference we need to rewrite.
613+
_rewrite_closure_cells(contents, old_cls, new_cls)
614+
615+
559616
def _frozen_setattrs(self, name, value):
560617
"""
561618
Attached to frozen classes as __setattr__.
@@ -970,31 +1027,17 @@ def _create_slots_class(self):
9701027
# compiler will bake a reference to the class in the method itself
9711028
# as `method.__closure__`. Since we replace the class with a
9721029
# clone, we rewrite these references so it keeps working.
1030+
#
1031+
# The rewrite is recursive because a method may be wrapped by a
1032+
# decorator (decorator returns a wrapper function whose closure
1033+
# cell contains the original method; see GH-1038). Plain iteration
1034+
# over the class dict stops at the wrapper, so the inner method's
1035+
# baked `__class__` is left pointing at the old class and
1036+
# ``super()`` raises ``TypeError`` at call time.
9731037
for item in itertools.chain(
9741038
cls.__dict__.values(), additional_closure_functions_to_update
9751039
):
976-
if isinstance(item, (classmethod, staticmethod)):
977-
# Class- and staticmethods hide their functions inside.
978-
# These might need to be rewritten as well.
979-
closure_cells = getattr(item.__func__, "__closure__", None)
980-
elif isinstance(item, property):
981-
# Workaround for property `super()` shortcut (PY3-only).
982-
# There is no universal way for other descriptors.
983-
closure_cells = getattr(item.fget, "__closure__", None)
984-
else:
985-
closure_cells = getattr(item, "__closure__", None)
986-
987-
if not closure_cells: # Catch None or the empty list.
988-
continue
989-
for cell in closure_cells:
990-
try:
991-
match = cell.cell_contents is self._cls
992-
except ValueError: # noqa: PERF203
993-
# ValueError: Cell is empty
994-
pass
995-
else:
996-
if match:
997-
cell.cell_contents = cls
1040+
_rewrite_closure_cells(item, self._cls, cls)
9981041
return cls
9991042

10001043
def add_repr(self, ns):

tests/test_slots.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,56 @@ def statmethod():
497497

498498
assert D.statmethod() is D
499499

500+
def test_decorated_method_with_super(self, slots):
501+
"""
502+
Closure cell rewriting recurses through wrapped methods (GH-1038).
503+
504+
When a method is wrapped by a decorator, the wrapper function's
505+
closure cell holds the original (unwrapped) method, which in turn
506+
has its own closure cell baking a reference to ``__class__``. The
507+
previous closure walker only inspected the outer wrapper, so the
508+
inner method's ``__class__`` / no-arg ``super()`` reference kept
509+
pointing at the pre-slot-rebuild class and raised ``TypeError`` at
510+
call time on slotted attrs classes.
511+
512+
The parametrized ``slots`` fixture covers both the slots-on and
513+
slots-off paths; the bug is slots-specific.
514+
"""
515+
def trace(method):
516+
def wrapper(self, *args, **kwargs):
517+
return method(self, *args, **kwargs)
518+
return wrapper
519+
520+
@attr.s(slots=slots)
521+
class Parent:
522+
def f(self):
523+
return "Parent.f"
524+
525+
@attr.s(slots=slots)
526+
class Child(Parent):
527+
@trace
528+
def f(self):
529+
return super().f() + "+Child.f"
530+
531+
# Non-slotted classes were always correct; the regression was only
532+
# on slots=True. Assert both paths work.
533+
assert Child().f() == "Parent.f+Child.f"
534+
535+
# And the static case: an unwrapped subclass that mentions
536+
# ``__class__`` inside a method that lives behind a decorator.
537+
def returns_class(method):
538+
def wrapper(self, *args, **kwargs):
539+
return method(self, *args, **kwargs)
540+
return wrapper
541+
542+
@attr.s(slots=slots)
543+
class WithClass:
544+
@returns_class
545+
def who(self):
546+
return __class__
547+
548+
assert WithClass().who() is WithClass
549+
500550

501551
@pytest.mark.skipif(PYPY, reason="__slots__ only block weakref on CPython")
502552
def test_not_weakrefable():

0 commit comments

Comments
 (0)