|
| 1 | +import functools |
1 | 2 | import itertools |
2 | 3 |
|
3 | 4 | import pytest |
@@ -176,6 +177,92 @@ def test_parameterization_cache_does_not_leak_into_instance(): |
176 | 177 | assert not isinstance(child, str) |
177 | 178 |
|
178 | 179 |
|
| 180 | +def test_cached_property_names_classmethod_walks_mro(): |
| 181 | + """The ``_cached_property_names`` classmethod on AbstractModel exposes the |
| 182 | + autoconf ``cached_property_names`` MRO walker. It must pick up |
| 183 | + descriptors declared on any ancestor and memoise the result on the class.""" |
| 184 | + |
| 185 | + import functools |
| 186 | + |
| 187 | + import autofit as af |
| 188 | + |
| 189 | + # Build a synthetic subclass with a cached_property to verify the walker |
| 190 | + # finds it. We use af.Collection because both AbstractPriorModel and |
| 191 | + # ModelInstance inherit from AbstractModel. |
| 192 | + class SyntheticCollection(af.Collection): |
| 193 | + @functools.cached_property |
| 194 | + def synthetic_value(self): |
| 195 | + return "a synthetic cached string" |
| 196 | + |
| 197 | + names = SyntheticCollection._cached_property_names() |
| 198 | + assert "synthetic_value" in names |
| 199 | + |
| 200 | + # Result is memoised on the synthetic class. |
| 201 | + assert "__cached_property_names_cache__" in SyntheticCollection.__dict__ |
| 202 | + |
| 203 | + # Plain af.Collection (no synthetic_value) has its own cache. |
| 204 | + base_names = af.Collection._cached_property_names() |
| 205 | + assert "synthetic_value" not in base_names |
| 206 | + |
| 207 | + |
| 208 | +class _GuardedCollection(af.Collection): |
| 209 | + """Module-level subclass used by |
| 210 | + ``test_cached_property_excluded_from_all_dict_walks`` — must live at |
| 211 | + module scope so ``pickle.dumps`` can locate the class on round-trip.""" |
| 212 | + |
| 213 | + @functools.cached_property |
| 214 | + def derived(self): |
| 215 | + return "leaky-string" |
| 216 | + |
| 217 | + |
| 218 | +def test_cached_property_excluded_from_all_dict_walks(): |
| 219 | + """Regression: a future ``@functools.cached_property`` declared anywhere |
| 220 | + in the model class hierarchy must not surface through any of: |
| 221 | + ``Collection._instance_for_arguments`` (via ``instance.__dict__``), |
| 222 | + ``ModelInstance.dict``, ``ModelInstance.tree_flatten()``, |
| 223 | + ``AbstractModel.items()``, ``ModelObject._dict``, or pickling via |
| 224 | + ``__getstate__``. |
| 225 | +
|
| 226 | + Covers the class of bug PyAutoFit#1300 fixed for ``parameterization``; |
| 227 | + this test will fail if a maintainer reintroduces an un-prefixed |
| 228 | + cached_property on the model hierarchy without the |
| 229 | + ``_cached_property_names`` defense applied at every site.""" |
| 230 | + |
| 231 | + import pickle |
| 232 | + |
| 233 | + model = _GuardedCollection(gaussian=af.Model(af.ex.Gaussian)) |
| 234 | + |
| 235 | + # Trigger the cache. After this, model.__dict__["derived"] = "leaky-string". |
| 236 | + _ = model.derived |
| 237 | + assert model.__dict__.get("derived") == "leaky-string" |
| 238 | + |
| 239 | + instance = model.instance_from_prior_medians() |
| 240 | + |
| 241 | + # Site 1+4: Collection._instance_for_arguments + ModelInstance.dict |
| 242 | + assert "derived" not in instance.__dict__ |
| 243 | + assert "derived" not in instance.dict |
| 244 | + |
| 245 | + # Site 4 also feeds tree_flatten — no string leaves. |
| 246 | + leaves = instance.dict.values() |
| 247 | + for leaf in leaves: |
| 248 | + assert not isinstance(leaf, str) |
| 249 | + |
| 250 | + # Site 3: AbstractModel.items() on the model itself. |
| 251 | + assert all(key != "derived" for key, _ in model.items()) |
| 252 | + |
| 253 | + # Site 5: __getstate__ drops the cached value from pickles. |
| 254 | + state = model.__getstate__() |
| 255 | + assert "derived" not in state |
| 256 | + |
| 257 | + # Round-trip via pickle: the unpickled model re-computes the cached value, |
| 258 | + # rather than carrying the pickled string on the wire. |
| 259 | + blob = pickle.dumps(model) |
| 260 | + revived = pickle.loads(blob) |
| 261 | + assert "derived" not in revived.__dict__ |
| 262 | + # Touching it recomputes. |
| 263 | + assert revived.derived == "leaky-string" |
| 264 | + |
| 265 | + |
179 | 266 | def test_integer_attributes(): |
180 | 267 | model = af.Model(af.ex.Gaussian) |
181 | 268 |
|
|
0 commit comments