From 14e21d747453a3aa3d1aa92c0ebe0d51e8ea1f4f Mon Sep 17 00:00:00 2001 From: arindamsikder <88596876+arindamsikder@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:48:25 +0530 Subject: [PATCH] fix: support pickling cached properties Recreate pre-3.12 descriptor locks while preserving descriptor state and instance caches. Regression coverage includes default and protocol 2 roundtrips. Based on the reducer approach in charan-rathore's closed PR #605 and pierreglaser's guidance in #438. AI-assisted implementation and independent automated review; no human pre-review claimed. --- CHANGES.md | 4 ++ cloudpickle/cloudpickle.py | 12 +++++ tests/cloudpickle_test.py | 99 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a6b0b443..f1f5da10 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,10 @@ In development ============== +- Support pickling classes and instances with `functools.cached_property` on + Python versions before 3.12. + ([issue #438](https://github.com/cloudpipe/cloudpickle/issues/438)) + - Make pickling of functions depending on globals in notebook more deterministic. ([PR#560](https://github.com/cloudpipe/cloudpickle/pull/560)) diff --git a/cloudpickle/cloudpickle.py b/cloudpickle/cloudpickle.py index 08882306..0514b166 100644 --- a/cloudpickle/cloudpickle.py +++ b/cloudpickle/cloudpickle.py @@ -58,6 +58,7 @@ import dataclasses import dis from enum import Enum +import functools import io import itertools import logging @@ -1049,6 +1050,16 @@ def _property_reduce(obj): return property, (obj.fget, obj.fset, obj.fdel, obj.__doc__) +def _cached_property_reduce(obj): + state = obj.__dict__.copy() + state.pop("func") + if sys.version_info < (3, 12): + # Recreate the internal lock rather than pickling its ownership state. + # On newer Python versions, a "lock" attribute is user-defined state. + state.pop("lock", None) + return functools.cached_property, (obj.func,), state + + def _weakset_reduce(obj): return weakref.WeakSet, (list(obj),) @@ -1244,6 +1255,7 @@ class Pickler(pickle.Pickler): _dispatch_table[logging.RootLogger] = _root_logger_reduce _dispatch_table[memoryview] = _memoryview_reduce _dispatch_table[property] = _property_reduce + _dispatch_table[functools.cached_property] = _cached_property_reduce _dispatch_table[staticmethod] = _classmethod_reduce _dispatch_table[CellType] = _cell_reduce _dispatch_table[types.CodeType] = _code_reduce diff --git a/tests/cloudpickle_test.py b/tests/cloudpickle_test.py index e2097d1c..a9cc5ba7 100644 --- a/tests/cloudpickle_test.py +++ b/tests/cloudpickle_test.py @@ -1640,6 +1640,105 @@ def read_write_value(self, value): assert depickled_obj.read_write_value == 3 type(depickled_obj).read_only_value.__doc__ == "A read-only attribute" + def test_cached_property_descriptor(self): + def getter(obj): + "Original documentation" + return obj.value * 3 + + descriptor = functools.cached_property(getter) + descriptor.__set_name__(object, "tripled") + descriptor.__doc__ = "Custom documentation" + descriptor.metadata = {"units": "widgets"} + descriptor.reference = descriptor + if sys.version_info >= (3, 12): + descriptor.lock = "user-defined lock attribute" + + clone, repeated = pickle_depickle( + [descriptor, descriptor], protocol=self.protocol + ) + assert clone is repeated + assert clone.reference is clone + assert clone is not descriptor + assert clone.attrname == "tripled" + assert clone.__doc__ == "Custom documentation" + assert clone.metadata == descriptor.metadata + assert clone.func(types.SimpleNamespace(value=2)) == 6 + if sys.version_info < (3, 12): + assert clone.lock is not descriptor.lock + with clone.lock: + pass + else: + assert clone.lock == descriptor.lock + + def test_cached_property_class(self): + class Cached: + calls = 0 + + @functools.cached_property + def value(self): + "Computed once per instance" + self.calls += 1 + return self.calls * 3 + + payload = cloudpickle.dumps([Cached, Cached], protocol=self.protocol) + # A fresh interpreter cannot reuse the original dynamic class. + code = f"""\ + import pickle + + cls, repeated = pickle.loads({payload!r}) + assert cls is repeated + assert cls.value.attrname == 'value' + assert cls.value.__doc__ == 'Computed once per instance' + instance = cls() + assert instance.value == 3 + assert instance.value == 3 + assert instance.calls == 1 + del instance.value + assert instance.value == 6 + assert instance.calls == 2 + """ + assert_run_python_script(textwrap.dedent(code)) + + def test_cached_property_instance(self): + class Cached: + def __init__(self): + self.calls = 0 + + @functools.cached_property + def value(self): + self.calls += 1 + return self.calls * 3 + + original = Cached() + uncached = subprocess_pickle_echo(original, protocol=self.protocol) + assert uncached.calls == 0 + assert "value" not in uncached.__dict__ + assert uncached.value == 3 + assert uncached.calls == 1 + + assert original.value == 3 + original.calls = 10 + cached = subprocess_pickle_echo(original, protocol=self.protocol) + assert cached.value == 3 + assert cached.calls == 10 + del cached.value + assert cached.value == 33 + assert cached.calls == 11 + assert original.value == 3 + assert original.calls == 10 + + def test_cached_property_unbound(self): + descriptor = functools.cached_property(lambda obj: 42) + clone = pickle_depickle(descriptor, protocol=self.protocol) + assert clone.attrname is None + + class Cached: + answer = clone + + assert clone.attrname == "answer" + assert Cached().answer == 42 + assert descriptor.attrname is None + def test_namedtuple(self): MyTuple = collections.namedtuple("MyTuple", ["a", "b", "c"]) t1 = MyTuple(1, 2, 3)