Skip to content

Commit 853a430

Browse files
committed
Fix update_wrapper pickling with lazy annotations
1 parent f5199fe commit 853a430

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
In development
22
==============
33

4+
- Fix pickling of objects decorated with `functools.update_wrapper` when the
5+
wrapped callable uses Python 3.14 lazy annotations. ([issue #585](
6+
https://github.com/cloudpipe/cloudpickle/issues/585))
7+
48
- Make pickling of functions depending on globals in notebook more
59
deterministic. ([PR#560](https://github.com/cloudpipe/cloudpickle/pull/560))
610

cloudpickle/cloudpickle.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,73 @@ def _function_getstate(func):
749749
return state, slotstate
750750

751751

752+
def _is_copied_annotation_function(obj):
753+
"""Detect __annotate__ copied by functools.update_wrapper on Python 3.14+."""
754+
if sys.version_info < (3, 14):
755+
return False
756+
757+
try:
758+
obj_dict = obj.__dict__
759+
except Exception:
760+
return False
761+
if not isinstance(obj_dict, dict):
762+
return False
763+
764+
annotate = obj_dict.get("__annotate__")
765+
if not isinstance(annotate, types.FunctionType):
766+
return False
767+
768+
wrapped = obj_dict.get("__wrapped__")
769+
if wrapped is None:
770+
return False
771+
772+
if annotate.__name__ != "__annotate__":
773+
return False
774+
775+
annotate_module = getattr(annotate, "__module__", None)
776+
wrapped_module = getattr(wrapped, "__module__", None)
777+
if (
778+
annotate_module is not None
779+
and wrapped_module is not None
780+
and annotate_module != wrapped_module
781+
):
782+
return False
783+
784+
return True
785+
786+
787+
def _remove_key_from_state(state, key):
788+
if isinstance(state, dict):
789+
if key not in state:
790+
return state
791+
state = state.copy()
792+
state.pop(key, None)
793+
return state
794+
795+
if (
796+
isinstance(state, tuple)
797+
and len(state) == 2
798+
and isinstance(state[0], dict)
799+
and key in state[0]
800+
):
801+
state_dict = state[0].copy()
802+
state_dict.pop(key, None)
803+
return state_dict, state[1]
804+
805+
return state
806+
807+
808+
def _reduced_without_copied_annotation_function(obj, proto):
809+
"""Remove redundant __annotate__ copied from a wrapped callable."""
810+
rv = obj.__reduce_ex__(proto)
811+
if not isinstance(rv, tuple) or len(rv) < 3:
812+
return rv
813+
814+
state = rv[2]
815+
state = _remove_key_from_state(state, "__annotate__")
816+
return rv[:2] + (state,) + rv[3:]
817+
818+
752819
def _class_getstate(obj):
753820
clsdict = _extract_class_dict(obj)
754821
clsdict.pop("__weakref__", None)
@@ -1402,6 +1469,8 @@ def reducer_override(self, obj):
14021469
return _class_reduce(obj)
14031470
elif isinstance(obj, types.FunctionType):
14041471
return self._function_reduce(obj)
1472+
elif _is_copied_annotation_function(obj):
1473+
return _reduced_without_copied_annotation_function(obj, self.proto)
14051474
else:
14061475
# fallback to save_global, including the Pickler's
14071476
# dispatch_table

tests/cloudpickle_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2707,6 +2707,51 @@ class C(abc.ABC):
27072707
c2 = C2()
27082708
assert isinstance(c2, C2)
27092709

2710+
@pytest.mark.skipif(
2711+
sys.version_info < (3, 14),
2712+
reason="functools.update_wrapper copies __annotate__ starting in Python 3.14",
2713+
)
2714+
def test_update_wrapper_with_annotated_abc_method(self):
2715+
# see https://github.com/cloudpipe/cloudpickle/issues/585
2716+
class FuncWrapper:
2717+
def __init__(self, function):
2718+
self.function = function
2719+
functools.update_wrapper(self, self.function)
2720+
2721+
def __call__(self, *args, **kwargs):
2722+
return self.function(*args, **kwargs)
2723+
2724+
class AbstractClass(abc.ABC):
2725+
a: int
2726+
2727+
def method(self, arg: str) -> str:
2728+
return arg.upper()
2729+
2730+
wrapped = FuncWrapper(AbstractClass().method)
2731+
assert "__annotate__" in wrapped.__dict__
2732+
2733+
# Simulate CPython builds where the annotation function copied by
2734+
# update_wrapper is distinct from __wrapped__.__annotate__.
2735+
copied_annotate = wrapped.__dict__["__annotate__"]
2736+
wrapped.__annotate__ = types.FunctionType(
2737+
copied_annotate.__code__,
2738+
copied_annotate.__globals__,
2739+
copied_annotate.__name__,
2740+
copied_annotate.__defaults__,
2741+
copied_annotate.__closure__,
2742+
)
2743+
wrapped.__annotate__.__kwdefaults__ = copied_annotate.__kwdefaults__
2744+
wrapped.__annotate__.__qualname__ = copied_annotate.__qualname__
2745+
wrapped.__annotate__.__module__ = copied_annotate.__module__
2746+
assert wrapped.__annotate__ is not wrapped.__wrapped__.__annotate__
2747+
2748+
wrapped_clone = pickle_depickle(wrapped, protocol=self.protocol)
2749+
2750+
assert wrapped_clone("abc") == "ABC"
2751+
assert wrapped_clone.__name__ == "method"
2752+
assert "__annotate__" not in wrapped_clone.__dict__
2753+
assert wrapped_clone.__wrapped__.__annotations__ == {"arg": str, "return": str}
2754+
27102755
def test_function_annotations(self):
27112756
def f(a: int) -> str:
27122757
pass

0 commit comments

Comments
 (0)