Skip to content

Commit 1ba10a5

Browse files
authored
Pickling of generic annotations/types in 3.5+ (#318)
1 parent d8452cc commit 1ba10a5

File tree

4 files changed

+112
-117
lines changed

4 files changed

+112
-117
lines changed

Diff for: .github/workflows/testing.yml

+4
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ jobs:
6161
python -m pip install -r dev-requirements.txt
6262
python ci/install_coverage_subprocess_pth.py
6363
export
64+
- name: Install optional typing_extensions in Python 3.6
65+
shell: bash
66+
run: python -m pip install typing-extensions
67+
if: matrix.python_version == '3.6'
6468
- name: Display Python version
6569
shell: bash
6670
run: python -c "import sys; print(sys.version)"

Diff for: CHANGES.md

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
**This version requires Python 3.5 or later**
55

6+
- cloudpickle can now all pickle all constructs from the ``typing`` module
7+
and the ``typing_extensions`` library in Python 3.5+
8+
([PR #318](https://github.com/cloudpipe/cloudpickle/pull/318))
9+
610
- Stop pickling the annotations of a dynamic class for Python < 3.6
711
(follow up on #276)
812
([issue #347](https://github.com/cloudpipe/cloudpickle/issues/347))

Diff for: cloudpickle/cloudpickle.py

+65-26
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,18 @@
6161
import typing
6262
from enum import Enum
6363

64+
from typing import Generic, Union, Tuple, Callable, ClassVar
6465
from pickle import _Pickler as Pickler
6566
from pickle import _getattribute
6667
from io import BytesIO
6768
from importlib._bootstrap import _find_spec
6869

70+
try: # pragma: no branch
71+
import typing_extensions as _typing_extensions
72+
from typing_extensions import Literal, Final
73+
except ImportError:
74+
_typing_extensions = Literal = Final = None
75+
6976

7077
# cloudpickle is meant for inter process communication: we expect all
7178
# communicating processes to run the same Python version hence we favor
@@ -117,7 +124,18 @@ def _whichmodule(obj, name):
117124
- Errors arising during module introspection are ignored, as those errors
118125
are considered unwanted side effects.
119126
"""
120-
module_name = _get_module_attr(obj)
127+
if sys.version_info[:2] < (3, 7) and isinstance(obj, typing.TypeVar): # pragma: no branch # noqa
128+
# Workaround bug in old Python versions: prior to Python 3.7,
129+
# T.__module__ would always be set to "typing" even when the TypeVar T
130+
# would be defined in a different module.
131+
#
132+
# For such older Python versions, we ignore the __module__ attribute of
133+
# TypeVar instances and instead exhaustively lookup those instances in
134+
# all currently imported modules.
135+
module_name = None
136+
else:
137+
module_name = getattr(obj, '__module__', None)
138+
121139
if module_name is not None:
122140
return module_name
123141
# Protect the iteration by using a copy of sys.modules against dynamic
@@ -140,23 +158,6 @@ def _whichmodule(obj, name):
140158
return None
141159

142160

143-
if sys.version_info[:2] < (3, 7): # pragma: no branch
144-
# Workaround bug in old Python versions: prior to Python 3.7, T.__module__
145-
# would always be set to "typing" even when the TypeVar T would be defined
146-
# in a different module.
147-
#
148-
# For such older Python versions, we ignore the __module__ attribute of
149-
# TypeVar instances and instead exhaustively lookup those instances in all
150-
# currently imported modules via the _whichmodule function.
151-
def _get_module_attr(obj):
152-
if isinstance(obj, typing.TypeVar):
153-
return None
154-
return getattr(obj, '__module__', None)
155-
else:
156-
def _get_module_attr(obj):
157-
return getattr(obj, '__module__', None)
158-
159-
160161
def _is_importable_by_name(obj, name=None):
161162
"""Determine if obj can be pickled as attribute of a file-backed module"""
162163
return _lookup_module_and_qualname(obj, name=name) is not None
@@ -423,6 +424,18 @@ def _extract_class_dict(cls):
423424
return clsdict
424425

425426

427+
if sys.version_info[:2] < (3, 7): # pragma: no branch
428+
def _is_parametrized_type_hint(obj):
429+
# This is very cheap but might generate false positives.
430+
origin = getattr(obj, '__origin__', None) # typing Constructs
431+
values = getattr(obj, '__values__', None) # typing_extensions.Literal
432+
type_ = getattr(obj, '__type__', None) # typing_extensions.Final
433+
return origin is not None or values is not None or type_ is not None
434+
435+
def _create_parametrized_type_hint(origin, args):
436+
return origin[args]
437+
438+
426439
class CloudPickler(Pickler):
427440

428441
dispatch = Pickler.dispatch.copy()
@@ -611,11 +624,6 @@ def save_dynamic_class(self, obj):
611624
if isinstance(__dict__, property):
612625
type_kwargs['__dict__'] = __dict__
613626

614-
if sys.version_info < (3, 7):
615-
# Although annotations were added in Python 3.4, It is not possible
616-
# to properly pickle them until Python 3.7. (See #193)
617-
clsdict.pop('__annotations__', None)
618-
619627
save = self.save
620628
write = self.write
621629

@@ -715,9 +723,7 @@ def save_function_tuple(self, func):
715723
'doc': func.__doc__,
716724
'_cloudpickle_submodules': submodules
717725
}
718-
if hasattr(func, '__annotations__') and sys.version_info >= (3, 7):
719-
# Although annotations were added in Python3.4, It is not possible
720-
# to properly pickle them until Python3.7. (See #193)
726+
if hasattr(func, '__annotations__'):
721727
state['annotations'] = func.__annotations__
722728
if hasattr(func, '__qualname__'):
723729
state['qualname'] = func.__qualname__
@@ -800,6 +806,14 @@ def save_global(self, obj, name=None, pack=struct.pack):
800806
elif obj in _BUILTIN_TYPE_NAMES:
801807
return self.save_reduce(
802808
_builtin_type, (_BUILTIN_TYPE_NAMES[obj],), obj=obj)
809+
810+
if sys.version_info[:2] < (3, 7) and _is_parametrized_type_hint(obj): # noqa # pragma: no branch
811+
# Parametrized typing constructs in Python < 3.7 are not compatible
812+
# with type checks and ``isinstance`` semantics. For this reason,
813+
# it is easier to detect them using a duck-typing-based check
814+
# (``_is_parametrized_type_hint``) than to populate the Pickler's
815+
# dispatch with type-specific savers.
816+
self._save_parametrized_type_hint(obj)
803817
elif name is not None:
804818
Pickler.save_global(self, obj, name=name)
805819
elif not _is_importable_by_name(obj, name=name):
@@ -941,6 +955,31 @@ def inject_addons(self):
941955
"""Plug in system. Register additional pickling functions if modules already loaded"""
942956
pass
943957

958+
if sys.version_info < (3, 7): # pragma: no branch
959+
def _save_parametrized_type_hint(self, obj):
960+
# The distorted type check sematic for typing construct becomes:
961+
# ``type(obj) is type(TypeHint)``, which means "obj is a
962+
# parametrized TypeHint"
963+
if type(obj) is type(Literal): # pragma: no branch
964+
initargs = (Literal, obj.__values__)
965+
elif type(obj) is type(Final): # pragma: no branch
966+
initargs = (Final, obj.__type__)
967+
elif type(obj) is type(ClassVar):
968+
initargs = (ClassVar, obj.__type__)
969+
elif type(obj) in [type(Union), type(Tuple), type(Generic)]:
970+
initargs = (obj.__origin__, obj.__args__)
971+
elif type(obj) is type(Callable):
972+
args = obj.__args__
973+
if args[0] is Ellipsis:
974+
initargs = (obj.__origin__, args)
975+
else:
976+
initargs = (obj.__origin__, (list(args[:-1]), args[-1]))
977+
else: # pragma: no cover
978+
raise pickle.PicklingError(
979+
"Cloudpickle Error: Unknown type {}".format(type(obj))
980+
)
981+
self.save_reduce(_create_parametrized_type_hint, initargs, obj=obj)
982+
944983

945984
# Tornado support
946985

Diff for: tests/cloudpickle_test.py

+39-91
Original file line numberDiff line numberDiff line change
@@ -1787,9 +1787,6 @@ def g():
17871787

17881788
self.assertEqual(f2.__doc__, f.__doc__)
17891789

1790-
@unittest.skipIf(sys.version_info < (3, 7),
1791-
"Pickling type annotations isn't supported for py36 and "
1792-
"below.")
17931790
def test_wraps_preserves_function_annotations(self):
17941791
def f(x):
17951792
pass
@@ -1804,79 +1801,7 @@ def g(x):
18041801

18051802
self.assertEqual(f2.__annotations__, f.__annotations__)
18061803

1807-
@unittest.skipIf(sys.version_info >= (3, 7),
1808-
"pickling annotations is supported starting Python 3.7")
1809-
def test_function_annotations_silent_dropping(self):
1810-
# Because of limitations of typing module, cloudpickle does not pickle
1811-
# the type annotations of a dynamic function or class for Python < 3.7
1812-
1813-
class UnpicklableAnnotation:
1814-
# Mock Annotation metaclass that errors out loudly if we try to
1815-
# pickle one of its instances
1816-
def __reduce__(self):
1817-
raise Exception("not picklable")
1818-
1819-
unpickleable_annotation = UnpicklableAnnotation()
1820-
1821-
def f(a: unpickleable_annotation):
1822-
return a
1823-
1824-
with pytest.raises(Exception):
1825-
cloudpickle.dumps(f.__annotations__)
1826-
1827-
depickled_f = pickle_depickle(f, protocol=self.protocol)
1828-
assert depickled_f.__annotations__ == {}
1829-
1830-
@unittest.skipIf(sys.version_info >= (3, 7) or sys.version_info < (3, 6),
1831-
"pickling annotations is supported starting Python 3.7")
1832-
def test_class_annotations_silent_dropping(self):
1833-
# Because of limitations of typing module, cloudpickle does not pickle
1834-
# the type annotations of a dynamic function or class for Python < 3.7
1835-
1836-
# Pickling and unpickling must be done in different processes when
1837-
# testing dynamic classes (see #313)
1838-
1839-
code = '''if 1:
1840-
import cloudpickle
1841-
import sys
1842-
1843-
class UnpicklableAnnotation:
1844-
# Mock Annotation metaclass that errors out loudly if we try to
1845-
# pickle one of its instances
1846-
def __reduce__(self):
1847-
raise Exception("not picklable")
1848-
1849-
unpickleable_annotation = UnpicklableAnnotation()
1850-
1851-
class A:
1852-
a: unpickleable_annotation
1853-
1854-
try:
1855-
cloudpickle.dumps(A.__annotations__)
1856-
except Exception:
1857-
pass
1858-
else:
1859-
raise AssertionError
1860-
1861-
sys.stdout.buffer.write(cloudpickle.dumps(A, protocol={protocol}))
1862-
'''
1863-
cmd = [sys.executable, '-c', code.format(protocol=self.protocol)]
1864-
proc = subprocess.Popen(
1865-
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
1866-
)
1867-
proc.wait()
1868-
out, err = proc.communicate()
1869-
assert proc.returncode == 0, err
1870-
1871-
depickled_a = pickle.loads(out)
1872-
assert not hasattr(depickled_a, "__annotations__")
1873-
1874-
@unittest.skipIf(sys.version_info < (3, 7),
1875-
"Pickling type hints isn't supported for py36"
1876-
" and below.")
18771804
def test_type_hint(self):
1878-
# Try to pickle compound typing constructs. This would typically fail
1879-
# on Python < 3.7 (See #193)
18801805
t = typing.Union[list, int]
18811806
assert pickle_depickle(t) == t
18821807

@@ -2142,16 +2067,17 @@ def test_pickle_importable_typevar(self):
21422067
from typing import AnyStr
21432068
assert AnyStr is pickle_depickle(AnyStr, protocol=self.protocol)
21442069

2145-
@unittest.skipIf(sys.version_info < (3, 7),
2146-
"Pickling generics not supported below py37")
21472070
def test_generic_type(self):
21482071
T = typing.TypeVar('T')
21492072

21502073
class C(typing.Generic[T]):
21512074
pass
21522075

21532076
assert pickle_depickle(C, protocol=self.protocol) is C
2154-
assert pickle_depickle(C[int], protocol=self.protocol) is C[int]
2077+
2078+
# Identity is not part of the typing contract: only test for
2079+
# equality instead.
2080+
assert pickle_depickle(C[int], protocol=self.protocol) == C[int]
21552081

21562082
with subprocess_worker(protocol=self.protocol) as worker:
21572083

@@ -2170,33 +2096,55 @@ def check_generic(generic, origin, type_value):
21702096
assert check_generic(C[int], C, int) == "ok"
21712097
assert worker.run(check_generic, C[int], C, int) == "ok"
21722098

2173-
@unittest.skipIf(sys.version_info < (3, 7),
2174-
"Pickling type hints not supported below py37")
21752099
def test_locally_defined_class_with_type_hints(self):
21762100
with subprocess_worker(protocol=self.protocol) as worker:
21772101
for type_ in _all_types_to_test():
2178-
# The type annotation syntax causes a SyntaxError on Python 3.5
2179-
code = textwrap.dedent("""\
21802102
class MyClass:
2181-
attribute: type_
2182-
21832103
def method(self, arg: type_) -> type_:
21842104
return arg
2185-
""")
2186-
ns = {"type_": type_}
2187-
exec(code, ns)
2188-
MyClass = ns["MyClass"]
2105+
MyClass.__annotations__ = {'attribute': type_}
21892106

21902107
def check_annotations(obj, expected_type):
2191-
assert obj.__annotations__["attribute"] is expected_type
2192-
assert obj.method.__annotations__["arg"] is expected_type
2193-
assert obj.method.__annotations__["return"] is expected_type
2108+
assert obj.__annotations__["attribute"] == expected_type
2109+
assert obj.method.__annotations__["arg"] == expected_type
2110+
assert (
2111+
obj.method.__annotations__["return"] == expected_type
2112+
)
21942113
return "ok"
21952114

21962115
obj = MyClass()
21972116
assert check_annotations(obj, type_) == "ok"
21982117
assert worker.run(check_annotations, obj, type_) == "ok"
21992118

2119+
def test_generic_extensions(self):
2120+
typing_extensions = pytest.importorskip('typing_extensions')
2121+
2122+
objs = [
2123+
typing_extensions.Literal,
2124+
typing_extensions.Final,
2125+
typing_extensions.Literal['a'],
2126+
typing_extensions.Final[int],
2127+
]
2128+
2129+
for obj in objs:
2130+
depickled_obj = pickle_depickle(obj, protocol=self.protocol)
2131+
assert depickled_obj == obj
2132+
2133+
def test_class_annotations(self):
2134+
class C:
2135+
pass
2136+
C.__annotations__ = {'a': int}
2137+
2138+
C1 = pickle_depickle(C, protocol=self.protocol)
2139+
assert C1.__annotations__ == C.__annotations__
2140+
2141+
def test_function_annotations(self):
2142+
def f(a: int) -> str:
2143+
pass
2144+
2145+
f1 = pickle_depickle(f, protocol=self.protocol)
2146+
assert f1.__annotations__ == f.__annotations__
2147+
22002148

22012149
class Protocol2CloudPickleTest(CloudPickleTest):
22022150

0 commit comments

Comments
 (0)