Skip to content

Commit c55fec1

Browse files
Forward-port test_xpickle from Python 2 to 3.
1 parent 17b5be0 commit c55fec1

File tree

4 files changed

+354
-1
lines changed

4 files changed

+354
-1
lines changed

Lib/test/libregrtest/cmdline.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@
115115
116116
tzdata - Run tests that require timezone data.
117117
118+
xpickle - Test pickle and _pickle against Python 3.6, 3.7, 3.8
119+
and 3.9 to test backwards compatibility. These tests
120+
may take very long to complete.
121+
118122
To enable all resources except one, use '-uall,-<resource>'. For
119123
example, to run all the tests except for the gui tests, give the
120124
option '-uall,-gui'.
@@ -138,7 +142,7 @@
138142
# - tzdata: while needed to validate fully test_datetime, it makes
139143
# test_datetime too slow (15-20 min on some buildbots) and so is disabled by
140144
# default (see bpo-30822).
141-
RESOURCE_NAMES = ALL_RESOURCES + ('extralargefile', 'tzdata')
145+
RESOURCE_NAMES = ALL_RESOURCES + ('extralargefile', 'tzdata', 'xpickle')
142146

143147
class _ArgParser(argparse.ArgumentParser):
144148

Lib/test/test_xpickle.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# This test covers backwards compatibility with
2+
# previous version of Python by bouncing pickled objects through Python 3.6
3+
# and Python 3.9 by running xpickle_worker.py.
4+
import pathlib
5+
import pickle
6+
import subprocess
7+
import sys
8+
9+
10+
from test import support
11+
from test import pickletester
12+
from test.test_pickle import PyPicklerTests
13+
14+
try:
15+
import _pickle
16+
has_c_implementation = True
17+
except ModuleNotFoundError:
18+
has_c_implementation = False
19+
20+
is_windows = sys.platform.startswith('win')
21+
22+
# Map python version to a tuple containing the name of a corresponding valid
23+
# Python binary to execute and its arguments.
24+
py_executable_map = {}
25+
26+
def highest_proto_for_py_version(py_version):
27+
"""Finds the highest supported pickle protocol for a given Python version.
28+
Args:
29+
py_version: a 2-tuple of the major, minor version. Eg. Python 3.7 would
30+
be (3, 7)
31+
Returns:
32+
int for the highest supported pickle protocol
33+
"""
34+
major = sys.version_info.major
35+
minor = sys.version_info.minor
36+
# Versions older than py 3 only supported up until protocol 2.
37+
if py_version < (3, 0):
38+
return 2
39+
elif py_version < (3, 4):
40+
return 3
41+
elif py_version < (3, 8):
42+
return 4
43+
elif py_version <= (major, minor):
44+
return 5
45+
else:
46+
# Safe option.
47+
return 2
48+
49+
def have_python_version(py_version):
50+
"""Check whether a Python binary exists for the given py_version and has
51+
support. This respects your PATH.
52+
For Windows, it will first try to use the py launcher specified in PEP 397.
53+
Otherwise (and for all other platforms), it will attempt to check for
54+
python<py_version[0]>.<py_version[1]>.
55+
56+
Eg. given a *py_version* of (3, 7), the function will attempt to try
57+
'py -3.7' (for Windows) first, then 'python3.7', and return
58+
['py', '-3.7'] (on Windows) or ['python3.7'] on other platforms.
59+
60+
Args:
61+
py_version: a 2-tuple of the major, minor version. Eg. python 3.7 would
62+
be (3, 7)
63+
Returns:
64+
List/Tuple containing the Python binary name and its required arguments,
65+
or None if no valid binary names found.
66+
"""
67+
python_str = ".".join(map(str, py_version))
68+
targets = [('py', f'-{python_str}'), (f'python{python_str}',)]
69+
if py_version not in py_executable_map:
70+
for target in targets[0 if is_windows else 1:]:
71+
worker = subprocess.Popen([*target, '-c','import test.support'],
72+
shell=is_windows)
73+
worker.communicate()
74+
if worker.returncode == 0:
75+
py_executable_map[py_version] = target
76+
77+
return py_executable_map.get(py_version, None)
78+
79+
80+
81+
class AbstractCompatTests(PyPicklerTests):
82+
py_version = None
83+
_OLD_HIGHEST_PROTOCOL = pickle.HIGHEST_PROTOCOL
84+
85+
def setUp(self):
86+
self.assertTrue(self.py_version)
87+
if not have_python_version(self.py_version):
88+
py_version_str = ".".join(map(str, self.py_version))
89+
self.skipTest(f'Python {py_version_str} not available')
90+
91+
# Override the default pickle protocol to match what xpickle worker
92+
# will be running.
93+
highest_protocol = highest_proto_for_py_version(self.py_version)
94+
pickletester.protocols = range(highest_protocol + 1)
95+
pickle.HIGHEST_PROTOCOL = highest_protocol
96+
97+
def tearDown(self):
98+
# Set the highest protocol back to the default.
99+
pickletester.protocols = range(pickle.HIGHEST_PROTOCOL + 1)
100+
pickle.HIGHEST_PROTOCOL = self._OLD_HIGHEST_PROTOCOL
101+
102+
@staticmethod
103+
def send_to_worker(python, obj, proto, **kwargs):
104+
"""Bounce a pickled object through another version of Python.
105+
This will pickle the object, send it to a child process where it will
106+
be unpickled, then repickled and sent back to the parent process.
107+
Args:
108+
python: list containing the python binary to start and its arguments
109+
obj: object to pickle.
110+
proto: pickle protocol number to use.
111+
kwargs: other keyword arguments to pass into pickle.dumps()
112+
Returns:
113+
The pickled data received from the child process.
114+
"""
115+
target = pathlib.Path(__file__).parent / 'xpickle_worker.py'
116+
data = super().dumps((proto, obj), proto, **kwargs)
117+
worker = subprocess.Popen([*python, target],
118+
stdin=subprocess.PIPE,
119+
stdout=subprocess.PIPE,
120+
stderr=subprocess.PIPE,
121+
# For windows bpo-17023.
122+
shell=is_windows)
123+
stdout, stderr = worker.communicate(data)
124+
if worker.returncode == 0:
125+
return stdout
126+
# if the worker fails, it will write the exception to stdout
127+
try:
128+
exception = pickle.loads(stdout)
129+
except (pickle.UnpicklingError, EOFError):
130+
raise RuntimeError(stderr)
131+
else:
132+
if isinstance(exception, Exception):
133+
# To allow for tests which test for errors.
134+
raise exception
135+
else:
136+
raise RuntimeError(stderr)
137+
138+
139+
def dumps(self, arg, proto=0, **kwargs):
140+
# Skip tests that require buffer_callback arguments since
141+
# there isn't a reliable way to marshal/pickle the callback and ensure
142+
# it works in a different Python version.
143+
if 'buffer_callback' in kwargs:
144+
self.skipTest('Test does not support "buffer_callback" argument.')
145+
python = py_executable_map[self.py_version]
146+
return self.send_to_worker(python, arg, proto, **kwargs)
147+
148+
@staticmethod
149+
def loads(*args, **kwargs):
150+
return super().loads(*args, **kwargs)
151+
152+
# A scaled-down version of test_bytes from pickletester, to reduce
153+
# the number of calls to self.dumps() and hence reduce the number of
154+
# child python processes forked. This allows the test to complete
155+
# much faster (the one from pickletester takes 3-4 minutes when running
156+
# under text_xpickle).
157+
def test_bytes(self):
158+
for proto in pickletester.protocols:
159+
for s in b'', b'xyz', b'xyz'*100:
160+
p = self.dumps(s, proto)
161+
self.assert_is_copy(s, self.loads(p))
162+
s = bytes(range(256))
163+
p = self.dumps(s, proto)
164+
self.assert_is_copy(s, self.loads(p))
165+
s = bytes([i for i in range(256) for _ in range(2)])
166+
p = self.dumps(s, proto)
167+
self.assert_is_copy(s, self.loads(p))
168+
169+
# These tests are disabled because they require some special setup
170+
# on the worker that's hard to keep in sync.
171+
test_global_ext1 = None
172+
test_global_ext2 = None
173+
test_global_ext4 = None
174+
175+
# Backwards compatibility was explicitly broken in r67934 to fix a bug.
176+
test_unicode_high_plane = None
177+
178+
# These tests fail because they require classes from pickletester
179+
# which cannot be properly imported by the xpickle worker.
180+
test_c_methods = None
181+
test_py_methods = None
182+
test_nested_names = None
183+
184+
test_recursive_dict_key = None
185+
test_recursive_nested_names = None
186+
test_recursive_set = None
187+
188+
# Attribute lookup problems are expected, disable the test
189+
test_dynamic_class = None
190+
191+
# Base class for tests using Python 3.7 and earlier
192+
class CompatLowerPython37(AbstractCompatTests):
193+
# Python versions 3.7 and earlier are incompatible with these tests:
194+
195+
# This version does not support buffers
196+
test_in_band_buffers = None
197+
198+
199+
# Base class for tests using Python 3.6 and earlier
200+
class CompatLowerPython36(CompatLowerPython37):
201+
# Python versions 3.6 and earlier are incompatible with these tests:
202+
# This version has changes in framing using protocol 4
203+
test_framing_large_objects = None
204+
205+
# These fail for protocol 0
206+
test_simple_newobj = None
207+
test_complex_newobj = None
208+
test_complex_newobj_ex = None
209+
210+
211+
# Test backwards compatibility with Python 3.6.
212+
class PicklePython36Compat(CompatLowerPython36):
213+
py_version = (3, 6)
214+
215+
# Test backwards compatibility with Python 3.7.
216+
class PicklePython37Compat(CompatLowerPython37):
217+
py_version = (3, 7)
218+
219+
# Test backwards compatibility with Python 3.8.
220+
class PicklePython38Compat(AbstractCompatTests):
221+
py_version = (3, 8)
222+
223+
# Test backwards compatibility with Python 3.9.
224+
class PicklePython39Compat(AbstractCompatTests):
225+
py_version = (3, 9)
226+
227+
228+
if has_c_implementation:
229+
class CPicklePython36Compat(PicklePython36Compat):
230+
pickler = pickle._Pickler
231+
unpickler = pickle._Unpickler
232+
233+
class CPicklePython37Compat(PicklePython37Compat):
234+
pickler = pickle._Pickler
235+
unpickler = pickle._Unpickler
236+
237+
class CPicklePython38Compat(PicklePython38Compat):
238+
pickler = pickle._Pickler
239+
unpickler = pickle._Unpickler
240+
241+
class CPicklePython39Compat(PicklePython39Compat):
242+
pickler = pickle._Pickler
243+
unpickler = pickle._Unpickler
244+
245+
def test_main():
246+
support.requires('xpickle')
247+
tests = [PicklePython36Compat,
248+
PicklePython37Compat, PicklePython38Compat,
249+
PicklePython39Compat]
250+
if has_c_implementation:
251+
tests.extend([CPicklePython36Compat,
252+
CPicklePython37Compat, CPicklePython38Compat,
253+
CPicklePython39Compat])
254+
support.run_unittest(*tests)
255+
256+
257+
if __name__ == '__main__':
258+
test_main()

