Skip to content

Commit 215d3dd

Browse files
valtronogriselpierreglaser
authored
Fix and expand the support of typing.TypeVar for Python >=3.5 (#350)
Non-importable, dynamic `TypeVar` were pickled as importable objects in Python 3.7 -- this PR fixes it. Also, this PR adds support to pickle `TypeVar` objects on Python 3.5 and Python 3.6. Co-authored-by: Olivier Grisel <[email protected]> Co-authored-by: Pierre Glaser <[email protected]>
1 parent 059e5f3 commit 215d3dd

File tree

5 files changed

+122
-15
lines changed

5 files changed

+122
-15
lines changed

Diff for: CHANGES.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
(follow up on #276)
88
([issue #347](https://github.com/cloudpipe/cloudpickle/issues/347))
99

10+
- Fix a bug affecting the pickling of dynamic `TypeVar` instances on Python 3.7+,
11+
and expand the support for pickling `TypeVar` instances (dynamic or non-dynamic)
12+
to Python 3.5-3.6 ([PR #350](https://github.com/cloudpipe/cloudpickle/pull/350))
13+
1014
1.3.0
1115
=====
1216

Diff for: cloudpickle/cloudpickle.py

+65-11
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import weakref
5959
import uuid
6060
import threading
61+
import typing
6162
from enum import Enum
6263

6364
from pickle import _Pickler as Pickler
@@ -116,7 +117,7 @@ def _whichmodule(obj, name):
116117
- Errors arising during module introspection are ignored, as those errors
117118
are considered unwanted side effects.
118119
"""
119-
module_name = getattr(obj, '__module__', None)
120+
module_name = _get_module_attr(obj)
120121
if module_name is not None:
121122
return module_name
122123
# Protect the iteration by using a copy of sys.modules against dynamic
@@ -139,22 +140,46 @@ def _whichmodule(obj, name):
139140
return None
140141

141142

142-
def _is_global(obj, name=None):
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+
160+
def _is_importable_by_name(obj, name=None):
143161
"""Determine if obj can be pickled as attribute of a file-backed module"""
162+
return _lookup_module_and_qualname(obj, name=name) is not None
163+
164+
165+
def _lookup_module_and_qualname(obj, name=None):
144166
if name is None:
145167
name = getattr(obj, '__qualname__', None)
146-
if name is None:
168+
if name is None: # pragma: no cover
169+
# This used to be needed for Python 2.7 support but is probably not
170+
# needed anymore. However we keep the __name__ introspection in case
171+
# users of cloudpickle rely on this old behavior for unknown reasons.
147172
name = getattr(obj, '__name__', None)
148173

149174
module_name = _whichmodule(obj, name)
150175

151176
if module_name is None:
152177
# In this case, obj.__module__ is None AND obj was not found in any
153178
# imported module. obj is thus treated as dynamic.
154-
return False
179+
return None
155180

156181
if module_name == "__main__":
157-
return False
182+
return None
158183

159184
module = sys.modules.get(module_name, None)
160185
if module is None:
@@ -163,18 +188,20 @@ def _is_global(obj, name=None):
163188
# types.ModuleType. The other possibility is that module was removed
164189
# from sys.modules after obj was created/imported. But this case is not
165190
# supported, as the standard pickle does not support it either.
166-
return False
191+
return None
167192

168193
# module has been added to sys.modules, but it can still be dynamic.
169194
if _is_dynamic(module):
170-
return False
195+
return None
171196

172197
try:
173198
obj2, parent = _getattribute(module, name)
174199
except AttributeError:
175200
# obj was not found inside the module it points to
176-
return False
177-
return obj2 is obj
201+
return None
202+
if obj2 is not obj:
203+
return None
204+
return module, name
178205

179206

180207
def _extract_code_globals(co):
@@ -418,6 +445,11 @@ def dump(self, obj):
418445
else:
419446
raise
420447

448+
def save_typevar(self, obj):
449+
self.save_reduce(*_typevar_reduce(obj))
450+
451+
dispatch[typing.TypeVar] = save_typevar
452+
421453
def save_memoryview(self, obj):
422454
self.save(obj.tobytes())
423455

@@ -467,7 +499,7 @@ def save_function(self, obj, name=None):
467499
Determines what kind of function obj is (e.g. lambda, defined at
468500
interactive prompt, etc) and handles the pickling appropriately.
469501
"""
470-
if _is_global(obj, name=name):
502+
if _is_importable_by_name(obj, name=name):
471503
return Pickler.save_global(self, obj, name=name)
472504
elif PYPY and isinstance(obj.__code__, builtin_code_type):
473505
return self.save_pypy_builtin_func(obj)
@@ -770,7 +802,7 @@ def save_global(self, obj, name=None, pack=struct.pack):
770802
_builtin_type, (_BUILTIN_TYPE_NAMES[obj],), obj=obj)
771803
elif name is not None:
772804
Pickler.save_global(self, obj, name=name)
773-
elif not _is_global(obj, name=name):
805+
elif not _is_importable_by_name(obj, name=name):
774806
self.save_dynamic_class(obj)
775807
else:
776808
Pickler.save_global(self, obj, name=name)
@@ -1214,3 +1246,25 @@ def _is_dynamic(module):
12141246
else:
12151247
pkgpath = None
12161248
return _find_spec(module.__name__, pkgpath, module) is None
1249+
1250+
1251+
def _make_typevar(name, bound, constraints, covariant, contravariant):
1252+
return typing.TypeVar(
1253+
name, *constraints, bound=bound,
1254+
covariant=covariant, contravariant=contravariant
1255+
)
1256+
1257+
1258+
def _decompose_typevar(obj):
1259+
return (
1260+
obj.__name__, obj.__bound__, obj.__constraints__,
1261+
obj.__covariant__, obj.__contravariant__,
1262+
)
1263+
1264+
1265+
def _typevar_reduce(obj):
1266+
# TypeVar instances have no __qualname__ hence we pass the name explicitly.
1267+
module_and_name = _lookup_module_and_qualname(obj, name=obj.__name__)
1268+
if module_and_name is None:
1269+
return (_make_typevar, _decompose_typevar(obj))
1270+
return (getattr, module_and_name)

Diff for: cloudpickle/cloudpickle_fast.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
import sys
2121
import types
2222
import weakref
23+
import typing
2324

2425
from _pickle import Pickler
2526

2627
from .cloudpickle import (
2728
_is_dynamic, _extract_code_globals, _BUILTIN_TYPE_NAMES, DEFAULT_PROTOCOL,
28-
_find_imported_submodules, _get_cell_contents, _is_global, _builtin_type,
29+
_find_imported_submodules, _get_cell_contents, _is_importable_by_name, _builtin_type,
2930
Enum, _ensure_tracking, _make_skeleton_class, _make_skeleton_enum,
30-
_extract_class_dict, dynamic_subimport, subimport
31+
_extract_class_dict, dynamic_subimport, subimport, _typevar_reduce,
3132
)
3233

3334
load, loads = _pickle.load, _pickle.loads
@@ -332,7 +333,7 @@ def _class_reduce(obj):
332333
return type, (NotImplemented,)
333334
elif obj in _BUILTIN_TYPE_NAMES:
334335
return _builtin_type, (_BUILTIN_TYPE_NAMES[obj],)
335-
elif not _is_global(obj):
336+
elif not _is_importable_by_name(obj):
336337
return _dynamic_class_reduce(obj)
337338
return NotImplemented
338339

@@ -422,6 +423,7 @@ class CloudPickler(Pickler):
422423
dispatch[types.MethodType] = _method_reduce
423424
dispatch[types.MappingProxyType] = _mappingproxy_reduce
424425
dispatch[weakref.WeakSet] = _weakset_reduce
426+
dispatch[typing.TypeVar] = _typevar_reduce
425427

426428
def __init__(self, file, protocol=None, buffer_callback=None):
427429
if protocol is None:
@@ -503,7 +505,7 @@ def _function_reduce(self, obj):
503505
As opposed to cloudpickle.py, There no special handling for builtin
504506
pypy functions because cloudpickle_fast is CPython-specific.
505507
"""
506-
if _is_global(obj):
508+
if _is_importable_by_name(obj):
507509
return NotImplemented
508510
else:
509511
return self._dynamic_function_reduce(obj)

Diff for: tests/cloudpickle_test.py

+45
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from cloudpickle.cloudpickle import _is_dynamic
4747
from cloudpickle.cloudpickle import _make_empty_cell, cell_set
4848
from cloudpickle.cloudpickle import _extract_class_dict, _whichmodule
49+
from cloudpickle.cloudpickle import _lookup_module_and_qualname
4950

5051
from .testutils import subprocess_pickle_echo
5152
from .testutils import assert_run_python_script
@@ -2110,11 +2111,55 @@ class LocallyDefinedClass:
21102111
reconstructed = pickle.loads(pickle_bytes, buffers=buffers)
21112112
np.testing.assert_allclose(reconstructed.data, data_instance.data)
21122113

2114+
def test_pickle_dynamic_typevar(self):
2115+
T = typing.TypeVar('T')
2116+
depickled_T = pickle_depickle(T, protocol=self.protocol)
2117+
attr_list = [
2118+
"__name__", "__bound__", "__constraints__", "__covariant__",
2119+
"__contravariant__"
2120+
]
2121+
for attr in attr_list:
2122+
assert getattr(T, attr) == getattr(depickled_T, attr)
2123+
2124+
def test_pickle_importable_typevar(self):
2125+
from .mypkg import T
2126+
T1 = pickle_depickle(T, protocol=self.protocol)
2127+
assert T1 is T
2128+
2129+
# Standard Library TypeVar
2130+
from typing import AnyStr
2131+
assert AnyStr is pickle_depickle(AnyStr, protocol=self.protocol)
2132+
21132133

21142134
class Protocol2CloudPickleTest(CloudPickleTest):
21152135

21162136
protocol = 2
21172137

21182138

2139+
def test_lookup_module_and_qualname_dynamic_typevar():
2140+
T = typing.TypeVar('T')
2141+
module_and_name = _lookup_module_and_qualname(T, name=T.__name__)
2142+
assert module_and_name is None
2143+
2144+
2145+
def test_lookup_module_and_qualname_importable_typevar():
2146+
from . import mypkg
2147+
T = mypkg.T
2148+
module_and_name = _lookup_module_and_qualname(T, name=T.__name__)
2149+
assert module_and_name is not None
2150+
module, name = module_and_name
2151+
assert module is mypkg
2152+
assert name == 'T'
2153+
2154+
2155+
def test_lookup_module_and_qualname_stdlib_typevar():
2156+
module_and_name = _lookup_module_and_qualname(typing.AnyStr,
2157+
name=typing.AnyStr.__name__)
2158+
assert module_and_name is not None
2159+
module, name = module_and_name
2160+
assert module is typing
2161+
assert name == 'AnyStr'
2162+
2163+
21192164
if __name__ == '__main__':
21202165
unittest.main()

Diff for: tests/mypkg/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import typing
12
from .mod import module_function
23

34

@@ -13,3 +14,4 @@ def __reduce__(self):
1314

1415

1516
some_singleton = _SingletonClass()
17+
T = typing.TypeVar('T')

0 commit comments

Comments
 (0)