From 62718ec61fcc1cfc115c0e1a76482b5c85bd41b7 Mon Sep 17 00:00:00 2001 From: Eryx-Z Date: Sat, 17 Oct 2026 08:45:15 +0800 Subject: [PATCH] Pickle globals read from nested class bodies Class bodies compile free-variable reads as LOAD_NAME (and sometimes LOAD_FROM_DICT_OR_GLOBALS), not LOAD_GLOBAL. Include those loads when extracting globals for by-value functions, but ignore LOAD_NAME names that were already STOREd in the same class body so a local assignment that shadows an unpicklable global does not break pickling. Fixes #609. --- cloudpickle/cloudpickle.py | 34 +++++++++++++++++++++++++++++++++- tests/cloudpickle_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/cloudpickle/cloudpickle.py b/cloudpickle/cloudpickle.py index 08882306..be8cc2dc 100644 --- a/cloudpickle/cloudpickle.py +++ b/cloudpickle/cloudpickle.py @@ -55,6 +55,7 @@ import abc import builtins import copyreg +import inspect import dataclasses import dis from enum import Enum @@ -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 @@ -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): diff --git a/tests/cloudpickle_test.py b/tests/cloudpickle_test.py index e2097d1c..64057c17 100644 --- a/tests/cloudpickle_test.py +++ b/tests/cloudpickle_test.py @@ -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(