Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions autoconf/tools/decorators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import functools

import numpy as np


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


cached_property = CachedProperty


def cached_property_names(cls) -> frozenset:
"""
Return the names of every ``cached_property``-style descriptor declared
anywhere in ``cls``'s MRO.

Recognises both stdlib :class:`functools.cached_property` and the
autoconf :class:`CachedProperty` wrapper above. Walks the MRO so
descriptors declared on base classes are picked up.

The first call for a given class walks the MRO and caches the resulting
frozenset on the class itself under ``__cached_property_names_cache__``;
subsequent calls return the cached frozenset directly. The cache key is
stored under a dunder name so the result itself never appears in any
instance ``__dict__`` walk.

Used by PyAutoFit and PyAutoArray to extend their existing
``__dict__``-iteration filters with a forward-compat guard: any future
``@cached_property`` declared on a model or Fit class will be
automatically excluded from instance construction, ``ModelInstance.dict``,
pickling, and JAX pytree flattening, preventing the class of bug that
PR PyAutoFit#1300 fixed for ``parameterization``.

Parameters
----------
cls
The class to inspect.

Returns
-------
A frozenset of attribute names corresponding to ``cached_property`` or
autoconf ``CachedProperty`` descriptors found in ``cls.__mro__``.
"""
cache = cls.__dict__.get("__cached_property_names_cache__")
if cache is not None:
return cache

names = set()
for base in cls.__mro__:
for attr_name, value in base.__dict__.items():
if isinstance(value, (functools.cached_property, CachedProperty)):
names.add(attr_name)

result = frozenset(names)
# Stash on the class itself (not a parent) so subclass overrides
# of cached_property descriptors are re-discovered on the subclass.
try:
setattr(cls, "__cached_property_names_cache__", result)
except (TypeError, AttributeError):
# Some classes (slotted, built-in) reject setattr; that's fine,
# the function still returns the correct value without memoisation.
pass
return result
146 changes: 146 additions & 0 deletions test_autoconf/test_cached_property_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Tests for autoconf.tools.decorators.cached_property_names — the
MRO-walking helper used by PyAutoFit and PyAutoArray to extend their
__dict__-iteration filters with a forward-compat guard against
cached_property pytree/dict leaks."""

import functools

import pytest

from autoconf.tools.decorators import (
CachedProperty,
cached_property,
cached_property_names,
)


def test_empty_class_returns_empty_frozenset():
class Empty:
pass

result = cached_property_names(Empty)
assert result == frozenset()
assert isinstance(result, frozenset)


def test_stdlib_cached_property_is_recognised():
class Container:
@functools.cached_property
def derived(self):
return "computed"

assert cached_property_names(Container) == frozenset({"derived"})


def test_autoconf_cached_property_is_recognised():
class Container:
@CachedProperty
def derived(self):
return "computed"

assert cached_property_names(Container) == frozenset({"derived"})


def test_autoconf_cached_property_alias_is_recognised():
"""``cached_property`` re-exported from autoconf.tools.decorators is
the same object as ``CachedProperty`` — verifies both spellings work."""

class Container:
@cached_property
def derived(self):
return "computed"

assert cached_property_names(Container) == frozenset({"derived"})


def test_mro_walk_picks_up_base_class_descriptors():
class Base:
@functools.cached_property
def base_value(self):
return 1

class Mid(Base):
@functools.cached_property
def mid_value(self):
return 2

class Leaf(Mid):
@functools.cached_property
def leaf_value(self):
return 3

assert cached_property_names(Leaf) == frozenset(
{"base_value", "mid_value", "leaf_value"}
)
assert cached_property_names(Mid) == frozenset({"base_value", "mid_value"})
assert cached_property_names(Base) == frozenset({"base_value"})


def test_mixed_stdlib_and_autoconf_descriptors_both_recognised():
class Container:
@functools.cached_property
def stdlib_value(self):
return 1

@CachedProperty
def autoconf_value(self):
return 2

assert cached_property_names(Container) == frozenset(
{"stdlib_value", "autoconf_value"}
)


def test_regular_property_is_ignored():
class Container:
@property
def regular(self):
return "live"

@functools.cached_property
def cached(self):
return "memoised"

assert cached_property_names(Container) == frozenset({"cached"})


def test_result_is_memoised_on_the_class():
class Container:
@functools.cached_property
def derived(self):
return "computed"

first = cached_property_names(Container)
# The cache lives on the class itself, not a parent
assert "__cached_property_names_cache__" in Container.__dict__
cached = Container.__dict__["__cached_property_names_cache__"]
assert cached is first

# Subsequent calls return the same object identity
second = cached_property_names(Container)
assert second is first


def test_subclass_gets_its_own_cache_entry():
class Base:
@functools.cached_property
def base_value(self):
return 1

class Sub(Base):
@functools.cached_property
def sub_value(self):
return 2

base_result = cached_property_names(Base)
sub_result = cached_property_names(Sub)

assert base_result == frozenset({"base_value"})
assert sub_result == frozenset({"base_value", "sub_value"})

# Each class owns its own cache entry — subclass cache does not
# leak into the parent.
assert (
Sub.__dict__["__cached_property_names_cache__"]
is not Base.__dict__["__cached_property_names_cache__"]
)
Loading