From 771ca5792f13e67e711751c4f2deec36faa171ff Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:53:23 +0530 Subject: [PATCH] Support pickling cached_property descriptors on Python 3.8-3.11 --- CHANGES.md | 3 ++ cloudpickle/cloudpickle.py | 11 +++++ tests/cloudpickle_test.py | 86 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a6b0b443..ea1db89c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,9 @@ In development ============== +- Support pickling `functools.cached_property` descriptors on Python 3.8-3.11. + ([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..3ec10f5c 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,15 @@ def _property_reduce(obj): return property, (obj.fget, obj.fset, obj.fdel, obj.__doc__) +def _cached_property_reduce(obj): + # Recreate the descriptor's lock (Python < 3.12), not its locked state. + state = obj.__dict__.copy() + state.pop("func") + if sys.version_info < (3, 12): + state.pop("lock", None) + return functools.cached_property, (obj.func,), state + + def _weakset_reduce(obj): return weakref.WeakSet, (list(obj),) @@ -1244,6 +1254,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..ce969146 100644 --- a/tests/cloudpickle_test.py +++ b/tests/cloudpickle_test.py @@ -1640,6 +1640,92 @@ 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(self): + class MyObject: + def __init__(self, value): + self.value = value + self.calls = 0 + + @functools.cached_property + def doubled(self): + "A cached result" + self.calls += 1 + return self.value * 2 + + original = MyObject(3) + clone = subprocess_pickle_echo(original, protocol=self.protocol) + assert clone.doubled == 6 + clone.value = 10 + assert clone.doubled == 6 + assert clone.calls == 1 + del clone.doubled + assert clone.doubled == 20 + assert clone.calls == 2 + assert type(clone).doubled.__doc__ == "A cached result" + + original.doubled + original.value = 10 + clone = subprocess_pickle_echo(original, protocol=self.protocol) + assert clone.doubled == 6 + assert clone.calls == 1 + + def test_cached_property_descriptor(self): + def func(obj): + "Original documentation" + return obj.value * 2 + + descriptor = functools.cached_property(func) + descriptor.__set_name__(object, "doubled") + descriptor.__doc__ = "Updated documentation" + descriptor.extra = [1, 2] + if sys.version_info >= (3, 12): + descriptor.lock = "a user-defined attribute" + clone, repeated = subprocess_pickle_echo( + [descriptor, descriptor], protocol=self.protocol + ) + assert clone is repeated + assert clone.attrname == "doubled" + assert clone.__doc__ == "Updated documentation" + assert clone.extra == [1, 2] + if sys.version_info < (3, 12): + assert clone.lock is not descriptor.lock + else: + assert clone.lock == "a user-defined attribute" + obj = types.SimpleNamespace(value=4) + assert clone.__get__(obj) == 8 + obj.value = 10 + assert clone.__get__(obj) == 8 + + def test_cached_property_unbound(self): + descriptor = functools.cached_property(lambda obj: 42) + clone = subprocess_pickle_echo(descriptor, protocol=self.protocol) + assert clone.attrname is None + with pytest.raises(TypeError, match="__set_name__"): + clone.__get__(types.SimpleNamespace()) + clone.__set_name__(object, "answer") + assert clone.__get__(types.SimpleNamespace()) == 42 + + def test_cached_property_class_in_fresh_process(self): + script = """ + import functools + import cloudpickle + import pickle + import subprocess + import sys + + class MyObject: + @functools.cached_property + def myself(self): + return MyObject + + payload = cloudpickle.dumps(MyObject, protocol={protocol}) + code = "import pickle,sys; C=pickle.loads(sys.stdin.buffer.read()); " + code += "obj=C(); assert obj.myself is C; " + code += "assert obj.__dict__['myself'] is C" + subprocess.run([sys.executable, "-c", code], input=payload, check=True) + """ + assert_run_python_script(textwrap.dedent(script.format(protocol=self.protocol))) + def test_namedtuple(self): MyTuple = collections.namedtuple("MyTuple", ["a", "b", "c"]) t1 = MyTuple(1, 2, 3)