Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
11 changes: 11 additions & 0 deletions cloudpickle/cloudpickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import dataclasses
import dis
from enum import Enum
import functools
import io
import itertools
import logging
Expand Down Expand Up @@ -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),)

Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/cloudpickle_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down