Skip to content

Commit 5251f7a

Browse files
Rewrite closure cells in decorated methods
1 parent 3823062 commit 5251f7a

2 files changed

Lines changed: 126 additions & 14 deletions

File tree

src/attr/_make.py

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

558558

559+
def _closure_cells(item, seen):
560+
"""
561+
Yield closure cells for *item* and any wrapped functions it closes over.
562+
"""
563+
item_id = id(item)
564+
if item_id in seen:
565+
return
566+
seen.add(item_id)
567+
568+
if isinstance(item, (classmethod, staticmethod)):
569+
item = item.__func__
570+
elif isinstance(item, property):
571+
for accessor in (item.fget, item.fset, item.fdel):
572+
if accessor is not None:
573+
yield from _closure_cells(accessor, seen)
574+
return
575+
elif isinstance(item, cached_property):
576+
item = item.func
577+
elif isinstance(item, types.MethodType):
578+
item = item.__func__
579+
elif not isinstance(item, types.FunctionType):
580+
wrapped = getattr(item, "__wrapped__", None)
581+
if wrapped is not None:
582+
yield from _closure_cells(wrapped, seen)
583+
return
584+
585+
closure = item.__closure__
586+
if closure:
587+
yield from closure
588+
589+
for cell in closure:
590+
try:
591+
cell_contents = cell.cell_contents
592+
except ValueError:
593+
continue
594+
595+
if isinstance(
596+
cell_contents,
597+
(
598+
types.FunctionType,
599+
types.MethodType,
600+
classmethod,
601+
staticmethod,
602+
property,
603+
cached_property,
604+
),
605+
):
606+
yield from _closure_cells(cell_contents, seen)
607+
608+
wrapped = getattr(item, "__wrapped__", None)
609+
if wrapped is not None:
610+
yield from _closure_cells(wrapped, seen)
611+
612+
559613
def _frozen_setattrs(self, name, value):
560614
"""
561615
Attached to frozen classes as __setattr__.
@@ -973,20 +1027,7 @@ def _create_slots_class(self):
9731027
for item in itertools.chain(
9741028
cls.__dict__.values(), additional_closure_functions_to_update
9751029
):
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:
1030+
for cell in _closure_cells(item, set()):
9901031
try:
9911032
match = cell.cell_contents is self._cls
9921033
except ValueError: # noqa: PERF203

tests/test_slots.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import functools
88
import pickle
9+
import types
910
import weakref
1011

1112
from unittest import mock
@@ -16,6 +17,7 @@
1617
import attrs
1718

1819
from attr._compat import PY_3_14_PLUS, PYPY
20+
from attr._make import _closure_cells
1921

2022

2123
# Pympler doesn't work on PyPy.
@@ -426,7 +428,52 @@ class C2(C1Bare):
426428
assert {"x": 1, "y": 2, "z": "test"} == attr.asdict(c2)
427429

428430

431+
def _function_closing_over(value):
432+
def function():
433+
return value
434+
435+
return function
436+
437+
429438
class TestClosureCellRewriting:
439+
def test_closure_cells_handles_wrapped_callables(self):
440+
"""
441+
Closure cells are found inside wrapped function shapes.
442+
"""
443+
444+
marker = object()
445+
function = _function_closing_over(marker)
446+
wrapped_function = _function_closing_over(marker)
447+
448+
def wrapper():
449+
pass
450+
451+
wrapper.__wrapped__ = wrapped_function
452+
cached_function = functools.lru_cache()(_function_closing_over(marker))
453+
454+
for item in (
455+
functools.cached_property(function),
456+
types.MethodType(function, object()),
457+
wrapper,
458+
cached_function,
459+
):
460+
assert any(
461+
cell.cell_contents is marker
462+
for cell in _closure_cells(item, set())
463+
)
464+
465+
def test_closure_cells_does_not_inspect_arbitrary_cell_contents(self):
466+
"""
467+
Closure cell discovery doesn't introspect unrelated user objects.
468+
"""
469+
470+
class Unrelated:
471+
def __getattr__(self, name):
472+
msg = f"unexpected attribute lookup: {name}"
473+
raise AssertionError(msg)
474+
475+
list(_closure_cells(_function_closing_over(Unrelated()), set()))
476+
430477
def test_closure_cell_rewriting(self):
431478
"""
432479
Slotted classes support proper closure cell rewriting.
@@ -497,6 +544,30 @@ def statmethod():
497544

498545
assert D.statmethod() is D
499546

547+
def test_decorated_method(self, slots):
548+
"""
549+
Slotted classes rewrite closure cells in decorated methods.
550+
"""
551+
552+
def decorated(method):
553+
def wrapped(self, *args, **kwargs):
554+
return method(self, *args, **kwargs)
555+
556+
return wrapped
557+
558+
@attr.s(slots=slots)
559+
class A:
560+
def method(self):
561+
return "A"
562+
563+
@attr.s(slots=slots)
564+
class B(A):
565+
@decorated
566+
def method(self):
567+
return super().method()
568+
569+
assert B().method() == "A"
570+
500571

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

0 commit comments

Comments
 (0)