From fe7717c6116402a05854e0d6ccb1016990f378b0 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 25 Sep 2026 13:34:05 +0200 Subject: [PATCH 1/6] gh-158080: Py_InitializeFromConfig() no longer exits Python (#158085) If a command line option wants to exit Python, move this option processing from PyConfig_Read() to Py_Main(). So Py_InitializeFromInitConfig() and Py_InitializeFromConfig() can no longer return an exit code. Deprecate PyInitConfig_GetExitCode(), PyStatus_Exit(), and PyStatus_IsExit() functions, and PyStatus.exitcode member. Renumber getopt.c long options to start at 1, instead of 0. --- Doc/c-api/init_config.rst | 35 ++-- Doc/c-api/interp-lifecycle.rst | 5 + Doc/whatsnew/3.16.rst | 8 + Include/cpython/initconfig.h | 15 +- Include/internal/pycore_initconfig.h | 2 + Lib/test/test_cmd_line.py | 15 +- Lib/test/test_embed.py | 1 + ...-09-24-15-27-35.gh-issue-158080.YIqypf.rst | 6 + Modules/main.c | 19 ++- Programs/_bootstrap_python.c | 3 - Programs/_testembed.c | 20 +-- Python/getopt.c | 8 +- Python/initconfig.c | 150 ++++++++++++------ Python/pylifecycle.c | 7 +- 14 files changed, 190 insertions(+), 104 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-09-24-15-27-35.gh-issue-158080.YIqypf.rst diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst index ef09639189a6c27..400a05758c95174 100644 --- a/Doc/c-api/init_config.rst +++ b/Doc/c-api/init_config.rst @@ -116,15 +116,14 @@ Error Handling Get the *config* exit code. - * Set *\*exitcode* and return ``1`` if *config* has an exit code set. - * Return ``0`` if *config* has no exit code set. + Return ``0`` and leave *\*exitcode* unchanged. - Only the ``Py_InitializeFromInitConfig()`` function can set an exit - code if the ``parse_argv`` option is non-zero. + In Python 3.15, :c:func:`Py_InitializeFromInitConfig` sets an exit code if a + command line option wants to exit Python. This is no longer the case in + Python 3.16. Instead, the option is now processed in :c:func:`Py_RunMain`. + This function became useless. - An exit code can be set when parsing the command line failed (exit - code ``2``) or when a command line option asks to display the command - line help (exit code ``0``). + .. deprecated:: next Get Options @@ -247,10 +246,11 @@ Initialize Python * Return ``0`` on success. * Set an error in *config* and return ``-1`` on error. - * Set an exit code in *config* and return ``-1`` if Python wants to - exit. - See ``PyInitConfig_GetExitcode()`` for the exit code case. + .. versionchanged:: next + The function no longer sets an exit code if a command line option wants + to exit Python. Instead, the option is now processed in + :c:func:`Py_RunMain`. .. _pyinitconfig-opts: @@ -690,9 +690,6 @@ Example of customized Python always running in isolated mode:: exception: PyConfig_Clear(&config); - if (PyStatus_IsExit(status)) { - return status.exitcode; - } /* Display the error message and exit the process with non-zero exit code */ Py_ExitStatusException(status); @@ -758,6 +755,8 @@ PyStatus Exit code. Argument passed to ``exit()``. + .. deprecated:: next + .. c:member:: const char *err_msg Error message. @@ -788,6 +787,11 @@ PyStatus Exit Python with the specified exit code. + .. deprecated:: next + :c:func:`Py_InitializeFromConfig` no longer sets an exit code if a + command line option wants to exit Python. Instead, the option is + now processed in :c:func:`Py_RunMain`. + Functions to handle a status: .. c:function:: int PyStatus_Exception(PyStatus status) @@ -803,6 +807,11 @@ PyStatus Is the result an exit? + .. deprecated:: next + :c:func:`Py_InitializeFromConfig` no longer sets an exit code if a + command line option wants to exit Python. Instead, the option is + now processed in :c:func:`Py_RunMain`. + .. c:function:: void Py_ExitStatusException(PyStatus status) Call ``exit(exitcode)`` if *status* is an exit. Print the error diff --git a/Doc/c-api/interp-lifecycle.rst b/Doc/c-api/interp-lifecycle.rst index bd4125cacdd0e66..efd208263bdf339 100644 --- a/Doc/c-api/interp-lifecycle.rst +++ b/Doc/c-api/interp-lifecycle.rst @@ -127,6 +127,11 @@ Initializing and finalizing the interpreter interpreter, populating the runtime configuration structure, and querying the returned status structure. + .. versionchanged:: next + The function no longer returns an exit code if a command line option + wants to exit Python. Instead, the option is processed in + :c:func:`Py_RunMain`. + .. c:function:: int Py_IsInitialized() diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 3d3125ad17b126b..7a1f9410c87fc7d 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -1128,6 +1128,14 @@ Deprecated C APIs and :c:func:`PyAsyncGen_New` are deprecated. They are scheduled for removal in 3.18. +* Deprecate :c:func:`PyInitConfig_GetExitCode`, :c:func:`PyStatus_Exit`, + and :c:func:`PyStatus_IsExit` functions, and :c:member:`PyStatus.exitcode` + member. :c:func:`Py_InitializeFromInitConfig` and + :c:func:`Py_InitializeFromConfig` can no longer return an exit code. + Instead, if a command line option wants to exit Python, the option is + now processed in :c:func:`Py_RunMain`. + (Contributed by Victor Stinner in :gh:`158080`.) + * :c:func:`PyModule_GetFilename` is no longer deprecated, but using :c:func:`PyModule_GetFilenameObject` instead is still recommended. (Contributed by Victor Stinner in :gh:`154757`.) diff --git a/Include/cpython/initconfig.h b/Include/cpython/initconfig.h index 1ccc496c63ac780..846e05fb26a4d83 100644 --- a/Include/cpython/initconfig.h +++ b/Include/cpython/initconfig.h @@ -11,19 +11,19 @@ typedef struct { enum { _PyStatus_TYPE_OK=0, _PyStatus_TYPE_ERROR=1, - _PyStatus_TYPE_EXIT=2 + _PyStatus_TYPE_EXIT=2 // deprecated } _type; const char *func; const char *err_msg; - int exitcode; + Py_DEPRECATED(3.16) int exitcode; } PyStatus; PyAPI_FUNC(PyStatus) PyStatus_Ok(void); PyAPI_FUNC(PyStatus) PyStatus_Error(const char *err_msg); PyAPI_FUNC(PyStatus) PyStatus_NoMemory(void); -PyAPI_FUNC(PyStatus) PyStatus_Exit(int exitcode); +Py_DEPRECATED(3.16) PyAPI_FUNC(PyStatus) PyStatus_Exit(int exitcode); PyAPI_FUNC(int) PyStatus_IsError(PyStatus err); -PyAPI_FUNC(int) PyStatus_IsExit(PyStatus err); +Py_DEPRECATED(3.16) PyAPI_FUNC(int) PyStatus_IsExit(PyStatus err); PyAPI_FUNC(int) PyStatus_Exception(PyStatus err); /* --- PyWideStringList ------------------------------------------------ */ @@ -242,6 +242,11 @@ typedef struct PyConfig { // PYTHON_PRESITE=package.module or -X presite=package.module wchar_t *run_presite; #endif + + // If a command line option wants to exit Python, store it in this member + // and only process the option in Py_RunMain() instead of PyConfig_Read(). + // If equals to 0, there is no option. + int _deferred_cmdline_option; } PyConfig; PyAPI_FUNC(void) PyConfig_InitPythonConfig(PyConfig *config); @@ -293,7 +298,7 @@ PyAPI_FUNC(void) PyInitConfig_Free(PyInitConfig *config); PyAPI_FUNC(int) PyInitConfig_GetError(PyInitConfig* config, const char **err_msg); -PyAPI_FUNC(int) PyInitConfig_GetExitCode(PyInitConfig* config, +Py_DEPRECATED(3.16) PyAPI_FUNC(int) PyInitConfig_GetExitCode(PyInitConfig* config, int *exitcode); PyAPI_FUNC(int) PyInitConfig_HasOption(PyInitConfig *config, diff --git a/Include/internal/pycore_initconfig.h b/Include/internal/pycore_initconfig.h index 183b2d45c5ede1c..50df2efdd4e5269 100644 --- a/Include/internal/pycore_initconfig.h +++ b/Include/internal/pycore_initconfig.h @@ -182,6 +182,8 @@ extern PyObject* _PyConfig_CreateXOptionsDict(const PyConfig *config); extern void _Py_DumpPathConfig(PyThreadState *tstate); +extern int _PyConfig_ProcessDeferredCmdlineOption(PyConfig *config); + /* --- Function used for testing ---------------------------------- */ diff --git a/Lib/test/test_cmd_line.py b/Lib/test/test_cmd_line.py index 4c1abb15c0cb148..476a481a0ba54b3 100644 --- a/Lib/test/test_cmd_line.py +++ b/Lib/test/test_cmd_line.py @@ -133,11 +133,16 @@ def test_site_flag(self): @support.cpython_only def test_version(self): - version = ('Python %d.%d' % sys.version_info[:2]).encode("ascii") - for switch in '-V', '--version', '-VV': - rc, out, err = assert_python_ok(switch) - self.assertNotStartsWith(err, version) - self.assertStartsWith(out, version) + short_version = ('Python %d.%d' % sys.version_info[:2]) + for switch in ('-V', '--version'): + with self.subTest(switch=switch): + rc, out, err = assert_python_ok(switch) + self.assertStartsWith(out, short_version.encode()) + self.assertEqual(err, b'') + + rc, out, err = assert_python_ok('-VV') + self.assertEqual(out.rstrip(), f"Python {sys.version}".encode()) + self.assertEqual(err, b'') def test_verbose(self): # -v causes imports to write to stderr. If the write to diff --git a/Lib/test/test_embed.py b/Lib/test/test_embed.py index 77b231323cc2757..39485afcdb0f57d 100644 --- a/Lib/test/test_embed.py +++ b/Lib/test/test_embed.py @@ -783,6 +783,7 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase): 'use_frozen_modules': not support.Py_DEBUG, 'safe_path': False, '_is_python_build': IGNORE_CONFIG, + '_deferred_cmdline_option': 0, } if Py_STATS: CONFIG_COMPAT['_pystats'] = False diff --git a/Misc/NEWS.d/next/C_API/2026-09-24-15-27-35.gh-issue-158080.YIqypf.rst b/Misc/NEWS.d/next/C_API/2026-09-24-15-27-35.gh-issue-158080.YIqypf.rst new file mode 100644 index 000000000000000..4413b328d474e48 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-09-24-15-27-35.gh-issue-158080.YIqypf.rst @@ -0,0 +1,6 @@ +Deprecate :c:func:`PyInitConfig_GetExitCode`, :c:func:`PyStatus_Exit`, and +:c:func:`PyStatus_IsExit` functions, and :c:member:`PyStatus.exitcode` +member. :c:func:`Py_InitializeFromInitConfig` and +:c:func:`Py_InitializeFromConfig` can no longer return an exit code. +Instead, if a command line option wants to exit Python, the option is +now processed in :c:func:`Py_RunMain`. Patch by Victor Stinner. diff --git a/Modules/main.c b/Modules/main.c index ef22331760906ce..44a025d500e35e2 100644 --- a/Modules/main.c +++ b/Modules/main.c @@ -749,13 +749,21 @@ pymain_set_path0(PyObject *main_importer_path) static void pymain_run_python(int *exitcode) { - int set_running_main = 0; - - PyObject *main_importer_path = NULL; PyInterpreterState *interp = _PyInterpreterState_GET(); /* pymain_repl() and pymain_run_stdin() modify the config */ PyConfig *config = (PyConfig*)_PyInterpreterState_GetConfig(interp); + // Process command line options which want to exit Python + int cmdline_exitcode = _PyConfig_ProcessDeferredCmdlineOption(config); + if (cmdline_exitcode >= 0) { + *exitcode = cmdline_exitcode; + return; + } + + int set_running_main = 0; + + PyObject *main_importer_path = NULL; + /* ensure path config is written into global variables */ PyStatus status = _PyPathConfig_UpdateGlobal(config); if (_PyStatus_EXCEPTION(status)) { @@ -910,10 +918,7 @@ static int pymain_main(_PyArgv *args) { PyStatus status = pymain_init(args); - if (_PyStatus_IS_EXIT(status)) { - pymain_free(); - return status.exitcode; - } + assert(!_PyStatus_IS_EXIT(status)); if (_PyStatus_EXCEPTION(status)) { pymain_exit_error(status); } diff --git a/Programs/_bootstrap_python.c b/Programs/_bootstrap_python.c index d30ef8c879d8153..c65d616afbd5010 100644 --- a/Programs/_bootstrap_python.c +++ b/Programs/_bootstrap_python.c @@ -104,9 +104,6 @@ main(int argc, char **argv) error: PyConfig_Clear(&config); - if (PyStatus_IsExit(status)) { - return status.exitcode; - } Py_ExitStatusException(status); } diff --git a/Programs/_testembed.c b/Programs/_testembed.c index 79e817829c15947..592c23756201f01 100644 --- a/Programs/_testembed.c +++ b/Programs/_testembed.c @@ -1722,7 +1722,7 @@ static int test_initconfig_api(void) goto error; } - // Set a list of UTF-8 strings (argv) + // Set a list of UTF-8 strings (xoptions) char* xoptions[] = {"faulthandler"}; if (PyInitConfig_SetStrList(config, "xoptions", Py_ARRAY_LENGTH(xoptions), xoptions) < 0) { @@ -1814,29 +1814,25 @@ static int test_initconfig_get_api(void) static int test_initconfig_exit(void) { + // -h command line option is stored as PyConfig._deferred_cmdline_option PyInitConfig *config = PyInitConfig_Create(); if (config == NULL) { printf("Init allocation error\n"); return 1; } - char *argv[] = {PROGRAM_NAME_UTF8, "--help"}; + char *argv[] = {PROGRAM_NAME_UTF8, "-h"}; assert(PyInitConfig_SetStrList(config, "argv", Py_ARRAY_LENGTH(argv), argv) == 0); - assert(PyInitConfig_SetInt(config, "parse_argv", 1) == 0); - assert(Py_InitializeFromInitConfig(config) < 0); - - int exitcode; - assert(PyInitConfig_GetExitCode(config, &exitcode) == 1); - assert(exitcode == 0); + assert(Py_InitializeFromInitConfig(config) == 0); + PyInitConfig_Free(config); - const char *err_msg; - assert(PyInitConfig_GetError(config, &err_msg) == 1); - assert(strcmp(err_msg, "exit code 0") == 0); + const PyConfig *rt_config = _Py_GetConfig(); + assert(rt_config->_deferred_cmdline_option == 'h'); - PyInitConfig_Free(config); + Py_Finalize(); return 0; } diff --git a/Python/getopt.c b/Python/getopt.c index 79bea2359ffffcf..7e918189c716a9e 100644 --- a/Python/getopt.c +++ b/Python/getopt.c @@ -41,10 +41,10 @@ static const wchar_t *opt_ptr = L""; static const _PyOS_LongOption longopts[] = { /* name, has_arg, val (used in switch in initconfig.c) */ - {L"check-hash-based-pycs", 1, 0}, - {L"help-all", 0, 1}, - {L"help-env", 0, 2}, - {L"help-xoptions", 0, 3}, + {L"check-hash-based-pycs", 1, 1}, + {L"help-all", 0, 2}, + {L"help-env", 0, 3}, + {L"help-xoptions", 0, 4}, {NULL, 0, -1}, /* sentinel */ }; diff --git a/Python/initconfig.c b/Python/initconfig.c index d683fdd6abc6e1b..6de12db9d600ecc 100644 --- a/Python/initconfig.c +++ b/Python/initconfig.c @@ -204,6 +204,7 @@ static const PyConfigSpec PYCONFIG_SPEC[] = { SPEC(module_search_paths_set, BOOL, INIT_ONLY, NO_SYS), SPEC(pythonpath_env, WSTR_OPT, INIT_ONLY, NO_SYS), SPEC(sys_path_0, WSTR_OPT, INIT_ONLY, NO_SYS), + SPEC(_deferred_cmdline_option, INT, INIT_ONLY, NO_SYS), // Array terminator {NULL, 0, 0, 0, NO_SYS}, @@ -2996,13 +2997,17 @@ static PyStatus config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, Py_ssize_t *opt_index) { + // Only store the first option +#define DEFER_OPTION(OPTION) \ + do { \ + if (config->_deferred_cmdline_option == 0) { \ + config->_deferred_cmdline_option = (OPTION); \ + } \ + } while (0) + PyStatus status; const PyWideStringList *argv = &config->argv; int print_version = 0; - const wchar_t* program = config->program_name; - if (!program && argv->length >= 1) { - program = argv->items[0]; - } _PyOS_ResetGetOpt(); do { @@ -3045,7 +3050,7 @@ config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, switch (c) { // Integers represent long options, see Python/getopt.c - case 0: + case 1: // check-hash-based-pycs if (wcscmp(_PyOS_optarg, L"always") == 0 || wcscmp(_PyOS_optarg, L"never") == 0 @@ -3057,27 +3062,24 @@ config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, return status; } } else { - fprintf(stderr, "--check-hash-based-pycs must be one of " - "'default', 'always', or 'never'\n"); - config_usage(1, program); - return _PyStatus_EXIT(2); + DEFER_OPTION(c); } break; - case 1: + case 2: // help-all - config_complete_usage(program); - return _PyStatus_EXIT(0); + DEFER_OPTION(c); + break; - case 2: + case 3: // help-env - config_envvars_usage(); - return _PyStatus_EXIT(0); + DEFER_OPTION(c); + break; - case 3: + case 4: // help-xoptions - config_xoptions_usage(); - return _PyStatus_EXIT(0); + DEFER_OPTION(c); + break; case 'b': config->bytes_warning++; @@ -3136,8 +3138,8 @@ config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, case 'h': case '?': - config_usage(0, program); - return _PyStatus_EXIT(0); + DEFER_OPTION(c); + break; case 'V': print_version++; @@ -3162,15 +3164,16 @@ config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, default: /* unknown argument: parsing failed */ - config_usage(1, program); - return _PyStatus_EXIT(2); + DEFER_OPTION(c); + break; } - } while (1); + } while (config->_deferred_cmdline_option == 0); - if (print_version) { - printf("Python %s\n", - (print_version >= 2) ? Py_GetVersion() : PY_VERSION); - return _PyStatus_EXIT(0); + if (print_version >= 2) { + DEFER_OPTION('W'); + } + else if (print_version >= 1) { + DEFER_OPTION('V'); } if (config->run_command == NULL && config->run_module == NULL @@ -3192,6 +3195,72 @@ config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions, *opt_index = _PyOS_optind; return _PyStatus_OK(); + +#undef DEFER_OPTION +} + + +int +_PyConfig_ProcessDeferredCmdlineOption(PyConfig *config) +{ + int c = config->_deferred_cmdline_option; + config->_deferred_cmdline_option = 0; + if (c == 0) { + // There is no deferred option + return -1; + } + + // Select the program name + const PyWideStringList *argv = &config->argv; + const wchar_t* program = config->program_name; + if (!program && argv->length >= 1) { + program = argv->items[0]; + } + if (!program) { + program = L"python"; + } + + switch (c) { + case 'h': + case '?': + config_usage(0, program); + return 0; + + case 'V': + printf("Python %s\n", PY_VERSION); + return 0; + + case 'W': // -VV or more -V options + printf("Python %s\n", Py_GetVersion()); + return 0; + + // Integers represent long options, see Python/getopt.c + case 1: + // check-hash-based-pycs + fprintf(stderr, "--check-hash-based-pycs must be one of " + "'default', 'always', or 'never'\n"); + config_usage(1, program); + return 2; + + case 2: + // help-all + config_complete_usage(program); + return 0; + + case 3: + // help-env + config_envvars_usage(); + return 0; + + case 4: + // help-xoptions + config_xoptions_usage(); + return 0; + + default: + config_usage(1, program); + return 2; + } } @@ -3634,7 +3703,6 @@ config_argv0_add_exe(PyConfig *config) * Command line arguments * Environment variables - * Py_xxx global configuration variables The only side effects are to modify config and to call _Py_SetArgcArgv(). */ PyStatus @@ -3966,22 +4034,7 @@ PyInitConfig_Free(PyInitConfig *config) int PyInitConfig_GetError(PyInitConfig* config, const char **perr_msg) { - if (_PyStatus_IS_EXIT(config->status)) { - char buffer[22]; // len("exit code -2147483648\0") - PyOS_snprintf(buffer, sizeof(buffer), - "exit code %i", - config->status.exitcode); - - if (config->err_msg != NULL) { - free(config->err_msg); - } - config->err_msg = strdup(buffer); - if (config->err_msg != NULL) { - *perr_msg = config->err_msg; - return 1; - } - config->status = _PyStatus_NO_MEMORY(); - } + assert(!_PyStatus_IS_EXIT(config->status)); if (_PyStatus_IS_ERROR(config->status) && config->status.err_msg != NULL) { *perr_msg = config->status.err_msg; @@ -3997,13 +4050,8 @@ PyInitConfig_GetError(PyInitConfig* config, const char **perr_msg) int PyInitConfig_GetExitCode(PyInitConfig* config, int *exitcode) { - if (_PyStatus_IS_EXIT(config->status)) { - *exitcode = config->status.exitcode; - return 1; - } - else { - return 0; - } + assert(!_PyStatus_IS_EXIT(config->status)); + return 0; } diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index a9c98d73fd0f721..8888f523559123a 100644 --- a/Python/pylifecycle.c +++ b/Python/pylifecycle.c @@ -3830,10 +3830,9 @@ _Py_FatalRefcountErrorFunc(const char *func, const char *msg) void _Py_NO_RETURN Py_ExitStatusException(PyStatus status) { - if (_PyStatus_IS_EXIT(status)) { - exit(status.exitcode); - } - else if (_PyStatus_IS_ERROR(status)) { + assert(!_PyStatus_IS_EXIT(status)); + + if (_PyStatus_IS_ERROR(status)) { fatal_error(fileno(stderr), 1, status.func, status.err_msg, 1); } else { From c04387348eb8223977e46f4972c08833c11c6617 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Fri, 25 Sep 2026 13:41:16 +0100 Subject: [PATCH 2/6] gh-157325: Fix an OOB read in the `hz` incremental decoder on a trailing `~` (gh-157333) --- Lib/test/test_multibytecodec.py | 9 +++++++++ .../2026-09-11-16-50-11.gh-issue-157325.hzTild.rst | 2 ++ Modules/cjkcodecs/_codecs_cn.c | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-11-16-50-11.gh-issue-157325.hzTild.rst diff --git a/Lib/test/test_multibytecodec.py b/Lib/test/test_multibytecodec.py index 6b032fc8604eefd..0cd822fef874003 100644 --- a/Lib/test/test_multibytecodec.py +++ b/Lib/test/test_multibytecodec.py @@ -265,6 +265,15 @@ def test_iso2022(self): self.assertRaises(UnicodeDecodeError, decoder.decode, b'', True) self.assertEqual(decoder.decode(b'B@$'), '\u4e16') + def test_hz_keep_buffer(self): + # A trailing '~' shouldn't read past the end of the input. + decoder = codecs.getincrementaldecoder('hz')() + self.assertEqual(decoder.decode(b'~'), '') + self.assertRaises(UnicodeDecodeError, decoder.decode, b'', True) + self.assertEqual(decoder.decode(b'~'), '~') + self.assertEqual(decoder.decode(b'~'), '') + self.assertEqual(decoder.decode(b'\n', True), '') + def test_decode_unicode(self): # Trying to decode a unicode string should raise a TypeError for enc in ALL_CJKENCODINGS: diff --git a/Misc/NEWS.d/next/Library/2026-09-11-16-50-11.gh-issue-157325.hzTild.rst b/Misc/NEWS.d/next/Library/2026-09-11-16-50-11.gh-issue-157325.hzTild.rst new file mode 100644 index 000000000000000..a5adf101c9dad3c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-11-16-50-11.gh-issue-157325.hzTild.rst @@ -0,0 +1,2 @@ +Fix an out-of-bounds read in the ``hz`` incremental decoder when the input +ends with ``~``. diff --git a/Modules/cjkcodecs/_codecs_cn.c b/Modules/cjkcodecs/_codecs_cn.c index e2c7908c9bb2753..dc89f1d899e36be 100644 --- a/Modules/cjkcodecs/_codecs_cn.c +++ b/Modules/cjkcodecs/_codecs_cn.c @@ -414,9 +414,9 @@ DECODER(hz) Py_UCS4 decoded; if (c == '~') { + REQUIRE_INBUF(2); unsigned char c2 = INBYTE2; - REQUIRE_INBUF(2); if (c2 == '~' && state->c[CN_STATE_OFFSET] == 0) OUTCHAR('~'); else if (c2 == '{' && state->c[CN_STATE_OFFSET] == 0) From 8c056c33aacea5768f5e2010401f2b98ecd7a305 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 25 Sep 2026 15:49:12 +0300 Subject: [PATCH 3/6] gh-158102: Add tests for the slice C API (GH-158120) Test PySlice_Check(), PySlice_New(), PySlice_GetIndices(), PySlice_Unpack(), PySlice_AdjustIndices() and PySlice_GetIndicesEx() -- both the macro and the deprecated function. Document the length and step precondition of PySlice_AdjustIndices() Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Victor Stinner --- Doc/c-api/slice.rst | 5 + Lib/test/test_capi/test_slice.py | 307 +++++++++++++++++++++++ Modules/Setup.stdlib.in | 2 +- Modules/_testlimitedcapi.c | 3 + Modules/_testlimitedcapi/parts.h | 1 + Modules/_testlimitedcapi/slice.c | 240 ++++++++++++++++++ PCbuild/_testlimitedcapi.vcxproj | 1 + PCbuild/_testlimitedcapi.vcxproj.filters | 1 + 8 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 Lib/test/test_capi/test_slice.py create mode 100644 Modules/_testlimitedcapi/slice.c diff --git a/Doc/c-api/slice.rst b/Doc/c-api/slice.rst index c6d761fe7fd1c96..ce2b64a8aca8530 100644 --- a/Doc/c-api/slice.rst +++ b/Doc/c-api/slice.rst @@ -53,6 +53,7 @@ Slice Objects length *length*, and store the length of the slice in *slicelength*. Out of bounds indices are clipped in a manner consistent with the handling of normal slices. + *length* must not be negative. Return ``0`` on success and ``-1`` on error with an exception set. @@ -108,6 +109,10 @@ Slice Objects Out of bounds indices are clipped in a manner consistent with the handling of normal slices. + *length* must not be negative. + *step* must not be zero and must not be less than ``-PY_SSIZE_T_MAX``, + as guaranteed by :c:func:`PySlice_Unpack`. + Return the length of the slice. Always successful. Doesn't call Python code. diff --git a/Lib/test/test_capi/test_slice.py b/Lib/test/test_capi/test_slice.py new file mode 100644 index 000000000000000..3f41b55a609f48f --- /dev/null +++ b/Lib/test/test_capi/test_slice.py @@ -0,0 +1,307 @@ +import sys +import unittest +from test.support import import_helper + +_testlimitedcapi = import_helper.import_module('_testlimitedcapi') + +NULL = None +SSIZE_MAX = sys.maxsize +SSIZE_MIN = -sys.maxsize - 1 + +VALUES = [None, 0, 1, 3, 7, -1, -3, -7] +STEPS = [None, 1, 3, 5, -1, -3, -5] +LENGTHS = [0, 1, 3, 10] + + +class Index: + def __init__(self, value): + self.value = value + + def __index__(self): + return self.value + + +class BadIndex: + def __index__(self): + raise RuntimeError('bad index') + + +class PopIndex: + # __index__() removes the last item of the list. + def __init__(self, value, seq): + self.value = value + self.seq = seq + + def __index__(self): + self.seq.pop() + return self.value + + +class SliceTest(unittest.TestCase): + + def test_check(self): + # Test PySlice_Check() + check = _testlimitedcapi.slice_check + self.assertTrue(check(slice(1, 7, 2))) + self.assertFalse(check(object())) + + # CRASHES check(NULL) + + def test_new(self): + # Test PySlice_New() + new = _testlimitedcapi.slice_new + self.assertEqual(new(1, 7, 2), slice(1, 7, 2)) + self.assertEqual(new(7, 1, -2), slice(7, 1, -2)) + self.assertEqual(new('a', 'b', 'c'), slice('a', 'b', 'c')) + self.assertEqual(new(NULL, NULL, NULL), slice(None, None, None)) + + def test_getindices(self): + # Test PySlice_GetIndices() + getindices = _testlimitedcapi.slice_getindices + self.assertEqual(getindices(slice(1, 7, 2), 10), (1, 7, 2)) + self.assertEqual(getindices(slice(None), 10), (0, 10, 1)) + self.assertEqual(getindices(slice(None, None, -1), 10), (9, -1, -1)) + self.assertEqual(getindices(slice(-3, -1), 10), (7, 9, 1)) + self.assertEqual(getindices(slice(-1, -3, -1), 10), (9, 7, -1)) + self.assertEqual(getindices(slice(None, None, -2), 0), (-1, -1, -2)) + self.assertEqual(getindices(slice(-3, -5, 1), 0), (-3, -5, 1)) + + # It fails without setting an exception for out of bounds indices, + # a zero step and non-integer indices. + self.assertIsNone(getindices(slice(1, 11), 10)) + self.assertIsNone(getindices(slice(10, 1), 10)) + self.assertIsNone(getindices(slice(1, 7, 0), 10)) + self.assertIsNone(getindices(slice(Index(1)), 10)) + self.assertIsNone(getindices(slice('a'), 10)) + self.assertIsNone(getindices(slice(1, 'a'), 10)) + self.assertIsNone(getindices(slice(1, 7, 'a'), 10)) + + # Negative length is not supported, but does not fail. + self.assertIsNone(getindices(slice(None), -3)) + self.assertIsNone(getindices(slice(1, 7, 2), -3)) + self.assertEqual(getindices(slice(-10, -5, 1), -3), (-13, -8, 1)) + self.assertEqual(getindices(slice(-5, -10, -2), -3), (-8, -13, -2)) + + # CRASHES getindices(NULL, 10) + # CRASHES getindices(object(), 10) + + def test_unpack(self): + # Test PySlice_Unpack() + unpack = _testlimitedcapi.slice_unpack + self.assertEqual(unpack(slice(1, 7)), (1, 7, 1)) + self.assertEqual(unpack(slice(1, 7, 2)), (1, 7, 2)) + self.assertEqual(unpack(slice(7, 1, -2)), (7, 1, -2)) + self.assertEqual(unpack(slice(None, 7, 2)), (0, 7, 2)) + self.assertEqual(unpack(slice(None, 7, -2)), (SSIZE_MAX, 7, -2)) + self.assertEqual(unpack(slice(1, None, 2)), (1, SSIZE_MAX, 2)) + self.assertEqual(unpack(slice(1, None, -2)), (1, SSIZE_MIN, -2)) + self.assertEqual(unpack(slice(None)), (0, SSIZE_MAX, 1)) + self.assertEqual(unpack(slice(None, None, -1)), + (SSIZE_MAX, SSIZE_MIN, -1)) + # Negative indices are not adjusted. + self.assertEqual(unpack(slice(-3, -1)), (-3, -1, 1)) + self.assertEqual(unpack(slice(Index(1), Index(7), Index(2))), + (1, 7, 2)) + + # Values which do not fit in Py_ssize_t are silently clipped. + self.assertEqual(unpack(slice(1, 2**1000)), (1, SSIZE_MAX, 1)) + self.assertEqual(unpack(slice(1, -2**1000)), (1, SSIZE_MIN, 1)) + self.assertEqual(unpack(slice(2**1000, 7)), (SSIZE_MAX, 7, 1)) + self.assertEqual(unpack(slice(-2**1000, 7)), (SSIZE_MIN, 7, 1)) + self.assertEqual(unpack(slice(1, 7, 2**1000)), (1, 7, SSIZE_MAX)) + # The step is boosted to -PY_SSIZE_T_MAX, not PY_SSIZE_T_MIN, so + # that negating it is safe. + self.assertEqual(unpack(slice(7, 1, -2**1000)), (7, 1, -SSIZE_MAX)) + self.assertEqual(unpack(slice(7, 1, SSIZE_MIN)), (7, 1, -SSIZE_MAX)) + + with self.assertRaisesRegex(ValueError, 'slice step cannot be zero'): + unpack(slice(1, 1, 0)) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + unpack(slice('a', 7)) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + unpack(slice(1, 'a')) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + unpack(slice(1, 7, 'a')) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + unpack(slice(BadIndex(), 7)) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + unpack(slice(1, BadIndex())) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + unpack(slice(1, 7, BadIndex())) + + # CRASHES unpack(NULL) + # CRASHES unpack(object()) + + def test_adjustindices(self): + # Test PySlice_AdjustIndices() + adjust = _testlimitedcapi.slice_adjustindices + self.assertEqual(adjust(10, 1, 7, 1), (6, 1, 7)) + self.assertEqual(adjust(10, 1, 7, 2), (3, 1, 7)) + self.assertEqual(adjust(10, 7, 1, -1), (6, 7, 1)) + self.assertEqual(adjust(10, 7, 1, -2), (3, 7, 1)) + # An empty slice keeps the adjusted indices. + self.assertEqual(adjust(10, 7, 1, 1), (0, 7, 1)) + self.assertEqual(adjust(10, 1, 7, -1), (0, 1, 7)) + + # Negative indices are added to the length. + self.assertEqual(adjust(10, -9, -3, 1), (6, 1, 7)) + self.assertEqual(adjust(10, -3, -9, -1), (6, 7, 1)) + + # Out of bounds indices are clipped. + self.assertEqual(adjust(10, -100, 100, 1), (10, 0, 10)) + self.assertEqual(adjust(10, 100, -100, -1), (10, 9, -1)) + self.assertEqual(adjust(10, SSIZE_MIN, SSIZE_MAX, 1), (10, 0, 10)) + self.assertEqual(adjust(10, SSIZE_MAX, SSIZE_MIN, -1), (10, 9, -1)) + self.assertEqual(adjust(0, 1, 7, 1), (0, 0, 0)) + self.assertEqual(adjust(0, 7, 1, -1), (0, -1, -1)) + + # The returned length is the length of the corresponding range. + for length in LENGTHS: + for start in VALUES[1:]: + for stop in VALUES[1:]: + for step in STEPS[1:]: + with self.subTest(length=length, start=start, + stop=stop, step=step): + slicelength, start2, stop2 = adjust(length, start, + stop, step) + self.assertEqual(slicelength, + len(range(start2, stop2, step))) + + # Negative length is not supported, but does not fail. + self.assertEqual(adjust(-3, 1, 7, 1), (0, -3, -3)) + self.assertEqual(adjust(-3, 7, 1, -1), (0, -4, -4)) + self.assertEqual(adjust(-3, -10, -5, 1), (0, 0, 0)) + + # The step is asserted to be neither zero nor less than + # -PY_SSIZE_T_MAX. + # CRASHES adjust(10, 0, 10, 0) + # CRASHES adjust(10, 0, 10, SSIZE_MIN) + + +class GetIndicesExMacroTest(unittest.TestCase): + # PySlice_GetIndicesEx() is a macro using PySlice_Unpack() and + # PySlice_AdjustIndices(). It is also a deprecated function, exported + # for the stable ABI. + getindicesex = staticmethod(_testlimitedcapi.slice_getindicesex_macro) + getindicesex_seq = staticmethod( + _testlimitedcapi.slice_getindicesex_seq_macro) + # The macro evaluates the length after calling PySlice_Unpack(), so the + # size of the list after removing an item is used. + resized = (6, 8, 1, 2) + + def test_getindicesex(self): + # Test PySlice_GetIndicesEx() + getindicesex = self.getindicesex + self.assertEqual(getindicesex(slice(1, 7, 2), 10), (1, 7, 2, 3)) + self.assertEqual(getindicesex(slice(7, 1, -2), 10), (7, 1, -2, 3)) + self.assertEqual(getindicesex(slice(Index(1), Index(7), Index(2)), 10), + (1, 7, 2, 3)) + + # The result agrees with slice.indices() and the slice length is + # the length of the corresponding range. + for length in LENGTHS: + for start in VALUES: + for stop in VALUES: + for step in STEPS: + s = slice(start, stop, step) + with self.subTest(slice=s, length=length): + indices = s.indices(length) + self.assertEqual(getindicesex(s, length), + indices + (len(range(*indices)),)) + + # Negative indices are added to the length. + self.assertEqual(getindicesex(slice(-9, -3), 10), (1, 7, 1, 6)) + self.assertEqual(getindicesex(slice(-3, -9, -1), 10), (7, 1, -1, 6)) + + # Out of bounds indices are clipped. + self.assertEqual(getindicesex(slice(-100, 100), 10), (0, 10, 1, 10)) + self.assertEqual(getindicesex(slice(100, -100, -1), 10), + (9, -1, -1, 10)) + self.assertEqual(getindicesex(slice(None), 0), (0, 0, 1, 0)) + self.assertEqual(getindicesex(slice(1, 7, 2), 0), (0, 0, 2, 0)) + self.assertEqual(getindicesex(slice(None, None, -1), 0), + (-1, -1, -1, 0)) + + # Indices which do not fit in Py_ssize_t are clipped, not rejected. + # Note that slice.indices() does not clip the step. + self.assertEqual(getindicesex(slice(1, 2**1000), 10), (1, 10, 1, 9)) + self.assertEqual(getindicesex(slice(2**1000, 7), 10), (10, 7, 1, 0)) + self.assertEqual(getindicesex(slice(1, 7, 2**1000), 10), + (1, 7, SSIZE_MAX, 1)) + # -PY_SSIZE_T_MAX-1 is replaced with -PY_SSIZE_T_MAX. + self.assertEqual(getindicesex(slice(7, 1, -2**1000), 10), + (7, 1, -SSIZE_MAX, 1)) + self.assertEqual(getindicesex(slice(7, 1, SSIZE_MIN), 10), + (7, 1, -SSIZE_MAX, 1)) + + with self.assertRaisesRegex(ValueError, 'slice step cannot be zero'): + getindicesex(slice(1, 7, 0), 10) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + getindicesex(slice('a', 7), 10) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + getindicesex(slice(1, 'a'), 10) + with self.assertRaisesRegex(TypeError, + 'slice indices must be integers'): + getindicesex(slice(1, 7, 'a'), 10) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + getindicesex(slice(BadIndex(), 7), 10) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + getindicesex(slice(1, BadIndex()), 10) + with self.assertRaisesRegex(RuntimeError, 'bad index'): + getindicesex(slice(1, 7, BadIndex()), 10) + + # Negative length is not supported, but does not fail. + self.assertEqual(getindicesex(slice(None), -3), (-3, -3, 1, 0)) + self.assertEqual(getindicesex(slice(1, 7, 2), -3), (-3, -3, 2, 0)) + self.assertEqual(getindicesex(slice(7, 1, -2), -3), (-4, -4, -2, 0)) + + # CRASHES getindicesex(NULL, 10) + # CRASHES getindicesex(object(), 10) + + def test_getindicesex_seq(self): + # The length is the size of a sequence. + getindicesex_seq = self.getindicesex_seq + seq = list(range(10)) + self.assertEqual(getindicesex_seq(slice(-3, -1), seq), (7, 9, 1, 2)) + self.assertEqual(getindicesex_seq(slice(-3, -1), []), (0, 0, 1, 0)) + + # gh-72054: __index__() can resize the sequence. Negative indices + # are adjusted by the length, so the result depends on when it is + # evaluated. + seq = list(range(10)) + self.assertEqual(getindicesex_seq(slice(PopIndex(-3, seq), -1), seq), + self.resized) + self.assertEqual(len(seq), 9, seq) + + seq = list(range(10)) + self.assertEqual(getindicesex_seq(slice(-3, PopIndex(-1, seq)), seq), + self.resized) + self.assertEqual(len(seq), 9, seq) + + seq = list(range(10)) + self.assertEqual( + getindicesex_seq(slice(-3, -1, PopIndex(1, seq)), seq), + self.resized) + self.assertEqual(len(seq), 9, seq) + + # CRASHES getindicesex_seq(slice(None), NULL) + # CRASHES getindicesex_seq(slice(None), object()) + + +class GetIndicesExFuncTest(GetIndicesExMacroTest): + # The deprecated function is equivalent to the macro, except that the + # length is evaluated before the call. + getindicesex = staticmethod(_testlimitedcapi.slice_getindicesex_func) + getindicesex_seq = staticmethod( + _testlimitedcapi.slice_getindicesex_seq_func) + # The size of the list before removing an item is used. + resized = (7, 9, 1, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index 696fec1290e5f3e..0ca1d90ac4f30b7 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -174,7 +174,7 @@ @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c _testinternalcapi/interpreter.c _testinternalcapi/tokenizer.c _testinternalcapi/tuple.c _testinternalcapi/typecache.c @MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/module.c _testcapi/weakref.c _testcapi/marshal.c -@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/capsule.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/slots.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c _testlimitedcapi/weakref.c _testlimitedcapi/run.c _testlimitedcapi/type.c _testlimitedcapi/hash.c +@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/capsule.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/slice.c _testlimitedcapi/slots.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c _testlimitedcapi/weakref.c _testlimitedcapi/run.c _testlimitedcapi/type.c _testlimitedcapi/hash.c @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c diff --git a/Modules/_testlimitedcapi.c b/Modules/_testlimitedcapi.c index b30e32c56c57041..0d290eb5ef5b9e4 100644 --- a/Modules/_testlimitedcapi.c +++ b/Modules/_testlimitedcapi.c @@ -58,6 +58,9 @@ module_exec(PyObject *mod) if (_PyTestLimitedCAPI_Init_Set(mod) < 0) { return -1; } + if (_PyTestLimitedCAPI_Init_Slice(mod) < 0) { + return -1; + } if (_PyTestLimitedCAPI_Init_Slots(mod) < 0) { return -1; } diff --git a/Modules/_testlimitedcapi/parts.h b/Modules/_testlimitedcapi/parts.h index e8a2d82fb94aea4..ab6f76f3d06699e 100644 --- a/Modules/_testlimitedcapi/parts.h +++ b/Modules/_testlimitedcapi/parts.h @@ -40,6 +40,7 @@ int _PyTestLimitedCAPI_Init_List(PyObject *module); int _PyTestLimitedCAPI_Init_Long(PyObject *module); int _PyTestLimitedCAPI_Init_PyOS(PyObject *module); int _PyTestLimitedCAPI_Init_Set(PyObject *module); +int _PyTestLimitedCAPI_Init_Slice(PyObject *module); int _PyTestLimitedCAPI_Init_Slots(PyObject *module); int _PyTestLimitedCAPI_Init_Sys(PyObject *module); int _PyTestLimitedCAPI_Init_ThreadState(PyObject *module); diff --git a/Modules/_testlimitedcapi/slice.c b/Modules/_testlimitedcapi/slice.c new file mode 100644 index 000000000000000..febb3d0de3cfa6c --- /dev/null +++ b/Modules/_testlimitedcapi/slice.c @@ -0,0 +1,240 @@ +#include "pyconfig.h" // Py_GIL_DISABLED +#ifdef Py_GIL_DISABLED +# define Py_TARGET_ABI3T 0x030f0000 +#else + // Need limited C API 3.6.1 for PySlice_Unpack() and PySlice_AdjustIndices() + // and for PySlice_GetIndicesEx() implemented as a macro. +# define Py_LIMITED_API 0x03060100 +#endif + +#include "parts.h" +#include "util.h" + + +static PyObject * +slice_check(PyObject *Py_UNUSED(module), PyObject *obj) +{ + NULLABLE(obj); + return PyLong_FromLong(PySlice_Check(obj)); +} + +static PyObject * +slice_new(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *start, *stop, *step; + + if (!PyArg_ParseTuple(args, "OOO", &start, &stop, &step)) { + return NULL; + } + NULLABLE(start); + NULLABLE(stop); + NULLABLE(step); + return PySlice_New(start, stop, step); +} + +/* Returns the (start, stop, step) triple on success. If PySlice_GetIndices() + * fails without setting an exception, returns None. */ +static PyObject * +slice_getindices(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *slice; + Py_ssize_t length; + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + + if (!PyArg_ParseTuple(args, "On", &slice, &length)) { + return NULL; + } + NULLABLE(slice); + if (PySlice_GetIndices(slice, length, &start, &stop, &step) < 0) { + if (PyErr_Occurred()) { + return NULL; + } + Py_RETURN_NONE; + } + assert(!PyErr_Occurred()); + assert(start != UNINITIALIZED_SIZE); + assert(stop != UNINITIALIZED_SIZE); + assert(step != UNINITIALIZED_SIZE); + return Py_BuildValue("nnn", start, stop, step); +} + +/* Test PySlice_GetIndicesEx() implemented as a macro using PySlice_Unpack() + * and PySlice_AdjustIndices(). */ +static PyObject * +slice_getindicesex_macro(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *slice; + Py_ssize_t length = UNINITIALIZED_SIZE; + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + Py_ssize_t slicelength = UNINITIALIZED_SIZE; + + if (!PyArg_ParseTuple(args, "On", &slice, &length)) { + return NULL; + } + NULLABLE(slice); + if (PySlice_GetIndicesEx(slice, length, + &start, &stop, &step, &slicelength) < 0) { + assert(PyErr_Occurred()); + /* The macro sets the slice length to 0 on error. */ + assert(slicelength == 0); + return NULL; + } + assert(!PyErr_Occurred()); + assert(start != UNINITIALIZED_SIZE); + assert(stop != UNINITIALIZED_SIZE); + assert(step != UNINITIALIZED_SIZE); + assert(slicelength != UNINITIALIZED_SIZE); + return Py_BuildValue("nnnn", start, stop, step, slicelength); +} + +/* Same as slice_getindicesex_macro(), but the length is the size of a sequence. + * The macro evaluates it after calling PySlice_Unpack(), which can execute + * arbitrary Python code and resize the sequence. */ +static PyObject * +slice_getindicesex_seq_macro(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *slice, *seq; + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + Py_ssize_t slicelength = UNINITIALIZED_SIZE; + + if (!PyArg_ParseTuple(args, "OO", &slice, &seq)) { + return NULL; + } + NULLABLE(slice); + NULLABLE(seq); + if (PySlice_GetIndicesEx(slice, Py_SIZE(seq), + &start, &stop, &step, &slicelength) < 0) { + assert(PyErr_Occurred()); + assert(slicelength == 0); + return NULL; + } + assert(!PyErr_Occurred()); + return Py_BuildValue("nnnn", start, stop, step, slicelength); +} + +static PyObject * +slice_unpack(PyObject *Py_UNUSED(module), PyObject *slice) +{ + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + + NULLABLE(slice); + if (PySlice_Unpack(slice, &start, &stop, &step) < 0) { + assert(PyErr_Occurred()); + return NULL; + } + assert(!PyErr_Occurred()); + assert(start != UNINITIALIZED_SIZE); + assert(stop != UNINITIALIZED_SIZE); + assert(step != UNINITIALIZED_SIZE); + return Py_BuildValue("nnn", start, stop, step); +} + +static PyObject * +slice_adjustindices(PyObject *Py_UNUSED(module), PyObject *args) +{ + Py_ssize_t length, start, stop, step; + + if (!PyArg_ParseTuple(args, "nnnn", &length, &start, &stop, &step)) { + return NULL; + } + Py_ssize_t slicelength = PySlice_AdjustIndices(length, &start, &stop, step); + assert(!PyErr_Occurred()); + return Py_BuildValue("nnn", slicelength, start, stop); +} + +#undef PySlice_GetIndicesEx + +/* Test the deprecated PySlice_GetIndicesEx() function. It is still exported + * for the stable ABI and used if Py_LIMITED_API is older than 3.5.4. */ +static PyObject * +slice_getindicesex_func(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *slice; + Py_ssize_t length; + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + Py_ssize_t slicelength = UNINITIALIZED_SIZE; + + if (!PyArg_ParseTuple(args, "On", &slice, &length)) { + return NULL; + } + NULLABLE(slice); +// Ignore deprecation warnings +_Py_COMP_DIAG_PUSH +_Py_COMP_DIAG_IGNORE_DEPR_DECLS + int res = PySlice_GetIndicesEx(slice, length, + &start, &stop, &step, &slicelength); +_Py_COMP_DIAG_POP + if (res < 0) { + assert(PyErr_Occurred()); + return NULL; + } + assert(!PyErr_Occurred()); + assert(start != UNINITIALIZED_SIZE); + assert(stop != UNINITIALIZED_SIZE); + assert(step != UNINITIALIZED_SIZE); + assert(slicelength != UNINITIALIZED_SIZE); + return Py_BuildValue("nnnn", start, stop, step, slicelength); +} + + +/* Same as slice_getindicesex_seq_macro(), but using the deprecated function. + * The length is evaluated before the call. */ +static PyObject * +slice_getindicesex_seq_func(PyObject *Py_UNUSED(module), PyObject *args) +{ + PyObject *slice, *seq; + Py_ssize_t start = UNINITIALIZED_SIZE; + Py_ssize_t stop = UNINITIALIZED_SIZE; + Py_ssize_t step = UNINITIALIZED_SIZE; + Py_ssize_t slicelength = UNINITIALIZED_SIZE; + + if (!PyArg_ParseTuple(args, "OO", &slice, &seq)) { + return NULL; + } + NULLABLE(slice); + NULLABLE(seq); +// Ignore deprecation warnings +_Py_COMP_DIAG_PUSH +_Py_COMP_DIAG_IGNORE_DEPR_DECLS + int res = PySlice_GetIndicesEx(slice, Py_SIZE(seq), + &start, &stop, &step, &slicelength); +_Py_COMP_DIAG_POP + if (res < 0) { + assert(PyErr_Occurred()); + return NULL; + } + assert(!PyErr_Occurred()); + return Py_BuildValue("nnnn", start, stop, step, slicelength); +} + + +static PyMethodDef test_methods[] = { + {"slice_check", slice_check, METH_O}, + {"slice_new", slice_new, METH_VARARGS}, + {"slice_getindices", slice_getindices, METH_VARARGS}, + {"slice_getindicesex_macro", slice_getindicesex_macro, METH_VARARGS}, + {"slice_getindicesex_seq_macro", slice_getindicesex_seq_macro, METH_VARARGS}, + {"slice_getindicesex_func", slice_getindicesex_func, + METH_VARARGS}, + {"slice_getindicesex_seq_func", slice_getindicesex_seq_func, + METH_VARARGS}, + {"slice_unpack", slice_unpack, METH_O}, + {"slice_adjustindices", slice_adjustindices, METH_VARARGS}, + {NULL}, +}; + +int +_PyTestLimitedCAPI_Init_Slice(PyObject *m) +{ + return PyModule_AddFunctions(m, test_methods); +} diff --git a/PCbuild/_testlimitedcapi.vcxproj b/PCbuild/_testlimitedcapi.vcxproj index 4218e0ed3945d27..2995dbb469ed3dd 100644 --- a/PCbuild/_testlimitedcapi.vcxproj +++ b/PCbuild/_testlimitedcapi.vcxproj @@ -110,6 +110,7 @@ + diff --git a/PCbuild/_testlimitedcapi.vcxproj.filters b/PCbuild/_testlimitedcapi.vcxproj.filters index ddef60e599d4853..92f1c5dedbd62e8 100644 --- a/PCbuild/_testlimitedcapi.vcxproj.filters +++ b/PCbuild/_testlimitedcapi.vcxproj.filters @@ -25,6 +25,7 @@ + From c1af94ae348da826ac530c8d5097fa887e19579c Mon Sep 17 00:00:00 2001 From: Christian Aurich Zanettini Martins Date: Fri, 25 Sep 2026 10:04:06 -0300 Subject: [PATCH 4/6] gh-157212: Fix _Py_ThreadId() on Windows ARM64 with MinGW (gh-157253) --- Include/cpython/object.h | 3 ++- .../Windows/2026-09-10-02-00-00.gh-issue-157212.x18TEB.rst | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Windows/2026-09-10-02-00-00.gh-issue-157212.x18TEB.rst diff --git a/Include/cpython/object.h b/Include/cpython/object.h index 0ef52d4d2bc7b4b..c0fcfc363ef90c9 100644 --- a/Include/cpython/object.h +++ b/Include/cpython/object.h @@ -523,7 +523,8 @@ _Py_ThreadId(void) #elif defined(__MINGW32__) && defined(_M_IX86) tid = __readfsdword(24); #elif defined(__MINGW32__) && defined(_M_ARM64) - tid = __getReg(18); + // x18 is the Windows ARM64 platform register and points to the TEB. + __asm__ ("mov %0, x18" : "=r" (tid)); #elif defined(__i386__) __asm__("{movl %%gs:0, %0|mov %0, dword ptr gs:[0]}" : "=r" (tid)); // 32-bit always uses GS #elif defined(__MACH__) && defined(__x86_64__) diff --git a/Misc/NEWS.d/next/Windows/2026-09-10-02-00-00.gh-issue-157212.x18TEB.rst b/Misc/NEWS.d/next/Windows/2026-09-10-02-00-00.gh-issue-157212.x18TEB.rst new file mode 100644 index 000000000000000..8d0970516242f93 --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2026-09-10-02-00-00.gh-issue-157212.x18TEB.rst @@ -0,0 +1,3 @@ +Fix ``_Py_ThreadId()`` compilation on Windows ARM64 with MinGW toolchains, +where ``__getReg()`` is unavailable. The TEB address is now read directly +from ``x18``. Patch by Christian Aurich. From 64d315ac11a341fa5622718a679d0f6ef1a8dd6b Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 25 Sep 2026 08:54:10 -0600 Subject: [PATCH 5/6] Doc: link to documented thread safety guarantees for builtins (#158173) --- Doc/howto/free-threading-python.rst | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Doc/howto/free-threading-python.rst b/Doc/howto/free-threading-python.rst index 53bea1db191d76f..676fffac9d33ee9 100644 --- a/Doc/howto/free-threading-python.rst +++ b/Doc/howto/free-threading-python.rst @@ -79,10 +79,7 @@ The free-threaded build of CPython aims to provide similar thread-safety behavior at the Python level to the default GIL-enabled build. Built-in types like :class:`dict`, :class:`list`, and :class:`set` use internal locks to protect against concurrent modifications in ways that behave similarly to -the GIL. However, Python has not historically guaranteed specific behavior for -concurrent modifications to these built-in types, so this should be treated -as a description of the current implementation, not a guarantee of current or -future behavior. +the GIL. See :ref:`threadsafety` for the guarantees provided by built-in types. .. note:: From 68d86eb18a91226975eec5523d2d55d4796b1211 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Fri, 25 Sep 2026 21:13:05 +0500 Subject: [PATCH 6/6] gh-157127: Reword the argparse mutually exclusive group error message (#157134) --- Doc/library/argparse.rst | 2 +- Lib/argparse.py | 4 ++-- Lib/test/test_argparse.py | 6 +++--- Lib/test/translationdata/argparse/msgids.txt | 2 +- .../Library/2026-09-08-00-31-56.gh-issue-157127.gduPd0.rst | 3 +++ 5 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-08-00-31-56.gh-issue-157127.gduPd0.rst diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index fc5302d875fc1f9..c88c10030fb3697 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -2099,7 +2099,7 @@ Mutual exclusion >>> group.add_argument('--bar', action='store_false') >>> parser.parse_args([]) usage: PROG [-h] (--foo | --bar) - PROG: error: one of the arguments --foo --bar is required + PROG: error: one of the following arguments is required: --foo, --bar Note that currently mutually exclusive argument groups do not support the *title* and *description* arguments of diff --git a/Lib/argparse.py b/Lib/argparse.py index 38e1c0d0ed78fd6..4cf5dca148c9695 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -2499,8 +2499,8 @@ def consume_positionals(start_index): names = [_get_action_name(action) for action in group._group_actions if action.help is not SUPPRESS] - msg = _('one of the arguments %s is required') - raise ArgumentError(None, msg % ' '.join(names)) + msg = _('one of the following arguments is required: %s') + raise ArgumentError(None, msg % ', '.join(names)) # return the updated namespace and the extra arguments return namespace, extras diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 75beb5ede13fefc..3abdb3244570a51 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -6866,7 +6866,7 @@ def test_required_exclusive(self): args = parser.parse_intermixed_args('1 --foo 2'.split()) self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args) self.assertRaisesRegex(argparse.ArgumentError, - 'one of the arguments --foo --spam is required', + 'one of the following arguments is required: --foo, --spam', parser.parse_intermixed_args, '1 2'.split()) self.assertEqual(group.required, True) @@ -6882,7 +6882,7 @@ def test_required_exclusive_with_positional(self): args = parser.parse_intermixed_args(['a', 'b']) self.assertEqual(NS(foo=False, spam=None, badger=['a', 'b']), args) self.assertRaisesRegex(argparse.ArgumentError, - 'one of the arguments --foo --spam badger is required', + 'one of the following arguments is required: --foo, --spam, badger', parser.parse_intermixed_args, []) self.assertRaisesRegex(argparse.ArgumentError, 'argument badger: not allowed with argument --foo', @@ -7258,7 +7258,7 @@ def test_required_mutually_exclusive_args(self): group.add_argument('--bar') group.add_argument('--baz') self.assertRaisesRegex(argparse.ArgumentError, - 'one of the arguments --bar --baz is required', + 'one of the following arguments is required: --bar, --baz', self.parser.parse_args, []) def test_conflicting_mutually_exclusive_args_optional_with_metavar(self): diff --git a/Lib/test/translationdata/argparse/msgids.txt b/Lib/test/translationdata/argparse/msgids.txt index ae89ac74726ecfc..8a2305b89a56672 100644 --- a/Lib/test/translationdata/argparse/msgids.txt +++ b/Lib/test/translationdata/argparse/msgids.txt @@ -18,7 +18,7 @@ invalid %(type)s value: %(value)r invalid choice: %(value)r (choose from %(choices)s) invalid choice: %(value)r, maybe you meant %(closest)r? (choose from %(choices)s) not allowed with argument %s -one of the arguments %s is required +one of the following arguments is required: %s option '%(option)s' is deprecated options positional arguments diff --git a/Misc/NEWS.d/next/Library/2026-09-08-00-31-56.gh-issue-157127.gduPd0.rst b/Misc/NEWS.d/next/Library/2026-09-08-00-31-56.gh-issue-157127.gduPd0.rst new file mode 100644 index 000000000000000..614c85fafa264a2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-08-00-31-56.gh-issue-157127.gduPd0.rst @@ -0,0 +1,3 @@ +Reword the :mod:`argparse` error message reported for a missing required +mutually exclusive group to ``one of the following arguments is required: +--foo, --bar``, so that the argument names are separated by commas.