Skip to content

Commit 938fc0d

Browse files
benjiminogrisel
authored andcommitted
Import submodules accessed by pickled functions (#80)
1 parent 72de40b commit 938fc0d

2 files changed

Lines changed: 112 additions & 1 deletion

File tree

cloudpickle/cloudpickle.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ def save_function(self, obj, name=None):
238238
# a builtin_function_or_method which comes in as an attribute of some
239239
# object (e.g., object.__new__, itertools.chain.from_iterable) will end
240240
# up with modname "__main__" and so end up here. But these functions
241-
# have no __code__ attribute in CPython, so the handling for
241+
# have no __code__ attribute in CPython, so the handling for
242242
# user-defined functions below will fail.
243243
# So we pickle them here using save_reduce; have to do it differently
244244
# for different python versions.
@@ -282,6 +282,27 @@ def save_function(self, obj, name=None):
282282
self.memoize(obj)
283283
dispatch[types.FunctionType] = save_function
284284

285+
def _save_subimports(self, code, top_level_dependencies):
286+
"""
287+
Ensure de-pickler imports any package child-modules that
288+
are needed by the function
289+
"""
290+
# check if any known dependency is an imported package
291+
for x in top_level_dependencies:
292+
if isinstance(x, types.ModuleType) and x.__package__:
293+
# check if the package has any currently loaded sub-imports
294+
prefix = x.__name__ + '.'
295+
for name, module in sys.modules.items():
296+
if name.startswith(prefix):
297+
# check whether the function can address the sub-module
298+
tokens = set(name[len(prefix):].split('.'))
299+
if not tokens - set(code.co_names):
300+
# ensure unpickler executes this import
301+
self.save(module)
302+
# then discards the reference to it
303+
self.write(pickle.POP)
304+
305+
285306
def save_function_tuple(self, func):
286307
""" Pickles an actual func object.
287308
@@ -307,6 +328,8 @@ def save_function_tuple(self, func):
307328
save(_fill_function) # skeleton function updater
308329
write(pickle.MARK) # beginning of tuple that _fill_function expects
309330

331+
self._save_subimports(code, set(f_globals.values()) | set(closure))
332+
310333
# create a skeleton function object and memoize it
311334
save(_make_skel_func)
312335
save((code, closure, base_globals))

tests/cloudpickle_test.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import itertools
1010
import platform
1111
import textwrap
12+
import base64
13+
import subprocess
1214

1315
try:
1416
# try importing numpy and scipy. These are not hard dependencies and
@@ -360,6 +362,92 @@ def f():
360362
self.assertTrue(f2 is f3)
361363
self.assertEqual(f2(), res)
362364

365+
def test_submodule(self):
366+
# Function that refers (by attribute) to a sub-module of a package.
367+
368+
# Choose any module NOT imported by __init__ of its parent package
369+
# examples in standard library include:
370+
# - http.cookies, unittest.mock, curses.textpad, xml.etree.ElementTree
371+
372+
global xml # imitate performing this import at top of file
373+
import xml.etree.ElementTree
374+
def example():
375+
x = xml.etree.ElementTree.Comment # potential AttributeError
376+
377+
s = cloudpickle.dumps(example)
378+
379+
# refresh the environment, i.e., unimport the dependency
380+
del xml
381+
for item in list(sys.modules):
382+
if item.split('.')[0] == 'xml':
383+
del sys.modules[item]
384+
385+
# deserialise
386+
f = pickle.loads(s)
387+
f() # perform test for error
388+
389+
def test_submodule_closure(self):
390+
# Same as test_submodule except the package is not a global
391+
def scope():
392+
import xml.etree.ElementTree
393+
def example():
394+
x = xml.etree.ElementTree.Comment # potential AttributeError
395+
return example
396+
example = scope()
397+
398+
s = cloudpickle.dumps(example)
399+
400+
# refresh the environment (unimport dependency)
401+
for item in list(sys.modules):
402+
if item.split('.')[0] == 'xml':
403+
del sys.modules[item]
404+
405+
f = cloudpickle.loads(s)
406+
f() # test
407+
408+
def test_multiprocess(self):
409+
# running a function pickled by another process (a la dask.distributed)
410+
def scope():
411+
import curses.textpad
412+
def example():
413+
x = xml.etree.ElementTree.Comment
414+
x = curses.textpad.Textbox
415+
return example
416+
global xml
417+
import xml.etree.ElementTree
418+
example = scope()
419+
420+
s = cloudpickle.dumps(example)
421+
422+
# choose "subprocess" rather than "multiprocessing" because the latter
423+
# library uses fork to preserve the parent environment.
424+
command = ("import pickle, base64; "
425+
"pickle.loads(base64.b32decode('" +
426+
base64.b32encode(s).decode('ascii') +
427+
"'))()")
428+
assert not subprocess.call([sys.executable, '-c', command])
429+
430+
def test_import(self):
431+
# like test_multiprocess except subpackage modules referenced directly
432+
# (unlike test_submodule)
433+
global etree
434+
def scope():
435+
import curses.textpad as foobar
436+
def example():
437+
x = etree.Comment
438+
x = foobar.Textbox
439+
return example
440+
example = scope()
441+
import xml.etree.ElementTree as etree
442+
443+
s = cloudpickle.dumps(example)
444+
445+
command = ("import pickle, base64; "
446+
"pickle.loads(base64.b32decode('" +
447+
base64.b32encode(s).decode('ascii') +
448+
"'))()")
449+
assert not subprocess.call([sys.executable, '-c', command])
450+
363451

364452
if __name__ == '__main__':
365453
unittest.main()

0 commit comments

Comments
 (0)