Skip to content

Commit 2ae35c4

Browse files
Jammy2211claude
authored andcommitted
feat(tools): add cached_property_names MRO walker
New public helper `autoconf.tools.decorators.cached_property_names(cls)` walks `cls.__mro__` and returns a frozenset of every `@functools.cached_property` and autoconf `@CachedProperty` descriptor name. Result is memoised on the class itself under `__cached_property_names_cache__`. Used by PyAutoFit + PyAutoArray to extend their existing `__dict__`-iteration filters with a forward-compatibility guard: any future cached_property declared on a model or Fit class is automatically excluded from instance construction, ModelInstance.dict, pickling, and JAX pytree flattening. Defends against the class of bug PyAutoFit #1300 fixed for `AbstractPriorModel.parameterization`. 9 unit tests cover: empty class, stdlib + autoconf descriptor variants, MRO walk through inheritance chain, regular @Property is ignored, memoisation lands on the class itself, subclass cache is independent of parent cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed97fa6 commit 2ae35c4

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

autoconf/tools/decorators.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import functools
2+
13
import numpy as np
24

5+
36
class CachedProperty(object):
47
"""
58
A property that is only computed once per instance and then replaces
@@ -21,3 +24,57 @@ def __get__(self, obj, cls):
2124

2225

2326
cached_property = CachedProperty
27+
28+
29+
def cached_property_names(cls) -> frozenset:
30+
"""
31+
Return the names of every ``cached_property``-style descriptor declared
32+
anywhere in ``cls``'s MRO.
33+
34+
Recognises both stdlib :class:`functools.cached_property` and the
35+
autoconf :class:`CachedProperty` wrapper above. Walks the MRO so
36+
descriptors declared on base classes are picked up.
37+
38+
The first call for a given class walks the MRO and caches the resulting
39+
frozenset on the class itself under ``__cached_property_names_cache__``;
40+
subsequent calls return the cached frozenset directly. The cache key is
41+
stored under a dunder name so the result itself never appears in any
42+
instance ``__dict__`` walk.
43+
44+
Used by PyAutoFit and PyAutoArray to extend their existing
45+
``__dict__``-iteration filters with a forward-compat guard: any future
46+
``@cached_property`` declared on a model or Fit class will be
47+
automatically excluded from instance construction, ``ModelInstance.dict``,
48+
pickling, and JAX pytree flattening, preventing the class of bug that
49+
PR PyAutoFit#1300 fixed for ``parameterization``.
50+
51+
Parameters
52+
----------
53+
cls
54+
The class to inspect.
55+
56+
Returns
57+
-------
58+
A frozenset of attribute names corresponding to ``cached_property`` or
59+
autoconf ``CachedProperty`` descriptors found in ``cls.__mro__``.
60+
"""
61+
cache = cls.__dict__.get("__cached_property_names_cache__")
62+
if cache is not None:
63+
return cache
64+
65+
names = set()
66+
for base in cls.__mro__:
67+
for attr_name, value in base.__dict__.items():
68+
if isinstance(value, (functools.cached_property, CachedProperty)):
69+
names.add(attr_name)
70+
71+
result = frozenset(names)
72+
# Stash on the class itself (not a parent) so subclass overrides
73+
# of cached_property descriptors are re-discovered on the subclass.
74+
try:
75+
setattr(cls, "__cached_property_names_cache__", result)
76+
except (TypeError, AttributeError):
77+
# Some classes (slotted, built-in) reject setattr; that's fine,
78+
# the function still returns the correct value without memoisation.
79+
pass
80+
return result
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Tests for autoconf.tools.decorators.cached_property_names — the
2+
MRO-walking helper used by PyAutoFit and PyAutoArray to extend their
3+
__dict__-iteration filters with a forward-compat guard against
4+
cached_property pytree/dict leaks."""
5+
6+
import functools
7+
8+
import pytest
9+
10+
from autoconf.tools.decorators import (
11+
CachedProperty,
12+
cached_property,
13+
cached_property_names,
14+
)
15+
16+
17+
def test_empty_class_returns_empty_frozenset():
18+
class Empty:
19+
pass
20+
21+
result = cached_property_names(Empty)
22+
assert result == frozenset()
23+
assert isinstance(result, frozenset)
24+
25+
26+
def test_stdlib_cached_property_is_recognised():
27+
class Container:
28+
@functools.cached_property
29+
def derived(self):
30+
return "computed"
31+
32+
assert cached_property_names(Container) == frozenset({"derived"})
33+
34+
35+
def test_autoconf_cached_property_is_recognised():
36+
class Container:
37+
@CachedProperty
38+
def derived(self):
39+
return "computed"
40+
41+
assert cached_property_names(Container) == frozenset({"derived"})
42+
43+
44+
def test_autoconf_cached_property_alias_is_recognised():
45+
"""``cached_property`` re-exported from autoconf.tools.decorators is
46+
the same object as ``CachedProperty`` — verifies both spellings work."""
47+
48+
class Container:
49+
@cached_property
50+
def derived(self):
51+
return "computed"
52+
53+
assert cached_property_names(Container) == frozenset({"derived"})
54+
55+
56+
def test_mro_walk_picks_up_base_class_descriptors():
57+
class Base:
58+
@functools.cached_property
59+
def base_value(self):
60+
return 1
61+
62+
class Mid(Base):
63+
@functools.cached_property
64+
def mid_value(self):
65+
return 2
66+
67+
class Leaf(Mid):
68+
@functools.cached_property
69+
def leaf_value(self):
70+
return 3
71+
72+
assert cached_property_names(Leaf) == frozenset(
73+
{"base_value", "mid_value", "leaf_value"}
74+
)
75+
assert cached_property_names(Mid) == frozenset({"base_value", "mid_value"})
76+
assert cached_property_names(Base) == frozenset({"base_value"})
77+
78+
79+
def test_mixed_stdlib_and_autoconf_descriptors_both_recognised():
80+
class Container:
81+
@functools.cached_property
82+
def stdlib_value(self):
83+
return 1
84+
85+
@CachedProperty
86+
def autoconf_value(self):
87+
return 2
88+
89+
assert cached_property_names(Container) == frozenset(
90+
{"stdlib_value", "autoconf_value"}
91+
)
92+
93+
94+
def test_regular_property_is_ignored():
95+
class Container:
96+
@property
97+
def regular(self):
98+
return "live"
99+
100+
@functools.cached_property
101+
def cached(self):
102+
return "memoised"
103+
104+
assert cached_property_names(Container) == frozenset({"cached"})
105+
106+
107+
def test_result_is_memoised_on_the_class():
108+
class Container:
109+
@functools.cached_property
110+
def derived(self):
111+
return "computed"
112+
113+
first = cached_property_names(Container)
114+
# The cache lives on the class itself, not a parent
115+
assert "__cached_property_names_cache__" in Container.__dict__
116+
cached = Container.__dict__["__cached_property_names_cache__"]
117+
assert cached is first
118+
119+
# Subsequent calls return the same object identity
120+
second = cached_property_names(Container)
121+
assert second is first
122+
123+
124+
def test_subclass_gets_its_own_cache_entry():
125+
class Base:
126+
@functools.cached_property
127+
def base_value(self):
128+
return 1
129+
130+
class Sub(Base):
131+
@functools.cached_property
132+
def sub_value(self):
133+
return 2
134+
135+
base_result = cached_property_names(Base)
136+
sub_result = cached_property_names(Sub)
137+
138+
assert base_result == frozenset({"base_value"})
139+
assert sub_result == frozenset({"base_value", "sub_value"})
140+
141+
# Each class owns its own cache entry — subclass cache does not
142+
# leak into the parent.
143+
assert (
144+
Sub.__dict__["__cached_property_names_cache__"]
145+
is not Base.__dict__["__cached_property_names_cache__"]
146+
)

0 commit comments

Comments
 (0)