Lib/test/xpickle_worker.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# This script is called by test_xpickle as a subprocess to load and dump
2+
# pickles in a different Python version.
3+
import importlib.util
4+
import os
5+
import pickle
6+
import sys
7+
8+
9+
# This allows the xpickle worker to import pickletester.py, which it needs
10+
# since some of the pickle objects hold references to pickletester.py.
11+
# Also provides the test library over the platform's native one since
12+
# pickletester requires some test.support functions (such as os_helper)
13+
# which are not available in versions below Python 3.10.
14+
test_mod_path = os.path.abspath(os.path.join(os.path.dirname(__file__),
15+
'__init__.py'))
16+
spec = importlib.util.spec_from_file_location('test', test_mod_path)
17+
test_module = importlib.util.module_from_spec(spec)
18+
spec.loader.exec_module(test_module)
19+
sys.modules['test'] = test_module
20+
21+
22+
# To unpickle certain objects, the structure of the class needs to be known.
23+
# These classes are mostly copies from pickletester.py.
24+
25+
class Nested:
26+
class A:
27+
class B:
28+
class C:
29+
pass
30+
class C:
31+
pass
32+
33+
class D(C):
34+
pass
35+
36+
class E(C):
37+
pass
38+
39+
class H(object):
40+
pass
41+
42+
class K(object):
43+
pass
44+
45+
class Subclass(tuple):
46+
class Nested(str):
47+
pass
48+
49+
class PyMethodsTest:
50+
@staticmethod
51+
def cheese():
52+
pass
53+
54+
@classmethod
55+
def wine(cls):
56+
pass
57+
58+
def biscuits(self):
59+
pass
60+
61+
class Nested:
62+
"Nested class"
63+
@staticmethod
64+
def ketchup():
65+
pass
66+
@classmethod
67+
def maple(cls):
68+
pass
69+
def pie(self):
70+
pass
71+
72+
73+
class Recursive:
74+
pass
75+
76+
77+
in_stream = sys.stdin.buffer
78+
out_stream = sys.stdout.buffer
79+
80+
try:
81+
message = pickle.load(in_stream)
82+
protocol, obj = message
83+
pickle.dump(obj, out_stream, protocol)
84+
except Exception as e:
85+
# dump the exception to stdout and write to stderr, then exit
86+
pickle.dump(e, out_stream)
87+
sys.stderr.write(repr(e))
88+
sys.exit(1)
89+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Forward-port test_xpickle from Python 2 to Python 3 and add the resource
2+
back to test's command line.

0 commit comments

Comments
 (0)