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