From 120941de6ff75101bb54e10810338d8f4243474c Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 11 Oct 2026 18:12:50 +0000 Subject: [PATCH] gh-159188: Do not immortalize constants in compile() of an AST In the free-threaded build, compile() of a source string sets suppress_co_const_immortalization so that its output is the same as in the default build. The branch for AST objects returned before that, so constants were replaced by the immortal ones of the interpreter's table while a tuple of names (such as co_names) which was the same object was not, and the marshal output depended on the code objects created before in the process. --- Lib/test/test_compile.py | 67 +++++++++++-------- ...-10-11-06-36-25.gh-issue-159188.Fq7wLm.rst | 5 ++ Python/bltinmodule.c | 14 +++- 3 files changed, 57 insertions(+), 29 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-11-06-36-25.gh-issue-159188.Fq7wLm.rst diff --git a/Lib/test/test_compile.py b/Lib/test/test_compile.py index 53b4be1e75b65a1..36f2993797a493d 100644 --- a/Lib/test/test_compile.py +++ b/Lib/test/test_compile.py @@ -2085,13 +2085,16 @@ def describe(obj): def compile_and_describe(source, use_ast): """Compile source; return the code object and what depends on identity. - If use_ast is true, compile an AST in which the set displays are - frozenset constants. + If use_ast is true, compile an AST in which the set displays of + constants are frozenset constants. """ if use_ast: class Transformer(ast.NodeTransformer): def visit_Set(self, node): - value = frozenset(ast.literal_eval(node)) + try: + value = frozenset(ast.literal_eval(node)) + except ValueError: + return node return ast.copy_location(ast.Constant(value), node) source = Transformer().visit(ast.parse(source)) # The marshal format also depends on reference counts (gh-156504), @@ -2158,10 +2161,6 @@ class Case: # and the number of distinct objects among them. frozensets_and_slices: int = 0 distinct: int = 0 - # Compile an AST with frozenset constants. In the free-threaded - # build, the constants of code compiled from an AST are also - # interned. - use_ast: bool = False @dataclasses.dataclass(frozen=True) class Job: @@ -2351,30 +2350,24 @@ def f(self): self.ID_a = 1 """, strings=['ID_a']), - 'ast_constants': Case( - source=""" - def f(x): - return "f", x in {"ID_a", ("ID_b", "ID_c")} - def g(x): - return "g", x in {"ID_a", ("ID_b", "ID_c")} - """, - strings=['ID_a', 'ID_b', 'ID_c'], - frozensets_and_slices=2, distinct=1, - use_ast=True), } @classmethod def jobs(cls, tag): - # A Job for each of the cases. The prefix depends on tag: a - # process compiles a source with given strings only once. + # Two Jobs for each of the cases: compiled from the source and + # from an AST. The prefix depends on tag and on use_ast: a process + # compiles a source with given strings only once. jobs = [] for name, case in cls.CASES.items(): - prefix = f'gh129724{tag}_{name}' - jobs.append(cls.Job( - key=f'{tag}:{name}', - source=textwrap.dedent(case.source).replace('ID', prefix), - strings=[s.replace('ID', prefix) for s in case.strings], - use_ast=case.use_ast)) + source = textwrap.dedent(case.source) + for use_ast in False, True: + kind = 'ast' if use_ast else 'str' + prefix = f'gh129724{tag}{kind}_{name}' + jobs.append(cls.Job( + key=f'{tag}:{kind}:{name}', + source=source.replace('ID', prefix), + strings=[s.replace('ID', prefix) for s in case.strings], + use_ast=use_ast)) return jobs @classmethod @@ -2439,9 +2432,11 @@ def check_child(self, mode): def test_fresh(self): # equal constants are the same object for name, case in self.CASES.items(): - with self.subTest(name): - self.assertEqual(self.fresh[f'c:{name}']['shared'], - [case.frozensets_and_slices, case.distinct]) + expected = [case.frozensets_and_slices, case.distinct] + for kind in 'str', 'ast': + with self.subTest(name, kind=kind): + self.assertEqual(self.fresh[f'c:{kind}:{name}']['shared'], + expected) def test_interned(self): self.check_child('interned') @@ -2478,6 +2473,22 @@ def test_same_process_interned(self): code, result = compile_and_describe(job.source, job.use_ast) self.check(job.key, result) + @support.cpython_only + @unittest.skipUnless(support.Py_GIL_DISABLED, + 'requires the free-threaded build') + def test_constants_not_immortal(self): + # compile() does not replace the constants by the immortal ones + # of the code objects which were created before. + source = 'x = (129724.5, 129724.25)' + exec(source, {}) + for use_ast in False, True: + with self.subTest(use_ast=use_ast): + code = compile(ast.parse(source) if use_ast else source, + '', 'exec') + const, = [const for const in code.co_consts + if isinstance(const, tuple)] + self.assertFalse(sys._is_immortal(const)) + def test_names_tuples(self): # Code objects with equal names share one tuple, and a constant # equal to the names is that same tuple, whatever was compiled diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-11-06-36-25.gh-issue-159188.Fq7wLm.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-11-06-36-25.gh-issue-159188.Fq7wLm.rst new file mode 100644 index 000000000000000..2637fb8e5326e3e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-11-06-36-25.gh-issue-159188.Fq7wLm.rst @@ -0,0 +1,5 @@ +In the :term:`free-threaded build`, :func:`compile` no longer immortalizes +the constants of the code objects when it is given an AST object, as was +already the case for a source string. The output of :mod:`marshal` for +such code no longer depends on the code objects which were created before +in the process. diff --git a/Python/bltinmodule.c b/Python/bltinmodule.c index 796897ff24cdc02..b95ba2c156d5fb8 100644 --- a/Python/bltinmodule.c +++ b/Python/bltinmodule.c @@ -942,8 +942,17 @@ builtin_compile_impl(PyObject *module, PyObject *source, PyObject *filename, _PyArena_Free(arena); goto error; } +#ifdef Py_GIL_DISABLED + // Disable immortalization of code constants, as for a source + // string below. + _PyThreadStateImpl *tstate = (_PyThreadStateImpl *)_PyThreadState_GET(); + tstate->suppress_co_const_immortalization++; +#endif result = (PyObject*)_PyAST_Compile(mod, filename, &cf, optimize, arena, modname); +#ifdef Py_GIL_DISABLED + tstate->suppress_co_const_immortalization--; +#endif } _PyArena_Free(arena); goto finally; @@ -956,7 +965,10 @@ builtin_compile_impl(PyObject *module, PyObject *source, PyObject *filename, #ifdef Py_GIL_DISABLED // Disable immortalization of code constants for explicit // compile() calls to get consistent frozen outputs between the default - // and free-threaded builds. + // and free-threaded builds. It also keeps the output independent of + // what the process compiled before: a constant is otherwise replaced + // by an equal one of an earlier code object, and is then no longer the + // same object as, for example, the equal co_names of its code object. _PyThreadStateImpl *tstate = (_PyThreadStateImpl *)_PyThreadState_GET(); tstate->suppress_co_const_immortalization++; #endif