Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion cloudpickle/cloudpickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import abc
import builtins
import copyreg
import inspect
import dataclasses
import dis
from enum import Enum
Expand Down Expand Up @@ -396,6 +397,13 @@ def func():
_glbl_ops.append(LOAD_GLOBAL)
GLOBAL_OPS = tuple(_glbl_ops)
del _glbl_ops

# Class bodies compile global reads as LOAD_NAME (and, on 3.12+, sometimes as
# LOAD_FROM_DICT_OR_GLOBALS for annotations / type aliases), not LOAD_GLOBAL.
LOAD_NAME = opcode.opmap["LOAD_NAME"]
STORE_NAME = opcode.opmap["STORE_NAME"]
LOAD_FROM_DICT_OR_GLOBALS = opcode.opmap.get("LOAD_FROM_DICT_OR_GLOBALS")

HAVE_ARGUMENT = dis.HAVE_ARGUMENT
EXTENDED_ARG = dis.EXTENDED_ARG

Expand All @@ -416,11 +424,35 @@ def _builtin_type(name):


def _walk_global_ops(code):
"""Yield referenced name for global-referencing instructions in code."""
"""Yield referenced name for global-referencing instructions in code.

Function code uses LOAD_GLOBAL / STORE_GLOBAL. Class bodies instead use
LOAD_NAME for names looked up in the class namespace (falling back to
globals). To avoid treating a class-local assignment that shadows an
unpicklable global as a global read, LOAD_NAME only counts as a global
reference when it appears before any STORE_NAME of the same name in that
code object.
"""
# Class bodies are not CO_OPTIMIZED; function/comprehension code is.
class_body = not (code.co_flags & inspect.CO_OPTIMIZED)
seen_stores = set()

for instr in dis.get_instructions(code):
op = instr.opcode
if op in GLOBAL_OPS:
yield instr.argval
elif class_body and op == STORE_NAME:
seen_stores.add(instr.argval)
elif class_body and op == LOAD_NAME:
if instr.argval not in seen_stores:
yield instr.argval
elif (
class_body
and LOAD_FROM_DICT_OR_GLOBALS is not None
and op == LOAD_FROM_DICT_OR_GLOBALS
):
# Type aliases / lazy annotations look up missing names in globals.
yield instr.argval


def _extract_class_dict(cls):
Expand Down
33 changes: 33 additions & 0 deletions tests/cloudpickle_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2526,6 +2526,39 @@ def inner_function():
inner_func = depickled_factory()
assert inner_func() == _TEST_GLOBAL_VARIABLE

def test_class_body_reads_globals_by_value(self):
# Issue #609: globals read only inside a nested class body must be
# pickled for by-value functions.
ns = {"__name__": "not_importable", "CONST": 42}
exec(
"def f():\n"
" class Rec:\n"
" q = CONST\n"
" return Rec.q\n",
ns,
)
self.assertEqual(ns["f"](), 42)
self.assertEqual(pickle_depickle(ns["f"], protocol=self.protocol)(), 42)

def test_class_body_local_shadow_skips_unpicklable_global(self):
# A class-local assignment that shadows an unpicklable global must not
# pull that global into the pickled function state.
import threading

ns = {"__name__": "not_importable", "resource": threading.Lock()}
exec(
"def factory():\n"
" class Record:\n"
" resource = 42\n"
" value = resource\n"
" return Record.value\n",
ns,
)
self.assertEqual(ns["factory"](), 42)
self.assertEqual(
pickle_depickle(ns["factory"], protocol=self.protocol)(), 42
)

# TODO: remove this xfail when we drop support for Python 3.8. We don't
# plan to fix it because Python 3.8 is EOL.
@pytest.mark.skipif(
Expand Down