From 1795fccfbc7ccb89ead5c529b2f55f54622d1314 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 6 Apr 2026 12:56:36 +0100 Subject: [PATCH 1/8] gh-148157: Check for `_PyPegen_add_type_comment_to_arg` fail in `_PyPegen_name_default_pair` (#148158) --- Lib/test/test_type_comments.py | 3 +++ .../2026-04-06-11-15-46.gh-issue-148157.JFnZDn.rst | 2 ++ Parser/action_helpers.c | 3 +++ 3 files changed, 8 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-11-15-46.gh-issue-148157.JFnZDn.rst diff --git a/Lib/test/test_type_comments.py b/Lib/test/test_type_comments.py index dd2e67841651d9..d827ac271085bd 100644 --- a/Lib/test/test_type_comments.py +++ b/Lib/test/test_type_comments.py @@ -398,6 +398,9 @@ def test_non_utf8_type_comment_with_ignore_cookie(self): with self.assertRaises(UnicodeDecodeError): _testcapi.Py_CompileStringExFlags( b"a=1 # type: \x80", "", 256, flags) + with self.assertRaises(UnicodeDecodeError): + _testcapi.Py_CompileStringExFlags( + b"def a(f=8, #type: \x80\n\x80", "", 256, flags) def test_func_type_input(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-11-15-46.gh-issue-148157.JFnZDn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-11-15-46.gh-issue-148157.JFnZDn.rst new file mode 100644 index 00000000000000..6565291eb998ed --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-11-15-46.gh-issue-148157.JFnZDn.rst @@ -0,0 +1,2 @@ +Fix an unlikely crash when parsing an invalid type comments for function +parameters. Found by OSS Fuzz in :oss-fuzz:`492782951`. diff --git a/Parser/action_helpers.c b/Parser/action_helpers.c index 1f5b6220ba1baa..5e52bb83871904 100644 --- a/Parser/action_helpers.c +++ b/Parser/action_helpers.c @@ -435,6 +435,9 @@ _PyPegen_name_default_pair(Parser *p, arg_ty arg, expr_ty value, Token *tc) return NULL; } a->arg = _PyPegen_add_type_comment_to_arg(p, arg, tc); + if (!a->arg) { + return NULL; + } a->value = value; return a; } From 69f51625e6314883456b336b9e3471a6221d2787 Mon Sep 17 00:00:00 2001 From: Wulian233 <1055917385@qq.com> Date: Mon, 6 Apr 2026 20:11:30 +0800 Subject: [PATCH 2/8] gh-148119: Refactor `get_type_attr_as_size` to improve error handling in `structseq.c` (#148120) --- Objects/structseq.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Objects/structseq.c b/Objects/structseq.c index 8fa9cbba3bcce3..9130fe6a133b1e 100644 --- a/Objects/structseq.c +++ b/Objects/structseq.c @@ -28,7 +28,11 @@ static Py_ssize_t get_type_attr_as_size(PyTypeObject *tp, PyObject *name) { PyObject *v = PyDict_GetItemWithError(_PyType_GetDict(tp), name); - if (v == NULL && !PyErr_Occurred()) { + + if (v == NULL) { + if (PyErr_Occurred()) { + return -1; + } PyErr_Format(PyExc_TypeError, "Missed attribute '%U' of type %s", name, tp->tp_name); From 476fadc9aec9beb04dd95ee1894594b5dfa6a5cf Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 6 Apr 2026 13:47:44 +0100 Subject: [PATCH 3/8] Add Stan to docs config reviewers (#148164) --- .github/CODEOWNERS | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c5348d606b82d8..af904a567cfb7e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -100,12 +100,12 @@ Lib/test/test_build_details.py @FFY00 InternalDocs/ @AA-Turner # Tools, Configuration, etc -Doc/Makefile @AA-Turner @hugovk -Doc/_static/ @AA-Turner @hugovk -Doc/conf.py @AA-Turner @hugovk -Doc/make.bat @AA-Turner @hugovk -Doc/requirements.txt @AA-Turner @hugovk -Doc/tools/ @AA-Turner @hugovk +Doc/Makefile @AA-Turner @hugovk @StanFromIreland +Doc/_static/ @AA-Turner @hugovk @StanFromIreland +Doc/conf.py @AA-Turner @hugovk @StanFromIreland +Doc/make.bat @AA-Turner @hugovk @StanFromIreland +Doc/requirements.txt @AA-Turner @hugovk @StanFromIreland +Doc/tools/ @AA-Turner @hugovk @StanFromIreland # PR Previews .readthedocs.yml @AA-Turner From efda60e2ece11beda204f89de8cf8ecd1e66fde5 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Mon, 6 Apr 2026 14:52:42 +0200 Subject: [PATCH 4/8] gh-100239: Propagate type info through _BINARY_OP_EXTEND in tier 2 (GH-148146) --- Include/internal/pycore_code.h | 7 +++++ Lib/test/test_capi/test_opt.py | 23 +++++++++++++++ ...04-06-00-00-00.gh-issue-100239.binopxt.rst | 3 ++ Python/optimizer_bytecodes.c | 12 ++++++-- Python/optimizer_cases.c.h | 12 ++++++-- Python/specialize.c | 28 +++++++++---------- 6 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-00-00-00.gh-issue-100239.binopxt.rst diff --git a/Include/internal/pycore_code.h b/Include/internal/pycore_code.h index 376e68a4c8773c..fe8d0a54f2af1a 100644 --- a/Include/internal/pycore_code.h +++ b/Include/internal/pycore_code.h @@ -496,6 +496,13 @@ typedef struct { int oparg; binaryopguardfunc guard; binaryopactionfunc action; + /* Static type of the result, or NULL if unknown. Used by the tier 2 + optimizer to propagate type information through _BINARY_OP_EXTEND. */ + PyTypeObject *result_type; + /* Nonzero iff `action` always returns a freshly allocated object (not + aliased to either operand). Used by the tier 2 optimizer to enable + inplace follow-up ops. */ + int result_unique; } _PyBinaryOpSpecializationDescr; /* Comparison bit masks. */ diff --git a/Lib/test/test_capi/test_opt.py b/Lib/test/test_capi/test_opt.py index 56f90194b480a1..b31c9f68d01bec 100644 --- a/Lib/test/test_capi/test_opt.py +++ b/Lib/test/test_capi/test_opt.py @@ -3813,6 +3813,29 @@ def f(n): self.assertIn("_UNPACK_SEQUENCE_TWO_TUPLE", uops) self.assertNotIn("_GUARD_TOS_TUPLE", uops) + def test_binary_op_extend_float_result_enables_inplace_multiply(self): + # (2 + x) * y with x, y floats: `2 + x` goes through _BINARY_OP_EXTEND + # (int + float). The result_type/result_unique info should let the + # subsequent float multiply use the inplace variant. + def testfunc(n): + x = 3.5 + y = 2.0 + res = 0.0 + for _ in range(n): + res = (2 + x) * y + return res + + res, ex = self._run_with_optimizer(testfunc, TIER2_THRESHOLD) + self.assertEqual(res, 11.0) + self.assertIsNotNone(ex) + uops = get_opnames(ex) + self.assertIn("_BINARY_OP_EXTEND", uops) + self.assertIn("_BINARY_OP_MULTIPLY_FLOAT_INPLACE", uops) + self.assertNotIn("_BINARY_OP_MULTIPLY_FLOAT", uops) + # NOS guard on the multiply is eliminated because _BINARY_OP_EXTEND + # propagates PyFloat_Type. + self.assertNotIn("_GUARD_NOS_FLOAT", uops) + def test_unary_invert_long_type(self): def testfunc(n): for _ in range(n): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-00-00-00.gh-issue-100239.binopxt.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-00-00-00.gh-issue-100239.binopxt.rst new file mode 100644 index 00000000000000..9eccef3ef9d342 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-06-00-00-00.gh-issue-100239.binopxt.rst @@ -0,0 +1,3 @@ +Propagate result type and uniqueness information through +``_BINARY_OP_EXTEND`` in the tier 2 optimizer, enabling elimination of +downstream type guards and selection of inplace float operations. diff --git a/Python/optimizer_bytecodes.c b/Python/optimizer_bytecodes.c index b8148ef57ede0c..58b50707e55cee 100644 --- a/Python/optimizer_bytecodes.c +++ b/Python/optimizer_bytecodes.c @@ -410,8 +410,16 @@ dummy_func(void) { } op(_BINARY_OP_EXTEND, (descr/4, left, right -- res, l, r)) { - (void)descr; - res = sym_new_not_null(ctx); + _PyBinaryOpSpecializationDescr *d = (_PyBinaryOpSpecializationDescr *)descr; + if (d != NULL && d->result_type != NULL) { + res = sym_new_type(ctx, d->result_type); + if (d->result_unique) { + res = PyJitRef_MakeUnique(res); + } + } + else { + res = sym_new_not_null(ctx); + } l = left; r = right; } diff --git a/Python/optimizer_cases.c.h b/Python/optimizer_cases.c.h index a15b5ae1d13d3b..891887301119d7 100644 --- a/Python/optimizer_cases.c.h +++ b/Python/optimizer_cases.c.h @@ -1168,8 +1168,16 @@ right = stack_pointer[-1]; left = stack_pointer[-2]; PyObject *descr = (PyObject *)this_instr->operand0; - (void)descr; - res = sym_new_not_null(ctx); + _PyBinaryOpSpecializationDescr *d = (_PyBinaryOpSpecializationDescr *)descr; + if (d != NULL && d->result_type != NULL) { + res = sym_new_type(ctx, d->result_type); + if (d->result_unique) { + res = PyJitRef_MakeUnique(res); + } + } + else { + res = sym_new_not_null(ctx); + } l = left; r = right; CHECK_STACK_BOUNDS(1); diff --git a/Python/specialize.c b/Python/specialize.c index 09ec25767a4c3f..0fe225dcbb6b5f 100644 --- a/Python/specialize.c +++ b/Python/specialize.c @@ -2195,24 +2195,24 @@ LONG_FLOAT_ACTION(compactlong_float_true_div, /) static _PyBinaryOpSpecializationDescr binaryop_extend_descrs[] = { /* long-long arithmetic */ - {NB_OR, compactlongs_guard, compactlongs_or}, - {NB_AND, compactlongs_guard, compactlongs_and}, - {NB_XOR, compactlongs_guard, compactlongs_xor}, - {NB_INPLACE_OR, compactlongs_guard, compactlongs_or}, - {NB_INPLACE_AND, compactlongs_guard, compactlongs_and}, - {NB_INPLACE_XOR, compactlongs_guard, compactlongs_xor}, + {NB_OR, compactlongs_guard, compactlongs_or, &PyLong_Type, 1}, + {NB_AND, compactlongs_guard, compactlongs_and, &PyLong_Type, 1}, + {NB_XOR, compactlongs_guard, compactlongs_xor, &PyLong_Type, 1}, + {NB_INPLACE_OR, compactlongs_guard, compactlongs_or, &PyLong_Type, 1}, + {NB_INPLACE_AND, compactlongs_guard, compactlongs_and, &PyLong_Type, 1}, + {NB_INPLACE_XOR, compactlongs_guard, compactlongs_xor, &PyLong_Type, 1}, /* float-long arithemetic */ - {NB_ADD, float_compactlong_guard, float_compactlong_add}, - {NB_SUBTRACT, float_compactlong_guard, float_compactlong_subtract}, - {NB_TRUE_DIVIDE, nonzero_float_compactlong_guard, float_compactlong_true_div}, - {NB_MULTIPLY, float_compactlong_guard, float_compactlong_multiply}, + {NB_ADD, float_compactlong_guard, float_compactlong_add, &PyFloat_Type, 1}, + {NB_SUBTRACT, float_compactlong_guard, float_compactlong_subtract, &PyFloat_Type, 1}, + {NB_TRUE_DIVIDE, nonzero_float_compactlong_guard, float_compactlong_true_div, &PyFloat_Type, 1}, + {NB_MULTIPLY, float_compactlong_guard, float_compactlong_multiply, &PyFloat_Type, 1}, /* float-float arithmetic */ - {NB_ADD, compactlong_float_guard, compactlong_float_add}, - {NB_SUBTRACT, compactlong_float_guard, compactlong_float_subtract}, - {NB_TRUE_DIVIDE, nonzero_compactlong_float_guard, compactlong_float_true_div}, - {NB_MULTIPLY, compactlong_float_guard, compactlong_float_multiply}, + {NB_ADD, compactlong_float_guard, compactlong_float_add, &PyFloat_Type, 1}, + {NB_SUBTRACT, compactlong_float_guard, compactlong_float_subtract, &PyFloat_Type, 1}, + {NB_TRUE_DIVIDE, nonzero_compactlong_float_guard, compactlong_float_true_div, &PyFloat_Type, 1}, + {NB_MULTIPLY, compactlong_float_guard, compactlong_float_multiply, &PyFloat_Type, 1}, }; static int From 36f15ba5cd15607fb4e4908ddbfb462c44626d6b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:07:15 +0300 Subject: [PATCH 5/8] gh-140279: Stale workflow needs 'actions: write' to update its own cache (#148165) --- .github/workflows/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2c73d10350f69f..a862fde5e14eb4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,6 +11,7 @@ jobs: if: github.repository_owner == 'python' runs-on: ubuntu-latest permissions: + actions: write pull-requests: write timeout-minutes: 10 From f8293faf37971fd0d4d30c0c83df2ac654e610a1 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 6 Apr 2026 15:57:25 +0100 Subject: [PATCH 6/8] gh-130472: Remove readline-only hacks from PyREPL completions (#148161) PyREPL was still carrying over two readline-specific tricks from the fancy completer: a synthetic CSI prefix to influence sorting and a fake blank completion entry to suppress readline's prefix insertion. Those workarounds are not appropriate in PyREPL because the reader already owns completion ordering and menu rendering, so the fake entries leaked into the UI as real terminal attributes and empty menu cells. Sort completion candidates in ReadlineAlikeReader by their visible text with stripcolor(), and let the fancy completer return only real matches. That keeps colored completions stable without emitting bogus escape sequences, removes the empty completion slot, and adds regression tests for both the low-level completer output and the reader integration. --- Lib/_pyrepl/fancycompleter.py | 23 +++++--------- Lib/_pyrepl/readline.py | 8 ++--- Lib/test/test_pyrepl/test_fancycompleter.py | 34 ++++++++------------- Lib/test/test_pyrepl/test_pyrepl.py | 22 +++++++++++++ 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/Lib/_pyrepl/fancycompleter.py b/Lib/_pyrepl/fancycompleter.py index 5b5b7ae5f2bb59..7a639afd74ef3c 100644 --- a/Lib/_pyrepl/fancycompleter.py +++ b/Lib/_pyrepl/fancycompleter.py @@ -105,9 +105,6 @@ def attr_matches(self, text): names = [f'{expr}.{name}' for name in names] if self.use_colors: return self.colorize_matches(names, values) - - if prefix: - names.append(' ') return names def _attr_matches(self, text): @@ -173,21 +170,15 @@ def _attr_matches(self, text): return expr, attr, names, values def colorize_matches(self, names, values): - matches = [self._color_for_obj(i, name, obj) - for i, (name, obj) - in enumerate(zip(names, values))] - # We add a space at the end to prevent the automatic completion of the - # common prefix, which is the ANSI escape sequence. - matches.append(' ') - return matches - - def _color_for_obj(self, i, name, value): + return [ + self._color_for_obj(name, obj) + for name, obj in zip(names, values) + ] + + def _color_for_obj(self, name, value): t = type(value) color = self._color_by_type(t) - # Encode the match index into a fake escape sequence that - # stripcolor() can still remove once i reaches four digits. - N = f"\x1b[{i // 100:03d};{i % 100:02d}m" - return f"{N}{color}{name}{ANSIColors.RESET}" + return f"{color}{name}{ANSIColors.RESET}" def _color_by_type(self, t): typename = t.__name__ diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 687084601e77c1..8d3be37b4adeec 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -37,7 +37,7 @@ from rlcompleter import Completer as RLCompleter from . import commands, historical_reader -from .completing_reader import CompletingReader +from .completing_reader import CompletingReader, stripcolor from .console import Console as ConsoleType from ._module_completer import ModuleCompleter, make_default_module_completer from .fancycompleter import Completer as FancyCompleter @@ -163,9 +163,9 @@ def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None break result.append(next) state += 1 - # emulate the behavior of the standard readline that sorts - # the completions before displaying them. - result.sort() + # Emulate readline's sorting using the visible text rather than + # the raw ANSI escape sequences used for colorized matches. + result.sort(key=stripcolor) return result, None def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None: diff --git a/Lib/test/test_pyrepl/test_fancycompleter.py b/Lib/test/test_pyrepl/test_fancycompleter.py index 77c80853a3c0e3..d2646cd3050428 100644 --- a/Lib/test/test_pyrepl/test_fancycompleter.py +++ b/Lib/test/test_pyrepl/test_fancycompleter.py @@ -55,7 +55,7 @@ class C(object): self.assertEqual(compl.attr_matches('a.'), ['a.attr', 'a.mro']) self.assertEqual( compl.attr_matches('a._'), - ['a._C__attr__attr', 'a._attr', ' '], + ['a._C__attr__attr', 'a._attr'], ) matches = compl.attr_matches('a.__') self.assertNotIn('__class__', matches) @@ -79,7 +79,7 @@ def test_complete_attribute_colored(self): break else: self.assertFalse(True, matches) - self.assertIn(' ', matches) + self.assertNotIn(' ', matches) def test_preserves_callable_postfix_for_single_attribute_match(self): compl = Completer({'os': os}, use_colors=False) @@ -159,22 +159,17 @@ def test_complete_global_colored(self): self.assertEqual(compl.global_matches('foo'), ['fooba']) matches = compl.global_matches('fooba') - # these are the fake escape sequences which are needed so that - # readline displays the matches in the proper order - N0 = f"\x1b[000;00m" - N1 = f"\x1b[000;01m" int_color = theme.fancycompleter.int - self.assertEqual(set(matches), { - ' ', - f'{N0}{int_color}foobar{ANSIColors.RESET}', - f'{N1}{int_color}foobazzz{ANSIColors.RESET}', - }) + self.assertEqual(matches, [ + f'{int_color}foobar{ANSIColors.RESET}', + f'{int_color}foobazzz{ANSIColors.RESET}', + ]) self.assertEqual(compl.global_matches('foobaz'), ['foobazzz']) self.assertEqual(compl.global_matches('nothing'), []) - def test_large_color_sort_prefix_is_stripped(self): + def test_colorized_match_is_stripped(self): compl = Completer({'a': 42}, use_colors=True) - match = compl._color_for_obj(1000, 'spam', 1) + match = compl._color_for_obj('spam', 1) self.assertEqual(stripcolor(match), 'spam') def test_complete_with_indexer(self): @@ -197,13 +192,11 @@ class A: compl = Completer({'A': A}, use_colors=False) # # In this case, we want to display all attributes which start with - # 'a'. Moreover, we also include a space to prevent readline to - # automatically insert the common prefix (which will the the ANSI escape - # sequence if we use colors). + # 'a'. matches = compl.attr_matches('A.a') self.assertEqual( sorted(matches), - [' ', 'A.aaa', 'A.abc_1', 'A.abc_2', 'A.abc_3'], + ['A.aaa', 'A.abc_1', 'A.abc_2', 'A.abc_3'], ) # # If there is an actual common prefix, we return just it, so that readline @@ -211,13 +204,12 @@ class A: matches = compl.attr_matches('A.ab') self.assertEqual(matches, ['A.abc_']) # - # Finally, at the next tab, we display again all the completions available - # for this common prefix. Again, we insert a spurious space to prevent the - # automatic completion of ANSI sequences. + # Finally, at the next tab, we display again all the completions + # available for this common prefix. matches = compl.attr_matches('A.abc_') self.assertEqual( sorted(matches), - [' ', 'A.abc_1', 'A.abc_2', 'A.abc_3'], + ['A.abc_1', 'A.abc_2', 'A.abc_3'], ) def test_complete_exception(self): diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index c3556823c72476..8a3cae966a6e05 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -36,6 +36,7 @@ code_to_events, ) from _pyrepl.console import Event +from _pyrepl.completing_reader import stripcolor from _pyrepl._module_completer import ( ImportParser, ModuleCompleter, @@ -999,6 +1000,27 @@ class Obj: self.assertNotIn("banana", menu) self.assertNotIn("mro", menu) + def test_get_completions_sorts_colored_matches_by_visible_text(self): + console = FakeConsole(iter(())) + config = ReadlineConfig() + config.readline_completer = FancyCompleter( + { + "foo_str": "value", + "foo_int": 1, + "foo_none": None, + }, + use_colors=True, + ).complete + reader = ReadlineAlikeReader(console=console, config=config) + + matches, action = reader.get_completions("foo_") + + self.assertIsNone(action) + self.assertEqual( + [stripcolor(match) for match in matches], + ["foo_int", "foo_none", "foo_str"], + ) + class TestPyReplReadlineSetup(TestCase): def test_setup_ignores_basic_completer_env_when_env_is_disabled(self): From 3d724dd9149068ec9c335262d81d410a564d3598 Mon Sep 17 00:00:00 2001 From: Junya Fukuda Date: Tue, 7 Apr 2026 00:37:02 +0900 Subject: [PATCH 7/8] gh-148072: Cache pickle.dumps/loads per interpreter in XIData (GH-148125) Store references to pickle.dumps and pickle.loads in _PyXI_state_t so they are looked up only once per interpreter lifetime, avoiding repeated PyImport_ImportModuleAttrString calls on every cross-interpreter data transfer via pickle fallback. Benchmarks show 1.7x-3.3x speedup for InterpreterPoolExecutor when transferring mutable types (list, dict) through XIData. --- Include/internal/pycore_crossinterp.h | 6 ++ ...-04-05-00-00-00.gh-issue-148072.xid9Pe.rst | 4 ++ Python/crossinterp.c | 62 +++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-05-00-00-00.gh-issue-148072.xid9Pe.rst diff --git a/Include/internal/pycore_crossinterp.h b/Include/internal/pycore_crossinterp.h index 81faffac194171..bed966681fa1f0 100644 --- a/Include/internal/pycore_crossinterp.h +++ b/Include/internal/pycore_crossinterp.h @@ -265,6 +265,12 @@ typedef struct { // heap types PyObject *PyExc_NotShareableError; } exceptions; + + // Cached references to pickle.dumps/loads (per-interpreter). + struct { + PyObject *dumps; + PyObject *loads; + } pickle; } _PyXI_state_t; #define _PyXI_GET_GLOBAL_STATE(interp) (&(interp)->runtime->xi) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-05-00-00-00.gh-issue-148072.xid9Pe.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-05-00-00-00.gh-issue-148072.xid9Pe.rst new file mode 100644 index 00000000000000..17c6f882f24d70 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-05-00-00-00.gh-issue-148072.xid9Pe.rst @@ -0,0 +1,4 @@ +Cache ``pickle.dumps`` and ``pickle.loads`` per interpreter in the XIData +framework, avoiding repeated module lookups on every cross-interpreter data +transfer. This speeds up :class:`~concurrent.futures.InterpreterPoolExecutor` +for mutable types (``list``, ``dict``) by 1.7x--3.3x. diff --git a/Python/crossinterp.c b/Python/crossinterp.c index f92927da475321..4cd4b32ef906bb 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -568,6 +568,48 @@ _PyObject_GetXIData(PyThreadState *tstate, /* pickle C-API */ +/* Per-interpreter cache for pickle.dumps and pickle.loads. + * + * Each interpreter has its own cache in _PyXI_state_t.pickle, preserving + * interpreter isolation. The cache is populated lazily on first use and + * cleared during interpreter finalization in _Py_xi_state_fini(). + * + * Note: the cached references are captured at first use and not invalidated + * on module reload. This matches the caching pattern used elsewhere in + * CPython (e.g. arraymodule.c, _decimal.c). */ + +static PyObject * +_get_pickle_dumps(PyThreadState *tstate) +{ + _PyXI_state_t *state = _PyXI_GET_STATE(tstate->interp); + PyObject *dumps = state->pickle.dumps; + if (dumps != NULL) { + return dumps; + } + dumps = PyImport_ImportModuleAttrString("pickle", "dumps"); + if (dumps == NULL) { + return NULL; + } + state->pickle.dumps = dumps; // owns the reference + return dumps; +} + +static PyObject * +_get_pickle_loads(PyThreadState *tstate) +{ + _PyXI_state_t *state = _PyXI_GET_STATE(tstate->interp); + PyObject *loads = state->pickle.loads; + if (loads != NULL) { + return loads; + } + loads = PyImport_ImportModuleAttrString("pickle", "loads"); + if (loads == NULL) { + return NULL; + } + state->pickle.loads = loads; // owns the reference + return loads; +} + struct _pickle_context { PyThreadState *tstate; }; @@ -575,13 +617,12 @@ struct _pickle_context { static PyObject * _PyPickle_Dumps(struct _pickle_context *ctx, PyObject *obj) { - PyObject *dumps = PyImport_ImportModuleAttrString("pickle", "dumps"); + PyObject *dumps = _get_pickle_dumps(ctx->tstate); if (dumps == NULL) { return NULL; } - PyObject *bytes = PyObject_CallOneArg(dumps, obj); - Py_DECREF(dumps); - return bytes; + // dumps is a borrowed reference from the cache. + return PyObject_CallOneArg(dumps, obj); } @@ -636,7 +677,8 @@ _PyPickle_Loads(struct _unpickle_context *ctx, PyObject *pickled) PyThreadState *tstate = ctx->tstate; PyObject *exc = NULL; - PyObject *loads = PyImport_ImportModuleAttrString("pickle", "loads"); + // loads is a borrowed reference from the per-interpreter cache. + PyObject *loads = _get_pickle_loads(tstate); if (loads == NULL) { return NULL; } @@ -682,7 +724,6 @@ _PyPickle_Loads(struct _unpickle_context *ctx, PyObject *pickled) // It might make sense to chain it (__context__). _PyErr_SetRaisedException(tstate, exc); } - Py_DECREF(loads); return obj; } @@ -3094,6 +3135,10 @@ _Py_xi_state_init(_PyXI_state_t *state, PyInterpreterState *interp) assert(state != NULL); assert(interp == NULL || state == _PyXI_GET_STATE(interp)); + // Initialize pickle function cache (before any fallible ops). + state->pickle.dumps = NULL; + state->pickle.loads = NULL; + xid_lookup_init(&state->data_lookup); // Initialize exceptions. @@ -3116,6 +3161,11 @@ _Py_xi_state_fini(_PyXI_state_t *state, PyInterpreterState *interp) assert(state != NULL); assert(interp == NULL || state == _PyXI_GET_STATE(interp)); + // Clear pickle function cache first: the cached functions may hold + // references to modules cleaned up by later finalization steps. + Py_CLEAR(state->pickle.dumps); + Py_CLEAR(state->pickle.loads); + fini_heap_exctypes(&state->exceptions); if (interp != NULL) { fini_static_exctypes(&state->exceptions, interp); From a0c57a8d17eb0f5c4e620d83a13a47cf4d85e76f Mon Sep 17 00:00:00 2001 From: Fionn <1897918+fionn@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:42:10 +0800 Subject: [PATCH 8/8] gh-137586: Open external osascript program with absolute path (GH-137584) Open web browser with absolute path On macOS, web browsers are opened via popen calling osascript. However, if a user has a colliding osascript executable earlier in their PATH, this may fail or cause unwanted behaviour. Depending on one's environment or level of paranoia, this may be considered a security vulnerability. Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/test/test_webbrowser.py | 2 +- Lib/turtledemo/__main__.py | 2 +- Lib/webbrowser.py | 2 +- .../next/macOS/2025-10-17-01-07-03.gh-issue-137586.kVzxvp.rst | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/macOS/2025-10-17-01-07-03.gh-issue-137586.kVzxvp.rst diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index ea161ea1a43ea5..299dc185fcf211 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -351,7 +351,7 @@ def test_default_open(self): url = "https://python.org" self.browser.open(url) self.assertTrue(self.popen_pipe._closed) - self.assertEqual(self.popen_pipe.cmd, "osascript") + self.assertEqual(self.popen_pipe.cmd, "/usr/bin/osascript") script = self.popen_pipe.pipe.getvalue() self.assertEqual(script.strip(), f'open location "{url}"') diff --git a/Lib/turtledemo/__main__.py b/Lib/turtledemo/__main__.py index b49c0beab3ccf7..7c2d753f4c3111 100644 --- a/Lib/turtledemo/__main__.py +++ b/Lib/turtledemo/__main__.py @@ -136,7 +136,7 @@ def __init__(self, filename=None): # so that our menu bar appears. subprocess.run( [ - 'osascript', + '/usr/bin/osascript', '-e', 'tell application "System Events"', '-e', 'set frontmost of the first process whose ' 'unix id is {} to true'.format(os.getpid()), diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index deb6e64d17421b..0e0b5034e5f53d 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -656,7 +656,7 @@ def open(self, url, new=0, autoraise=True): end ''' - osapipe = os.popen("osascript", "w") + osapipe = os.popen("/usr/bin/osascript", "w") if osapipe is None: return False diff --git a/Misc/NEWS.d/next/macOS/2025-10-17-01-07-03.gh-issue-137586.kVzxvp.rst b/Misc/NEWS.d/next/macOS/2025-10-17-01-07-03.gh-issue-137586.kVzxvp.rst new file mode 100644 index 00000000000000..8e42065392a2de --- /dev/null +++ b/Misc/NEWS.d/next/macOS/2025-10-17-01-07-03.gh-issue-137586.kVzxvp.rst @@ -0,0 +1 @@ +Invoke :program:`osascript` with absolute path in :mod:`webbrowser` and :mod:`!turtledemo`.