From f33a5f5261652d29d185aff10854b72d7c90f199 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 5 Aug 2026 07:52:35 +0200 Subject: [PATCH 1/8] gh-153711: Add runtime guards for dup3 & pipe2 (#155174) Co-authored-by: Vamsi-klu Co-authored-by: Ned Deily --- Doc/library/os.rst | 2 +- Lib/test/test_os/test_posix.py | 16 +++ ...-08-04-14-14-31.gh-issue-153711.PBpc1g.rst | 4 + Modules/posixmodule.c | 120 ++++++++++++++---- Tools/c-analyzer/cpython/ignored.tsv | 6 +- 5 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-04-14-14-31.gh-issue-153711.PBpc1g.rst diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 7bdf415db655f3..0a4a02c45b533b 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -1448,7 +1448,7 @@ or `the MSDN `_ on Windo Return a pair of file descriptors ``(r, w)`` usable for reading and writing, respectively. - .. availability:: Unix, not WASI, not macOS, not iOS. + .. availability:: Unix, macOS >= 27.0, not WASI, not iOS. .. versionadded:: 3.3 diff --git a/Lib/test/test_os/test_posix.py b/Lib/test/test_os/test_posix.py index 41a730708974c2..8743b0bf0bc493 100644 --- a/Lib/test/test_os/test_posix.py +++ b/Lib/test/test_os/test_posix.py @@ -2388,6 +2388,22 @@ def test_pwritev(self): self.assertNotHasAttr(os, "pwritev") self.assertNotHasAttr(os, "preadv") + def test_pipe2(self): + self._verify_available("HAVE_PIPE2") + if self.mac_ver >= (27, 0): + self.assertHasAttr(os, "pipe2") + else: + self.assertNotHasAttr(os, "pipe2") + + def test_dup3(self): + self._verify_available("HAVE_DUP3") + r, w = os.pipe() + self.addCleanup(os.close, r) + self.addCleanup(os.close, w) + # Must not crash even when dup3 unavailable at runtime. + # os.dup2 returns fd2 (here w); do not double-close. + os.dup2(r, w, inheritable=False) + def test_stat(self): self._verify_available("HAVE_FSTATAT") if self.mac_ver >= (10, 10): diff --git a/Misc/NEWS.d/next/Library/2026-08-04-14-14-31.gh-issue-153711.PBpc1g.rst b/Misc/NEWS.d/next/Library/2026-08-04-14-14-31.gh-issue-153711.PBpc1g.rst new file mode 100644 index 00000000000000..9552dde73918c4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-04-14-14-31.gh-issue-153711.PBpc1g.rst @@ -0,0 +1,4 @@ +On macOS, add run-time checks around the syscalls :manpage:`pipe2 (2)` and +:manpage:`dup3 (2)`, in addition to the existing build-time checks. This +means that Python built on macOS 27 (where these calls are available) can +run on macOS 26 (where they aren't). diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index c34e3fc5eb600d..db65d586244065 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -504,6 +504,8 @@ static const unsigned int _Py_STATX_KNOWN = (STATX_BASIC_STATS | STATX_BTIME # define HAVE_MKFIFOAT_RUNTIME __builtin_available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) # define HAVE_MKNODAT_RUNTIME __builtin_available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) # define HAVE_PTSNAME_R_RUNTIME __builtin_available(macOS 10.13.4, iOS 11.3, tvOS 11.3, watchOS 4.3, *) +# define HAVE_DUP3_RUNTIME __builtin_available(macOS 27.0, *) +# define HAVE_PIPE2_RUNTIME __builtin_available(macOS 27.0, *) # define HAVE_POSIX_SPAWN_SETSID_RUNTIME __builtin_available(macOS 10.15, *) @@ -589,6 +591,14 @@ static const unsigned int _Py_STATX_KNOWN = (STATX_BASIC_STATS | STATX_BTIME # define HAVE_PTSNAME_R_RUNTIME (ptsname_r != NULL) # endif +# ifdef HAVE_DUP3 +# define HAVE_DUP3_RUNTIME (dup3 != NULL) +# endif + +# ifdef HAVE_PIPE2 +# define HAVE_PIPE2_RUNTIME (pipe2 != NULL) +# endif + #endif #ifdef HAVE_FUTIMESAT @@ -619,6 +629,8 @@ static const unsigned int _Py_STATX_KNOWN = (STATX_BASIC_STATS | STATX_BTIME # define HAVE_MKFIFOAT_RUNTIME 1 # define HAVE_MKNODAT_RUNTIME 1 # define HAVE_PTSNAME_R_RUNTIME 1 +# define HAVE_DUP3_RUNTIME 1 +# define HAVE_PIPE2_RUNTIME 1 #endif @@ -11866,11 +11878,16 @@ os_dup2_impl(PyObject *module, int fd, int fd2, int inheritable) /*[clinic end generated code: output=bc059d34a73404d1 input=c3cddda8922b038d]*/ { int res = 0; -#if defined(HAVE_DUP3) && \ - !(defined(HAVE_FCNTL_H) && defined(F_DUP2FD_CLOEXEC)) - /* dup3() is available on Linux 2.6.27+ and glibc 2.9 */ - static int dup3_works = -1; -#endif + + /* dup3() is available on Linux 2.6.27+ and glibc 2.9 and macOS 27.0; + * it needs runtime detection for the case of running on older kernels. + * Values: -1: unknown; 0: doesn't work; 1: works + * For thread safety, use a process-global with one read & one store, + * both relaxed. (It's fine if two threads race and do the detection + * simultaneously; they should get the same result.) + */ + static int dup3_works_atomic = -1; + (void) dup3_works_atomic; // unused on some platforms /* dup2() can fail with EINTR if the target FD is already open, because it * then has to be closed. See os_close_impl() for why we don't handle EINTR @@ -11909,18 +11926,27 @@ os_dup2_impl(PyObject *module, int fd, int fd2, int inheritable) #else #ifdef HAVE_DUP3 + int dup3_works = FT_ATOMIC_LOAD_INT_RELAXED(dup3_works_atomic); if (!inheritable && dup3_works != 0) { - Py_BEGIN_ALLOW_THREADS - res = dup3(fd, fd2, O_CLOEXEC); - Py_END_ALLOW_THREADS - if (res < 0) { - if (dup3_works == -1) - dup3_works = (errno != ENOSYS); - if (dup3_works) { - posix_error(); - return -1; + if (HAVE_DUP3_RUNTIME) { + Py_BEGIN_ALLOW_THREADS + res = dup3(fd, fd2, O_CLOEXEC); + Py_END_ALLOW_THREADS + if (res < 0) { + if (dup3_works == -1) { + dup3_works = (errno != ENOSYS); + FT_ATOMIC_STORE_INT_RELAXED(dup3_works_atomic, dup3_works); + } + if (dup3_works) { + posix_error(); + return -1; + } } } + else { + dup3_works = 0; + FT_ATOMIC_STORE_INT_RELAXED(dup3_works_atomic, dup3_works); + } } if (inheritable || dup3_works == 0) @@ -12761,7 +12787,13 @@ os_pipe_impl(PyObject *module) SECURITY_ATTRIBUTES attr; BOOL ok; #else - int res; + int res = -1; + + /* pipe2() is available on some newer linux/glibc & macOS; + * use the same runtime detection as for dup3 above. + */ + static int pipe2_works_atomic = -1; + (void) pipe2_works_atomic; // unused on some platforms #endif #ifdef MS_WINDOWS @@ -12787,11 +12819,30 @@ os_pipe_impl(PyObject *module) #else #ifdef HAVE_PIPE2 - Py_BEGIN_ALLOW_THREADS - res = pipe2(fds, O_CLOEXEC); - Py_END_ALLOW_THREADS + int pipe2_works = FT_ATOMIC_LOAD_INT_RELAXED(pipe2_works_atomic); + if (pipe2_works != 0) { + if (HAVE_PIPE2_RUNTIME) { + Py_BEGIN_ALLOW_THREADS + res = pipe2(fds, O_CLOEXEC); + Py_END_ALLOW_THREADS + if (pipe2_works == -1) { + if (res != 0 && errno == ENOSYS) { + pipe2_works = 0; + } + else { + // pipe2 is present but this call failed + pipe2_works = 1; + } + FT_ATOMIC_STORE_INT_RELAXED(pipe2_works_atomic, pipe2_works); + } + } + else { + pipe2_works = 0; + FT_ATOMIC_STORE_INT_RELAXED(pipe2_works_atomic, pipe2_works); + } + } - if (res != 0 && errno == ENOSYS) + if (pipe2_works == 0) { #endif Py_BEGIN_ALLOW_THREADS @@ -12814,8 +12865,9 @@ os_pipe_impl(PyObject *module) } #endif - if (res != 0) + if (res != 0) { return PyErr_SetFromErrno(PyExc_OSError); + } #endif /* !MS_WINDOWS */ return Py_BuildValue("(ii)", fds[0], fds[1]); } @@ -12845,9 +12897,17 @@ os_pipe2_impl(PyObject *module, int flags) int fds[2]; int res; - res = pipe2(fds, flags); - if (res != 0) + if (HAVE_PIPE2_RUNTIME) { + res = pipe2(fds, flags); + } + else { + res = -1; + errno = ENOSYS; + } + if (res != 0) { return posix_error(); + } + return Py_BuildValue("(ii)", fds[0], fds[1]); } #endif /* HAVE_PIPE2 */ @@ -18844,6 +18904,22 @@ posixmodule_exec(PyObject *m) } #endif +#if HAVE_PIPE2 + if (HAVE_PIPE2_RUNTIME) { + // Do nothing. (`__builtin_available` doesn't allow `!`; see + // "using negations" in a comment above.) + } + else { + PyObject* dct = PyModule_GetDict(m); + if (dct == NULL) { + return -1; + } + if (PyDict_PopString(dct, "pipe2", NULL) < 0) { + return -1; + } + } +#endif + /* Initialize environ dictionary */ if (PyModule_Add(m, "environ", convertenviron()) != 0) { return -1; diff --git a/Tools/c-analyzer/cpython/ignored.tsv b/Tools/c-analyzer/cpython/ignored.tsv index ef314625d507d6..4c143164650a2b 100644 --- a/Tools/c-analyzer/cpython/ignored.tsv +++ b/Tools/c-analyzer/cpython/ignored.tsv @@ -19,9 +19,9 @@ Python/bootstrap_hash.c py_getrandom getrandom_works - Python/bootstrap_hash.c py_getentropy getentropy_works - Python/fileutils.c - _Py_open_cloexec_works - Python/fileutils.c set_inheritable ioctl_works - -# (set lazily, *after* first init) -# XXX Is this thread-safe? -Modules/posixmodule.c os_dup2_impl dup3_works - +# (set lazily, atomically, *after* first init) +Modules/posixmodule.c os_dup2_impl dup3_works_atomic - +Modules/posixmodule.c os_pipe_impl pipe2_works_atomic - ## guards around resource init Python/thread_pthread.h PyThread__init_thread lib_initialized - From b7cd0abd75318e2a41009f7240749f933562a6ca Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 5 Aug 2026 01:55:52 -0400 Subject: [PATCH 2/8] gh-155063: Upgrade bundled pip to 26.2.1 (#155196) --- Lib/ensurepip/__init__.py | 2 +- ...ne-any.whl => pip-26.2.1-py3-none-any.whl} | Bin 1816475 -> 1816632 bytes ...-08-04-19-03-20.gh-issue-155063.gN_mCU.rst | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename Lib/ensurepip/_bundled/{pip-26.2-py3-none-any.whl => pip-26.2.1-py3-none-any.whl} (94%) create mode 100644 Misc/NEWS.d/next/Library/2026-08-04-19-03-20.gh-issue-155063.gN_mCU.rst diff --git a/Lib/ensurepip/__init__.py b/Lib/ensurepip/__init__.py index bd73ddb95cd07f..bc2fb742cb9cfc 100644 --- a/Lib/ensurepip/__init__.py +++ b/Lib/ensurepip/__init__.py @@ -10,7 +10,7 @@ __all__ = ["version", "bootstrap"] -_PIP_VERSION = "26.2" +_PIP_VERSION = "26.2.1" # Directory of system wheel packages. Some Linux distribution packaging # policies recommend against bundling dependencies. For example, Fedora diff --git a/Lib/ensurepip/_bundled/pip-26.2-py3-none-any.whl b/Lib/ensurepip/_bundled/pip-26.2.1-py3-none-any.whl similarity index 94% rename from Lib/ensurepip/_bundled/pip-26.2-py3-none-any.whl rename to Lib/ensurepip/_bundled/pip-26.2.1-py3-none-any.whl index e0cc1e2f8dd971cfa0d38e33eb2e06f5e94c756c..bc442f6b99306b37cbafcd1abb573c6c429e5c2a 100644 GIT binary patch delta 47885 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 Bkn#Wk delta 48097 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 Date: Wed, 5 Aug 2026 09:21:21 +0300 Subject: [PATCH 3/8] gh-64502: Fix Argument Clinic support of optional groups with defaults (GH-155191) Parameters with a default value which are not in any group were always required in the generated argument parsing code, although they were rendered as optional in the signature. They can now be omitted, and ambiguous combinations of optional groups and parameters with a default value are rejected. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/test/clinic.test.c | 61 ++++++++++++ Lib/test/test_clinic.py | 39 ++++++++ ...6-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst | 3 + Modules/_testclinic.c | 48 ++++++++++ Modules/clinic/_testclinic.c.h | 92 ++++++++++++++++++- Tools/c-analyzer/cpython/_parser.py | 1 + Tools/clinic/libclinic/clanguage.py | 45 +++++++-- 7 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst diff --git a/Lib/test/clinic.test.c b/Lib/test/clinic.test.c index 171570588e7a2b..870e4f6956ce4f 100644 --- a/Lib/test/clinic.test.c +++ b/Lib/test/clinic.test.c @@ -5769,6 +5769,67 @@ Test___init___impl(TestObj *self, PyObject *a, int group_right_1, /*[clinic end generated code: output=2bbb8ea60e8f57a6 input=10f5d0f1e8e466ef]*/ +/*[clinic input] +group_and_optional_parameter + [ + a: object + b: object + ] + c: object = None + / +The optional parameter can be omitted with or without the group. +[clinic start generated code]*/ + +PyDoc_STRVAR(group_and_optional_parameter__doc__, +"group_and_optional_parameter([a, b,] c=None)\n" +"The optional parameter can be omitted with or without the group."); + +#define GROUP_AND_OPTIONAL_PARAMETER_METHODDEF \ + {"group_and_optional_parameter", (PyCFunction)group_and_optional_parameter, METH_VARARGS, group_and_optional_parameter__doc__}, + +static PyObject * +group_and_optional_parameter_impl(PyObject *module, int group_left_1, + PyObject *a, PyObject *b, PyObject *c); + +static PyObject * +group_and_optional_parameter(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + int group_left_1 = 0; + PyObject *a = NULL; + PyObject *b = NULL; + PyObject *c = Py_None; + + switch (PyTuple_GET_SIZE(args)) { + case 0: + case 1: + if (!PyArg_ParseTuple(args, "|O:group_and_optional_parameter", &c)) { + goto exit; + } + break; + case 2: + case 3: + if (!PyArg_ParseTuple(args, "OO|O:group_and_optional_parameter", &a, &b, &c)) { + goto exit; + } + group_left_1 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "group_and_optional_parameter requires 0 to 3 arguments"); + goto exit; + } + return_value = group_and_optional_parameter_impl(module, group_left_1, a, b, c); + +exit: + return return_value; +} + +static PyObject * +group_and_optional_parameter_impl(PyObject *module, int group_left_1, + PyObject *a, PyObject *b, PyObject *c) +/*[clinic end generated code: output=3faea69eafd5bbbe input=7f0fbb6124f5a972]*/ + + /*[clinic input] Test._pyarg_parsestackandkeywords cls: defining_class diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index 93c284e58764f4..a9b3273ae2d7e1 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -330,6 +330,24 @@ def __init__(self): """ self.expect_failure(block, err, lineno=8) + def test_ambiguous_group_and_optional_parameters(self): + err = ("Function 'my_test_func' has an ambiguous group configuration: " + "a call with 2 argument(s) can be parsed in more than one way.") + block = """ + /*[clinic input] + my_test_func + + [ + a: object + b: object + ] + c: object = None + d: object = None + / + [clinic start generated code]*/ + """ + self.expect_failure(block, err) + def test_star_after_vararg(self): err = "'my_test_func' uses '*' more than once." block = """ @@ -3865,6 +3883,27 @@ def test_varpos_kwonly_req_opt(self): self.assertEqual(fn(1, a=2, b=3), ((1,), 2, 3, False)) self.assertEqual(fn(1, a=2, b=3, c=4), ((1,), 2, 3, 4)) + def test_group_and_opt(self): + # fn([a, b,] c=None) + fn = ac_tester.group_and_opt + self.assertEqual(fn(), (False, None, None, None)) + self.assertEqual(fn(1), (False, None, None, 1)) + self.assertEqual(fn(1, 2), (True, 1, 2, None)) + self.assertEqual(fn(1, 2, 3), (True, 1, 2, 3)) + self.assertRaises(TypeError, fn, 1, 2, 3, 4) + self.assertRaises(TypeError, fn, c=1) + + def test_group_and_two_opt(self): + # fn([a, b, c,] d=None, e=None) + fn = ac_tester.group_and_two_opt + self.assertEqual(fn(), (False, None, None, None, None, None)) + self.assertEqual(fn(1), (False, None, None, None, 1, None)) + self.assertEqual(fn(1, 2), (False, None, None, None, 1, 2)) + self.assertEqual(fn(1, 2, 3), (True, 1, 2, 3, None, None)) + self.assertEqual(fn(1, 2, 3, 4), (True, 1, 2, 3, 4, None)) + self.assertEqual(fn(1, 2, 3, 4, 5), (True, 1, 2, 3, 4, 5)) + self.assertRaises(TypeError, fn, 1, 2, 3, 4, 5, 6) + def test_gh_32092_oob(self): ac_tester.gh_32092_oob(1, 2, 3, 4, kw1=5, kw2=6) diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst new file mode 100644 index 00000000000000..da9647d1fdd369 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst @@ -0,0 +1,3 @@ +Fix Argument Clinic support of parameters with a default value used together +with optional groups. +Such parameters were always required in the generated parsing code. diff --git a/Modules/_testclinic.c b/Modules/_testclinic.c index 66a375589ba38e..5742dd65f6742a 100644 --- a/Modules/_testclinic.c +++ b/Modules/_testclinic.c @@ -1237,6 +1237,52 @@ posonly_poskw_varpos_array_impl(PyObject *module, PyObject *a, PyObject *b, } +/*[clinic input] +group_and_opt + + [ + a: object + b: object + ] + c: object = None + / + +[clinic start generated code]*/ + +static PyObject * +group_and_opt_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, PyObject *c) +/*[clinic end generated code: output=23413ec545526111 input=8a84d8f44bc8bd0b]*/ +{ + return pack_arguments_newref(4, group_left_1 ? Py_True : Py_False, + a, b, c); +} + + +/*[clinic input] +group_and_two_opt + + [ + a: object + b: object + c: object + ] + d: object = None + e: object = None + / + +[clinic start generated code]*/ + +static PyObject * +group_and_two_opt_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, PyObject *c, PyObject *d, PyObject *e) +/*[clinic end generated code: output=1427c4b3c35f24ff input=cdda98eec1e365ea]*/ +{ + return pack_arguments_newref(6, group_left_1 ? Py_True : Py_False, + a, b, c, d, e); +} + + /*[clinic input] gh_32092_oob @@ -2455,6 +2501,8 @@ static PyMethodDef tester_methods[] = { POSONLY_VARPOS_ARRAY_METHODDEF POSONLY_REQ_OPT_VARPOS_ARRAY_METHODDEF POSONLY_POSKW_VARPOS_ARRAY_METHODDEF + GROUP_AND_OPT_METHODDEF + GROUP_AND_TWO_OPT_METHODDEF GH_32092_OOB_METHODDEF GH_32092_KW_PASS_METHODDEF diff --git a/Modules/clinic/_testclinic.c.h b/Modules/clinic/_testclinic.c.h index 05615c1fdd81b9..19b215b0cca889 100644 --- a/Modules/clinic/_testclinic.c.h +++ b/Modules/clinic/_testclinic.c.h @@ -3477,6 +3477,96 @@ posonly_poskw_varpos_array(PyObject *module, PyObject *const *args, Py_ssize_t n return return_value; } +PyDoc_STRVAR(group_and_opt__doc__, +"group_and_opt([a, b,] c=None)"); + +#define GROUP_AND_OPT_METHODDEF \ + {"group_and_opt", (PyCFunction)group_and_opt, METH_VARARGS, group_and_opt__doc__}, + +static PyObject * +group_and_opt_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, PyObject *c); + +static PyObject * +group_and_opt(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + int group_left_1 = 0; + PyObject *a = NULL; + PyObject *b = NULL; + PyObject *c = Py_None; + + switch (PyTuple_GET_SIZE(args)) { + case 0: + case 1: + if (!PyArg_ParseTuple(args, "|O:group_and_opt", &c)) { + goto exit; + } + break; + case 2: + case 3: + if (!PyArg_ParseTuple(args, "OO|O:group_and_opt", &a, &b, &c)) { + goto exit; + } + group_left_1 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "group_and_opt requires 0 to 3 arguments"); + goto exit; + } + return_value = group_and_opt_impl(module, group_left_1, a, b, c); + +exit: + return return_value; +} + +PyDoc_STRVAR(group_and_two_opt__doc__, +"group_and_two_opt([a, b, c,] d=None, e=None)"); + +#define GROUP_AND_TWO_OPT_METHODDEF \ + {"group_and_two_opt", (PyCFunction)group_and_two_opt, METH_VARARGS, group_and_two_opt__doc__}, + +static PyObject * +group_and_two_opt_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, PyObject *c, PyObject *d, PyObject *e); + +static PyObject * +group_and_two_opt(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + int group_left_1 = 0; + PyObject *a = NULL; + PyObject *b = NULL; + PyObject *c = NULL; + PyObject *d = Py_None; + PyObject *e = Py_None; + + switch (PyTuple_GET_SIZE(args)) { + case 0: + case 1: + case 2: + if (!PyArg_ParseTuple(args, "|OO:group_and_two_opt", &d, &e)) { + goto exit; + } + break; + case 3: + case 4: + case 5: + if (!PyArg_ParseTuple(args, "OOO|OO:group_and_two_opt", &a, &b, &c, &d, &e)) { + goto exit; + } + group_left_1 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "group_and_two_opt requires 0 to 5 arguments"); + goto exit; + } + return_value = group_and_two_opt_impl(module, group_left_1, a, b, c, d, e); + +exit: + return return_value; +} + PyDoc_STRVAR(gh_32092_oob__doc__, "gh_32092_oob($module, /, pos1, pos2, *varargs, kw1=None, kw2=None)\n" "--\n" @@ -4600,4 +4690,4 @@ _testclinic_TestClass_posonly_poskw_varpos_array_no_fastcall(PyObject *type, PyO exit: return return_value; } -/*[clinic end generated code: output=9971dbbc5f62b8d2 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=f6a3b617130c4e3a input=a9049054013a1b77]*/ diff --git a/Tools/c-analyzer/cpython/_parser.py b/Tools/c-analyzer/cpython/_parser.py index 2875f45cb8d375..3d755765b96709 100644 --- a/Tools/c-analyzer/cpython/_parser.py +++ b/Tools/c-analyzer/cpython/_parser.py @@ -318,6 +318,7 @@ def format_tsv_lines(lines): _abs('Modules/_remote_debugging/debug_offsets_validation.h'): (25_000, 1000), _abs('Modules/_remote_debugging/*.h'): (20_000, 1000), _abs('Modules/_testcapimodule.c'): (20_000, 400), + _abs('Modules/_testclinic.c'): (20_000, 400), _abs('Modules/expat/expat.h'): (10_000, 400), _abs('Objects/stringlib/unicode_format.h'): (10_000, 400), _abs('Objects/typeobject.c'): (380_000, 13_000), diff --git a/Tools/clinic/libclinic/clanguage.py b/Tools/clinic/libclinic/clanguage.py index 7f02c7790f015a..a76fddb7602001 100644 --- a/Tools/clinic/libclinic/clanguage.py +++ b/Tools/clinic/libclinic/clanguage.py @@ -1,6 +1,5 @@ from __future__ import annotations import itertools -import sys import textwrap from typing import TYPE_CHECKING, Literal, Final from operator import attrgetter @@ -12,7 +11,7 @@ from libclinic.codegen import CRenderData, TemplateDict, CodeGen from libclinic.language import Language from libclinic.function import ( - Module, Class, Function, Parameter, + Module, Class, Function, Parameter, ParamTuple, permute_optional_groups, GETTER, SETTER, METHOD_INIT) from libclinic.converters import self_converter @@ -21,6 +20,20 @@ from libclinic.app import Clinic +def count_required(subset: ParamTuple) -> int: + """Return the number of arguments which cannot be omitted. + + A parameter in an optional group is passed together with its group, + so only trailing parameters with a default value can be omitted. + """ + count = len(subset) + for p in reversed(subset): + if p.group or not p.is_optional(): + break + count -= 1 + return count + + def c_id(name: str) -> str: if len(name) == 1 and ord(name) < 256: if name.isalnum(): @@ -301,18 +314,26 @@ def render_option_group_parsing( assert group is not None group.append(p) - count_min = sys.maxsize - count_max = -1 + # Map the number of arguments to the subset which accepts it. + subsets: dict[int, ParamTuple] = {} + for subset in permute_optional_groups(left, required, right): + for count in range(count_required(subset), len(subset) + 1): + if count in subsets: + fail(f"Function {f.full_name!r} has an ambiguous group " + f"configuration: a call with {count} argument(s) " + f"can be parsed in more than one way.") + subsets[count] = subset if limited_capi: nargs = 'PyTuple_Size(args)' else: nargs = 'PyTuple_GET_SIZE(args)' out.append(f"switch ({nargs}) {{\n") - for subset in permute_optional_groups(left, required, right): - count = len(subset) - count_min = min(count_min, count) - count_max = max(count_max, count) + for count, subset in sorted(subsets.items()): + if count < len(subset): + # The omitted parameters are parsed by the following case. + out.append(f" case {count}:\n") + continue if count == 0: out.append(""" case 0: @@ -324,7 +345,11 @@ def render_option_group_parsing( d: dict[str, str | int] = {} d['count'] = count d['name'] = f.name - d['format_units'] = "".join(p.converter.format_unit for p in subset) + format_units = [p.converter.format_unit for p in subset] + n_required = count_required(subset) + if n_required < count: + format_units.insert(n_required, '|') + d['format_units'] = "".join(format_units) parse_arguments: list[str] = [] for p in subset: @@ -351,7 +376,7 @@ def render_option_group_parsing( out.append(" default:\n") s = ' PyErr_SetString(PyExc_TypeError, "{} requires {} to {} arguments");\n' - out.append(s.format(f.full_name, count_min, count_max)) + out.append(s.format(f.full_name, min(subsets), max(subsets))) out.append(' goto exit;\n') out.append("}") From 3874ad1a527745fe17f074b2b090faafb50d6556 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 10:28:12 +0300 Subject: [PATCH 4/8] gh-155207: Add --dry-run and --diff options to Argument Clinic (GH-155208) --dry-run lists the files which would be changed, and --diff writes a unified diff of the changes to the standard output. No file and no directory is created or modified in these modes. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/test/test_clinic.py | 143 ++++++++++++++++++ ...-08-05-10-24-17.gh-issue-155207.Wv3nKq.rst | 3 + Tools/clinic/libclinic/__init__.py | 6 + Tools/clinic/libclinic/app.py | 8 +- Tools/clinic/libclinic/cli.py | 60 +++++++- Tools/clinic/libclinic/utils.py | 56 ++++++- 6 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-05-10-24-17.gh-issue-155207.Wv3nKq.rst diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index a9b3273ae2d7e1..9a8faf777869d4 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -9,6 +9,7 @@ from test.support.os_helper import TESTFN, unlink, rmtree from textwrap import dedent from unittest import TestCase +import difflib import inspect import os.path import re @@ -3060,6 +3061,148 @@ def test_cli_force(self): generated = f.read() self.assertEndsWith(generated, checksum) + DRY_RUN_CODE = dedent(""" + /*[clinic input] + func + a: int + / + + Docstring. + [clinic start generated code]*/ + """) + + def make_dry_run_file(self, tmp_dir): + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write(self.DRY_RUN_CODE) + return fn + + @staticmethod + def dest_file(fn): + # The default destination for the generated code. Its path is + # built from the "{dirname}/clinic/{basename}.h" template, so it + # always uses forward slashes, even on Windows. + dirname, basename = os.path.split(fn) + return f"{dirname}/clinic/{basename}.h" + + def check_unchanged(self, tmp_dir, fn, pre_mtime): + # Neither the source file nor the destination file + # nor its directory is created or modified. + with open(fn, encoding="utf-8") as f: + self.assertEqual(f.read(), self.DRY_RUN_CODE) + self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime) + self.assertEqual(os.listdir(tmp_dir), ["test.c"]) + + def test_cli_dry_run(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + pre_mtime = os.stat(fn).st_mtime_ns + out = self.expect_success("--dry-run", fn) + self.assertEqual(out.splitlines(), [ + f"would create {self.dest_file(fn)}", + f"would update {fn}", + ]) + self.check_unchanged(tmp_dir, fn, pre_mtime) + + def test_cli_dry_run_no_change(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + self.expect_success(fn) + self.assertEqual(self.expect_success("--dry-run", fn), "") + self.assertEqual(self.expect_success("--diff", fn), "") + + def test_cli_dry_run_no_clinic_block(self): + with os_helper.temp_dir() as tmp_dir: + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write("int x;\n") + self.assertEqual(self.expect_success("--dry-run", fn), "") + + def test_cli_dry_run_output(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + out_fn = os.path.join(tmp_dir, "output.c") + out = self.expect_success("--dry-run", "-o", out_fn, fn) + self.assertIn(f"would create {out_fn}", out) + self.assertNotIn(f"would update {fn}", out) + self.assertFalse(os.path.exists(out_fn)) + + def test_cli_dry_run_make(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + pre_mtime = os.stat(fn).st_mtime_ns + out = self.expect_success("--dry-run", "--make", "--srcdir", tmp_dir) + self.assertIn(f"would update {fn}", out) + self.check_unchanged(tmp_dir, fn, pre_mtime) + + def test_cli_dry_run_verbose(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + out, err, code = self.run_clinic("-v", "--dry-run", fn) + self.assertEqual(code, 0) + # The progress goes to stderr, so that the standard output + # contains only the report. + self.assertEqual(err.splitlines(), [fn]) + self.assertEqual(out.splitlines(), [ + f"would create {self.dest_file(fn)}", + f"would update {fn}", + ]) + + def test_cli_dry_run_checksum_mismatch(self): + invalid_input = dedent(""" + /*[clinic input] + output preset block + module test + test.fn + a: int + [clinic start generated code]*/ + /*[clinic end generated code: output=bogus input=bogus]*/ + """) + with os_helper.temp_dir() as tmp_dir: + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write(invalid_input) + pre_mtime = os.stat(fn).st_mtime_ns + # The dry run does not disable the checksum verification. + _, err = self.expect_failure("--dry-run", fn) + self.assertIn("Checksum mismatch!", err) + # With -f the change is reported, but still not written. + out = self.expect_success("--dry-run", "-f", fn) + self.assertIn(f"would update {fn}", out) + with open(fn, encoding="utf-8") as f: + self.assertEqual(f.read(), invalid_input) + self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime) + + def test_cli_diff(self): + with os_helper.temp_dir() as tmp_dir: + fn = self.make_dry_run_file(tmp_dir) + pre_mtime = os.stat(fn).st_mtime_ns + out = self.expect_success("--diff", fn) + self.check_unchanged(tmp_dir, fn, pre_mtime) + + # A new file is created by the patch. + dest_fn = self.dest_file(fn) + self.assertStartsWith(out, f"--- /dev/null\n+++ {dest_fn}\n@@ -0,0 +1,") + self.assertIn(f"--- {fn}\n+++ {fn}\n", out) + self.assertIn("+/*[clinic end generated code:", out) + + # The patch is what clinic would have written. + self.expect_success(fn) + with open(fn, encoding="utf-8") as f: + new_contents = f.read() + expected = "".join(difflib.unified_diff( + self.DRY_RUN_CODE.splitlines(keepends=True), + new_contents.splitlines(keepends=True), + fromfile=fn, tofile=fn)) + self.assertEndsWith(out, expected) + + def test_cli_fail_converters_and_dry_run(self): + for opt in "--dry-run", "--diff": + with self.subTest(opt=opt): + _, err = self.expect_failure("--converters", opt) + msg = "can't use --dry-run or --diff with --converters" + self.assertIn(msg, err) + def test_cli_make(self): c_code = dedent(""" /*[clinic input] diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-05-10-24-17.gh-issue-155207.Wv3nKq.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-10-24-17.gh-issue-155207.Wv3nKq.rst new file mode 100644 index 00000000000000..392ab42bffa726 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-10-24-17.gh-issue-155207.Wv3nKq.rst @@ -0,0 +1,3 @@ +Argument Clinic now supports the ``--dry-run`` and ``--diff`` options. +They list the files which would be changed, or write a unified diff of the +changes to the standard output, without modifying any file. diff --git a/Tools/clinic/libclinic/__init__.py b/Tools/clinic/libclinic/__init__.py index 5ee165d0c138a8..ff8f44be0b774f 100644 --- a/Tools/clinic/libclinic/__init__.py +++ b/Tools/clinic/libclinic/__init__.py @@ -26,6 +26,8 @@ is_legal_py_identifier, ) from .utils import ( + FileChange, + FileWriter, FormatCounterFormatter, NULL, NullType, @@ -33,6 +35,7 @@ VersionTuple, compute_checksum, create_regex, + read_file, unknown, unspecified, write_file, @@ -66,6 +69,8 @@ "is_legal_py_identifier", # Utility functions + "FileChange", + "FileWriter", "FormatCounterFormatter", "NULL", "NullType", @@ -73,6 +78,7 @@ "VersionTuple", "compute_checksum", "create_regex", + "read_file", "unknown", "unspecified", "write_file", diff --git a/Tools/clinic/libclinic/app.py b/Tools/clinic/libclinic/app.py index 9e8cec5320f877..d8de3687a35ce6 100644 --- a/Tools/clinic/libclinic/app.py +++ b/Tools/clinic/libclinic/app.py @@ -87,6 +87,7 @@ def __init__( filename: str, limited_capi: bool, verify: bool = True, + writer: libclinic.FileWriter | None = None, ) -> None: # maps strings to Parser objects. # (instantiated from the "parsers" global.) @@ -95,6 +96,7 @@ def __init__( if printer: fail("Custom printers are broken right now") self.printer = printer or BlockPrinter(language) + self.writer = writer or libclinic.FileWriter() self.verify = verify self.limited_capi = limited_capi self.filename = filename @@ -213,7 +215,7 @@ def parse(self, input: str) -> str: try: dirname = os.path.dirname(destination.filename) try: - os.makedirs(dirname) + self.writer.makedirs(dirname) except FileExistsError: if not os.path.isdir(dirname): fail(f"Can't write to destination " @@ -234,8 +236,8 @@ def parse(self, input: str) -> str: printer_2 = BlockPrinter(self.language) printer_2.print_block(block, header_includes=includes) - libclinic.write_file(destination.filename, - printer_2.f.getvalue()) + self.writer.write(destination.filename, + printer_2.f.getvalue()) continue return printer.f.getvalue() diff --git a/Tools/clinic/libclinic/cli.py b/Tools/clinic/libclinic/cli.py index f36c6d04efd383..678edc8ca28944 100644 --- a/Tools/clinic/libclinic/cli.py +++ b/Tools/clinic/libclinic/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import difflib import inspect import os import re @@ -52,9 +53,12 @@ def parse_file( limited_capi: bool, output: str | None = None, verify: bool = True, + writer: libclinic.FileWriter | None = None, ) -> None: if not output: output = filename + if writer is None: + writer = libclinic.FileWriter() extension = os.path.splitext(filename)[1][1:] if not extension: @@ -80,10 +84,11 @@ def parse_file( clinic = Clinic(language, verify=verify, filename=filename, - limited_capi=limited_capi) + limited_capi=limited_capi, + writer=writer) cooked = clinic.parse(raw) - libclinic.write_file(output, cooked) + writer.write(output, cooked) def create_cli() -> argparse.ArgumentParser: @@ -102,6 +107,12 @@ def create_cli() -> argparse.ArgumentParser: help="redirect file output to OUTPUT") cmdline.add_argument("-v", "--verbose", action='store_true', help="enable verbose mode") + cmdline.add_argument("--dry-run", action='store_true', + help=("don't write any file, only list the files " + "which would be changed")) + cmdline.add_argument("--diff", action='store_true', + help=("don't write any file, write a unified diff " + "of the changes to the standard output")) cmdline.add_argument("--converters", action='store_true', help=("print a list of all supported converters " "and return converters")) @@ -119,12 +130,43 @@ def create_cli() -> argparse.ArgumentParser: return cmdline +def print_diff(change: libclinic.FileChange) -> None: + if change.old_contents is None: + fromfile = "/dev/null" + old_lines: list[str] = [] + else: + fromfile = change.filename + old_lines = change.old_contents.splitlines(keepends=True) + sys.stdout.writelines(difflib.unified_diff( + old_lines, + change.new_contents.splitlines(keepends=True), + fromfile=fromfile, + tofile=change.filename, + )) + + +def report_changes(writer: libclinic.FileWriter, *, diff: bool) -> None: + for change in sorted(writer.changes, key=lambda change: change.filename): + if diff: + print_diff(change) + else: + action = "create" if change.old_contents is None else "update" + print(f"would {action} {change.filename}") + + def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: + dry_run = ns.dry_run or ns.diff + # The report is written to the standard output, so the progress + # is written to the standard error stream to not mix them. + verbose_file = sys.stderr if dry_run else sys.stdout + if ns.converters: if ns.filename: parser.error( "can't specify --converters and a filename at the same time" ) + if dry_run: + parser.error("can't use --dry-run or --diff with --converters") AnyConverterType = ConverterType | ReturnConverterType converter_list: list[tuple[str, AnyConverterType]] = [] return_converter_list: list[tuple[str, AnyConverterType]] = [] @@ -188,6 +230,7 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: excludes = [os.path.normpath(f) for f in excludes] else: excludes = [] + writer = libclinic.FileWriter(dry_run=dry_run) for root, dirs, files in os.walk(ns.srcdir): for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'): if rcs_dir in dirs: @@ -201,9 +244,11 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: if path in excludes: continue if ns.verbose: - print(path) + print(path, file=verbose_file) parse_file(path, - verify=not ns.force, limited_capi=ns.limited_capi) + verify=not ns.force, limited_capi=ns.limited_capi, + writer=writer) + report_changes(writer, diff=ns.diff) return if not ns.filename: @@ -212,11 +257,14 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: if ns.output and len(ns.filename) > 1: parser.error("can't use -o with multiple filenames") + writer = libclinic.FileWriter(dry_run=dry_run) for filename in ns.filename: if ns.verbose: - print(filename) + print(filename, file=verbose_file) parse_file(filename, output=ns.output, - verify=not ns.force, limited_capi=ns.limited_capi) + verify=not ns.force, limited_capi=ns.limited_capi, + writer=writer) + report_changes(writer, diff=ns.diff) def main(argv: list[str] | None = None) -> NoReturn: diff --git a/Tools/clinic/libclinic/utils.py b/Tools/clinic/libclinic/utils.py index 3df64f270dd074..8fc8748f0f9ae1 100644 --- a/Tools/clinic/libclinic/utils.py +++ b/Tools/clinic/libclinic/utils.py @@ -1,4 +1,5 @@ import collections +import dataclasses as dc import enum import hashlib import os @@ -7,17 +8,20 @@ from typing import Literal, Final -def write_file(filename: str, new_contents: str) -> None: - """Write new content to file, iff the content changed.""" +def read_file(filename: str) -> str | None: + """Return the content of the file, or None if it does not exist.""" try: with open(filename, encoding="utf-8") as fp: - old_contents = fp.read() - - if old_contents == new_contents: - # no change: avoid modifying the file modification time - return + return fp.read() except FileNotFoundError: - pass + return None + + +def write_file(filename: str, new_contents: str) -> None: + """Write new content to file, iff the content changed.""" + if read_file(filename) == new_contents: + # no change: avoid modifying the file modification time + return # Atomic write using a temporary file and os.replace() filename_new = f"{filename}.new" with open(filename_new, "w", encoding="utf-8") as fp: @@ -29,6 +33,42 @@ def write_file(filename: str, new_contents: str) -> None: raise +@dc.dataclass(slots=True, frozen=True) +class FileChange: + filename: str + # None if the file does not exist yet. + old_contents: str | None + new_contents: str + + +@dc.dataclass(slots=True) +class FileWriter: + """Write the generated files. + + In the dry run mode no file is written, the changes are only recorded. + """ + + dry_run: bool = False + changes: list[FileChange] = dc.field(default_factory=list) + + def makedirs(self, dirname: str) -> None: + if not self.dry_run: + os.makedirs(dirname) + elif os.path.exists(dirname): + # Create nothing, but fail as os.makedirs() does, so that + # the caller can report an existing non-directory. + raise FileExistsError(dirname) + + def write(self, filename: str, new_contents: str) -> None: + if not self.dry_run: + write_file(filename, new_contents) + return + old_contents = read_file(filename) + if old_contents != new_contents: + self.changes.append( + FileChange(filename, old_contents, new_contents)) + + def compute_checksum(input_: str, length: int | None = None) -> str: checksum = hashlib.sha1(input_.encode("utf-8")).hexdigest() if length: From 7aec160315a6316f7391abda10d4b6c57032d105 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 11:31:11 +0300 Subject: [PATCH 5/8] gh-155218: Generate the option group flags in a stable order (GH-155219) They were emitted in the iteration order of a set of group identifiers. The identifiers of the groups before the required parameters are small negative integers, -1 and -2 have the same hash, so their order depended on the size of Py_hash_t, and the code generated on a 32-bit platform differed from the checked in one. They are now emitted in the order of the parameters. Co-authored-by: Claude Opus 5 (1M context) --- ...6-08-05-14-02-55.gh-issue-155218.Nq4xZv.rst | 2 ++ Modules/clinic/_cursesmodule.c.h | 18 +++++++++--------- Tools/clinic/libclinic/clanguage.py | 6 ++++-- 3 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-05-14-02-55.gh-issue-155218.Nq4xZv.rst diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-05-14-02-55.gh-issue-155218.Nq4xZv.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-14-02-55.gh-issue-155218.Nq4xZv.rst new file mode 100644 index 00000000000000..909efc558693c0 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-14-02-55.gh-issue-155218.Nq4xZv.rst @@ -0,0 +1,2 @@ +Fix Argument Clinic generating the flags of the optional groups in +different order on 32-bit and 64-bit platforms. diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index dfd589ba45089e..58087416796f8f 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -247,8 +247,8 @@ _curses_window_addch(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOO&:addch", &y, &x, &ch, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.addch requires 1 to 4 arguments"); @@ -319,8 +319,8 @@ _curses_window_addstr(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOO&:addstr", &y, &x, &str, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.addstr requires 1 to 4 arguments"); @@ -394,8 +394,8 @@ _curses_window_addnstr(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOiO&:addnstr", &y, &x, &str, &n, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.addnstr requires 2 to 5 arguments"); @@ -1451,8 +1451,8 @@ _curses_window_hline(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOiO&:hline", &y, &x, &ch, &n, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.hline requires 2 to 5 arguments"); @@ -1521,8 +1521,8 @@ _curses_window_insch(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOO&:insch", &y, &x, &ch, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.insch requires 1 to 4 arguments"); @@ -1640,8 +1640,8 @@ _curses_window_insstr(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOO&:insstr", &y, &x, &str, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.insstr requires 1 to 4 arguments"); @@ -1717,8 +1717,8 @@ _curses_window_insnstr(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOiO&:insnstr", &y, &x, &str, &n, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.insnstr requires 2 to 5 arguments"); @@ -2328,8 +2328,8 @@ _curses_window_vline(PyObject *self, PyObject *args) if (!PyArg_ParseTuple(args, "iiOiO&:vline", &y, &x, &ch, &n, attr_converter, &attr)) { goto exit; } - group_right_1 = 1; group_left_1 = 1; + group_right_1 = 1; break; default: PyErr_SetString(PyExc_TypeError, "_curses.window.vline requires 2 to 5 arguments"); @@ -6234,4 +6234,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=cb5525c88ae5c440 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=c1f85ec415c303bf input=a9049054013a1b77]*/ diff --git a/Tools/clinic/libclinic/clanguage.py b/Tools/clinic/libclinic/clanguage.py index a76fddb7602001..3ee06307441fac 100644 --- a/Tools/clinic/libclinic/clanguage.py +++ b/Tools/clinic/libclinic/clanguage.py @@ -341,7 +341,9 @@ def render_option_group_parsing( """) continue - group_ids = {p.group for p in subset} # eliminate duplicates + # A set would eliminate duplicates too, but the iteration + # order of small negative integers depends on the platform. + group_ids = dict.fromkeys(p.group for p in subset) d: dict[str, str | int] = {} d['count'] = count d['name'] = f.name @@ -356,7 +358,7 @@ def render_option_group_parsing( p.converter.parse_argument(parse_arguments) d['parse_arguments'] = ", ".join(parse_arguments) - group_ids.discard(0) + group_ids.pop(0, None) lines = "\n".join([ self.group_to_variable_name(g) + " = 1;" for g in group_ids From 2d9a69319274480c23d6acd2b42250857527a84b Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 12:18:11 +0300 Subject: [PATCH 6/8] gh-155212: List the converters defined in a C file (GH-155221) The --converters option now accepts file names and can be used with --make. It parses the specified files, without writing anything, and prints the converters and return converters which they define, instead of the built-in ones. Legacy converters whose format unit does not start with a letter, like those defined in Modules/posixmodule.c, were silently omitted from the list; they are now printed on a separate line. A directory passed as a file name raised IsADirectoryError instead of reporting an error, in every mode. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/test/test_clinic.py | 64 +++++- ...-08-05-12-40-08.gh-issue-155212.Lx8vTm.rst | 4 + Tools/clinic/libclinic/cli.py | 213 ++++++++++-------- 3 files changed, 183 insertions(+), 98 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-05-12-40-08.gh-issue-155212.Lx8vTm.rst diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index 9a8faf777869d4..f35b11cff551a8 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -3364,10 +3364,66 @@ def test_cli_converters(self): with self.subTest(converter=converter): self.assertStartsWith(line, converter) - def test_cli_fail_converters_and_filename(self): - _, err = self.expect_failure("--converters", "test.c") - msg = "can't specify --converters and a filename at the same time" - self.assertIn(msg, err) + def test_cli_converters_file(self): + code = dedent(""" + /*[python input] + class my_type_converter(CConverter): + type = 'my_type' + converter = 'my_type_converter' + + def converter_init(self, *, strict=False): + pass + + class my_result_return_converter(CReturnConverter): + type = 'my_result' + [python start generated code]*/ + """) + with os_helper.temp_dir() as tmp_dir: + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write(code) + out = self.expect_success("--converters", fn) + self.assertIn("Converters:\n my_type(strict=False)\n", out) + self.assertIn("Return converters:\n my_result()\n", out) + # Only the converters defined in the file are listed. + self.assertNotIn("Legacy converters:", out) + self.assertNotIn("bool(", out) + # Listing the converters does not write anything. + with open(fn, encoding="utf-8") as f: + self.assertEqual(f.read(), code) + self.assertEqual(os.listdir(tmp_dir), ["test.c"]) + + def test_cli_converters_make(self): + code = dedent(""" + /*[python input] + class my_type_converter(CConverter): + type = 'my_type' + converter = 'my_type_converter' + [python start generated code]*/ + """) + with os_helper.temp_dir() as tmp_dir: + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write(code) + out = self.expect_success("--converters", "--make", + "--srcdir", tmp_dir) + self.assertIn("Converters:\n my_type()\n", out) + with open(fn, encoding="utf-8") as f: + self.assertEqual(f.read(), code) + + def test_cli_converters_no_converters(self): + with os_helper.temp_dir() as tmp_dir: + fn = os.path.join(tmp_dir, "test.c") + with open(fn, "w", encoding="utf-8") as f: + f.write("/*[clinic input]\n[clinic start generated code]*/\n") + self.assertEqual(self.expect_success("--converters", fn), "") + + def test_cli_fail_directory(self): + with os_helper.temp_dir() as tmp_dir: + subdir = os.path.join(tmp_dir, "test.c") + os.mkdir(subdir) + _, err = self.expect_failure(subdir) + self.assertIn(f"Can't read file {subdir!r}: it is a directory", err) def test_cli_fail_no_filename(self): _, err = self.expect_failure() diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-05-12-40-08.gh-issue-155212.Lx8vTm.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-12-40-08.gh-issue-155212.Lx8vTm.rst new file mode 100644 index 00000000000000..a4b52430436f3d --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-12-40-08.gh-issue-155212.Lx8vTm.rst @@ -0,0 +1,4 @@ +The ``--converters`` option of Argument Clinic now accepts file names and +can be used with ``--make``. +It prints the converters and return converters which the specified files +define, instead of the built-in ones. diff --git a/Tools/clinic/libclinic/cli.py b/Tools/clinic/libclinic/cli.py index 678edc8ca28944..c66084cf314482 100644 --- a/Tools/clinic/libclinic/cli.py +++ b/Tools/clinic/libclinic/cli.py @@ -6,7 +6,7 @@ import os import re import sys -from collections.abc import Callable +from collections.abc import Callable, Iterable, Iterator, Mapping from typing import NoReturn @@ -69,6 +69,9 @@ def parse_file( except KeyError: raise ClinicError(f"Can't identify file type for file {filename!r}") + if os.path.isdir(filename): + raise ClinicError(f"Can't read file {filename!r}: it is a directory") + with open(filename, encoding="utf-8") as f: raw = f.read() @@ -115,7 +118,9 @@ def create_cli() -> argparse.ArgumentParser: "of the changes to the standard output")) cmdline.add_argument("--converters", action='store_true', help=("print a list of all supported converters " - "and return converters")) + "and return converters; if files are " + "specified, print only the converters " + "which they define")) cmdline.add_argument("--make", action='store_true', help="walk --srcdir to run over all relevant files") cmdline.add_argument("--srcdir", type=str, default=os.curdir, @@ -154,117 +159,137 @@ def report_changes(writer: libclinic.FileWriter, *, diff: bool) -> None: print(f"would {action} {change.filename}") -def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: - dry_run = ns.dry_run or ns.diff - # The report is written to the standard output, so the progress - # is written to the standard error stream to not mix them. - verbose_file = sys.stderr if dry_run else sys.stdout +AnyConverterType = ConverterType | ReturnConverterType - if ns.converters: - if ns.filename: - parser.error( - "can't specify --converters and a filename at the same time" - ) - if dry_run: - parser.error("can't use --dry-run or --diff with --converters") - AnyConverterType = ConverterType | ReturnConverterType - converter_list: list[tuple[str, AnyConverterType]] = [] - return_converter_list: list[tuple[str, AnyConverterType]] = [] - - for name, converter in converters.items(): - converter_list.append(( - name, - converter, - )) - for name, return_converter in return_converters.items(): - return_converter_list.append(( - name, - return_converter - )) - print() +def defined_in_files( + registry: Mapping[str, AnyConverterType], + builtin: Mapping[str, AnyConverterType], +) -> dict[str, AnyConverterType]: + """Return the converters which the parsed files define or redefine.""" + return {name: cls for name, cls in registry.items() + if builtin.get(name) is not cls} + +def print_converter_list( + title: str, + attribute: str, + registry: Mapping[str, AnyConverterType], +) -> None: + print(title + ":") + for name, cls in sorted(registry.items(), key=lambda item: item[0].lower()): + callable = getattr(cls, attribute, None) + if not callable: + continue + signature = inspect.signature(callable) + parameters = [] + for parameter_name, parameter in signature.parameters.items(): + if parameter.kind == inspect.Parameter.KEYWORD_ONLY: + if parameter.default != inspect.Parameter.empty: + s = f'{parameter_name}={parameter.default!r}' + else: + s = parameter_name + parameters.append(s) + print(' {}({})'.format(name, ', '.join(parameters))) + print() + + +def print_converters( + converters: Mapping[str, AnyConverterType], + legacy_converters: Mapping[str, AnyConverterType], + return_converters: Mapping[str, AnyConverterType], +) -> None: + if not (converters or legacy_converters or return_converters): + return + print() + if legacy_converters: print("Legacy converters:") legacy = sorted(legacy_converters) - print(' ' + ' '.join(c for c in legacy if c[0].isupper())) - print(' ' + ' '.join(c for c in legacy if c[0].islower())) + # A converter defined in a file can use any string, even a C + # expression, as its format unit, not only a letter. + groups = ([c for c in legacy if c[0].isupper()], + [c for c in legacy if c[0].islower()], + [c for c in legacy if not c[0].isalpha()]) + for group in groups: + if group: + print(' ' + ' '.join(group)) print() + if converters: + print_converter_list("Converters", 'converter_init', converters) + if return_converters: + print_converter_list("Return converters", 'return_converter_init', + return_converters) + print("All converters also accept (c_default=None, py_default=None, annotation=None).") + print("All return converters also accept (py_default=None).") + + +def walk_srcdir(srcdir: str, exclude: list[str] | None) -> Iterator[str]: + """Yield the C files in the source directory tree.""" + if exclude: + excludes = [os.path.normpath(os.path.join(srcdir, f)) for f in exclude] + else: + excludes = [] + for root, dirs, files in os.walk(srcdir): + for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'): + if rcs_dir in dirs: + dirs.remove(rcs_dir) + for filename in files: + # handle .c, .cpp and .h files + if not filename.endswith(('.c', '.cpp', '.h')): + continue + path = os.path.normpath(os.path.join(root, filename)) + if path in excludes: + continue + yield path - for title, attribute, ids in ( - ("Converters", 'converter_init', converter_list), - ("Return converters", 'return_converter_init', return_converter_list), - ): - print(title + ":") - - ids.sort(key=lambda item: item[0].lower()) - longest = -1 - for name, _ in ids: - longest = max(longest, len(name)) - - for name, cls in ids: - callable = getattr(cls, attribute, None) - if not callable: - continue - signature = inspect.signature(callable) - parameters = [] - for parameter_name, parameter in signature.parameters.items(): - if parameter.kind == inspect.Parameter.KEYWORD_ONLY: - if parameter.default != inspect.Parameter.empty: - s = f'{parameter_name}={parameter.default!r}' - else: - s = parameter_name - parameters.append(s) - print(' {}({})'.format(name, ', '.join(parameters))) - print() - print("All converters also accept (c_default=None, py_default=None, annotation=None).") - print("All return converters also accept (py_default=None).") - return +def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None: + dry_run = ns.dry_run or ns.diff + # The report is written to the standard output, so the progress + # is written to the standard error stream to not mix them. + verbose_file = sys.stderr if dry_run else sys.stdout + + filenames: Iterable[str] if ns.make: if ns.output or ns.filename: parser.error("can't use -o or filenames with --make") if not ns.srcdir: parser.error("--srcdir must not be empty with --make") - if ns.exclude: - excludes = [os.path.join(ns.srcdir, f) for f in ns.exclude] - excludes = [os.path.normpath(f) for f in excludes] - else: - excludes = [] - writer = libclinic.FileWriter(dry_run=dry_run) - for root, dirs, files in os.walk(ns.srcdir): - for rcs_dir in ('.svn', '.git', '.hg', 'build', 'externals'): - if rcs_dir in dirs: - dirs.remove(rcs_dir) - for filename in files: - # handle .c, .cpp and .h files - if not filename.endswith(('.c', '.cpp', '.h')): - continue - path = os.path.join(root, filename) - path = os.path.normpath(path) - if path in excludes: - continue - if ns.verbose: - print(path, file=verbose_file) - parse_file(path, - verify=not ns.force, limited_capi=ns.limited_capi, - writer=writer) - report_changes(writer, diff=ns.diff) - return - - if not ns.filename: - parser.error("no input files") - - if ns.output and len(ns.filename) > 1: - parser.error("can't use -o with multiple filenames") + filenames = walk_srcdir(ns.srcdir, ns.exclude) + else: + if not ns.filename and not ns.converters: + parser.error("no input files") + if ns.output and len(ns.filename) > 1: + parser.error("can't use -o with multiple filenames") + filenames = ns.filename - writer = libclinic.FileWriter(dry_run=dry_run) - for filename in ns.filename: + if ns.converters: + if dry_run: + parser.error("can't use --dry-run or --diff with --converters") + if not ns.make and not ns.filename: + print_converters(converters, legacy_converters, return_converters) + return + # Converters defined in a file are added to the same registries + # as the built-in ones, so remember the latter to tell them apart. + builtin_converters = dict(converters) + builtin_legacy_converters = dict(legacy_converters) + builtin_return_converters = dict(return_converters) + + writer = libclinic.FileWriter(dry_run=dry_run or ns.converters) + for filename in filenames: if ns.verbose: print(filename, file=verbose_file) parse_file(filename, output=ns.output, verify=not ns.force, limited_capi=ns.limited_capi, writer=writer) - report_changes(writer, diff=ns.diff) + + if ns.converters: + print_converters( + defined_in_files(converters, builtin_converters), + defined_in_files(legacy_converters, builtin_legacy_converters), + defined_in_files(return_converters, builtin_return_converters)) + else: + report_changes(writer, diff=ns.diff) def main(argv: list[str] | None = None) -> NoReturn: From 7763c983c9b9e0c44059746b2e6b0d53a8af0815 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 12:38:40 +0300 Subject: [PATCH 7/8] gh-64502: Support several optional groups on the same level in Argument Clinic (GH-155210) Groups on the same nesting level, like in "[y, x,] [n,] attr", can now be omitted independently of each other. A group is now identified by a unique number instead of its nesting level. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/test/clinic.test.c | 79 +++++++++ Lib/test/test_clinic.py | 160 ++++++++++++++---- ...6-08-04-19-05-41.gh-issue-64502.Rb2wKt.rst | 3 + Modules/_testclinic.c | 54 ++++++ Modules/clinic/_testclinic.c.h | 116 ++++++++++++- Tools/clinic/libclinic/clanguage.py | 27 ++- Tools/clinic/libclinic/dsl_parser.py | 59 +++++-- Tools/clinic/libclinic/function.py | 26 ++- 8 files changed, 460 insertions(+), 64 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-04-19-05-41.gh-issue-64502.Rb2wKt.rst diff --git a/Lib/test/clinic.test.c b/Lib/test/clinic.test.c index 870e4f6956ce4f..146f57a2a11342 100644 --- a/Lib/test/clinic.test.c +++ b/Lib/test/clinic.test.c @@ -5830,6 +5830,85 @@ group_and_optional_parameter_impl(PyObject *module, int group_left_1, /*[clinic end generated code: output=3faea69eafd5bbbe input=7f0fbb6124f5a972]*/ +/*[clinic input] +two_groups_on_the_same_level + [ + a: object + b: object + ] + [ + c: object + ] + d: object + / +Groups on the same level are independent of each other. +[clinic start generated code]*/ + +PyDoc_STRVAR(two_groups_on_the_same_level__doc__, +"two_groups_on_the_same_level([a, b,] [c,] d)\n" +"Groups on the same level are independent of each other."); + +#define TWO_GROUPS_ON_THE_SAME_LEVEL_METHODDEF \ + {"two_groups_on_the_same_level", (PyCFunction)two_groups_on_the_same_level, METH_VARARGS, two_groups_on_the_same_level__doc__}, + +static PyObject * +two_groups_on_the_same_level_impl(PyObject *module, int group_left_1, + PyObject *a, PyObject *b, int group_left_2, + PyObject *c, PyObject *d); + +static PyObject * +two_groups_on_the_same_level(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + int group_left_1 = 0; + PyObject *a = NULL; + PyObject *b = NULL; + int group_left_2 = 0; + PyObject *c = NULL; + PyObject *d; + + switch (PyTuple_GET_SIZE(args)) { + case 1: + if (!PyArg_ParseTuple(args, "O:two_groups_on_the_same_level", &d)) { + goto exit; + } + break; + case 2: + if (!PyArg_ParseTuple(args, "OO:two_groups_on_the_same_level", &c, &d)) { + goto exit; + } + group_left_2 = 1; + break; + case 3: + if (!PyArg_ParseTuple(args, "OOO:two_groups_on_the_same_level", &a, &b, &d)) { + goto exit; + } + group_left_1 = 1; + break; + case 4: + if (!PyArg_ParseTuple(args, "OOOO:two_groups_on_the_same_level", &a, &b, &c, &d)) { + goto exit; + } + group_left_1 = 1; + group_left_2 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "two_groups_on_the_same_level requires 1 to 4 arguments"); + goto exit; + } + return_value = two_groups_on_the_same_level_impl(module, group_left_1, a, b, group_left_2, c, d); + +exit: + return return_value; +} + +static PyObject * +two_groups_on_the_same_level_impl(PyObject *module, int group_left_1, + PyObject *a, PyObject *b, int group_left_2, + PyObject *c, PyObject *d) +/*[clinic end generated code: output=508a61ee582da21e input=1b45d9b675b32d1a]*/ + + /*[clinic input] Test._pyarg_parsestackandkeywords cls: defining_class diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index f35b11cff551a8..cb4507dcac2336 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -836,7 +836,7 @@ def _test(self, l, m, r, output): self.assertEqual(output, computed) def test_range(self): - self._test([['start']], ['stop'], [['step']], + self._test([[['start']]], ['stop'], [[['step']]], ( ('stop',), ('start', 'stop',), @@ -844,7 +844,7 @@ def test_range(self): )) def test_add_window(self): - self._test([['x', 'y']], ['ch'], [['attr']], + self._test([[['x', 'y']]], ['ch'], [[['attr']]], ( ('ch',), ('ch', 'attr'), @@ -853,7 +853,8 @@ def test_add_window(self): )) def test_ludicrous(self): - self._test([['a1', 'a2', 'a3'], ['b1', 'b2']], ['c1'], [['d1', 'd2'], ['e1', 'e2', 'e3']], + self._test([[['a1', 'a2', 'a3'], ['b1', 'b2']]], ['c1'], + [[['d1', 'd2'], ['e1', 'e2', 'e3']]], ( ('c1',), ('b1', 'b2', 'c1'), @@ -864,7 +865,7 @@ def test_ludicrous(self): )) def test_right_only(self): - self._test([], [], [['a'],['b'],['c']], + self._test([], [], [[['a'],['b'],['c']]], ( (), ('a',), @@ -872,9 +873,28 @@ def test_right_only(self): ('a', 'b', 'c') )) + def test_chgat(self): + # Two independent groups on the left. + self._test([[['y', 'x']], [['n']]], ['attr'], [], + ( + ('attr',), + ('n', 'attr'), + ('y', 'x', 'attr'), + ('y', 'x', 'n', 'attr'), + )) + + def test_independent_groups_on_the_right(self): + self._test([], ['a'], [[['b']], [['c', 'd']]], + ( + ('a',), + ('a', 'b'), + ('a', 'c', 'd'), + ('a', 'b', 'c', 'd'), + )) + def test_have_left_options_but_required_is_empty(self): def fn(): - permute_optional_groups(['a'], [], []) + permute_optional_groups([[['a']]], [], []) self.assertRaises(ValueError, fn) @@ -1715,41 +1735,74 @@ def test_nested_groups(self): Attributes for the character. """) - def test_disallowed_grouping__two_top_groups_on_left(self): - err = ( - "Function 'two_top_groups_on_left' has an unsupported group " - "configuration. (Unexpected state 2.b)" - ) - block = """ - module foo - foo.two_top_groups_on_left + def test_two_top_groups_on_left(self): + function = self.parse_function(""" + module curses + curses.chgat [ - group1 : int + y: int + Y-coordinate. + x: int + X-coordinate. ] [ - group2 : int + num: int + Number of characters. ] - param: int - """ - self.expect_failure(block, err, lineno=5) + attr: long + Attributes for the characters. + / + """) + dataset = ( + ('y', -1), ('x', -1), + ('num', -2), + ('attr', 0), + ) + for name, group in dataset: + with self.subTest(name=name, group=group): + p = function.parameters[name] + self.assertEqual(p.group, group) + self.assertEqual(p.kind, inspect.Parameter.POSITIONAL_ONLY) + self.checkDocstring(function, """ + chgat([y, x,] [num,] attr) - def test_disallowed_grouping__two_top_groups_on_right(self): - block = """ + + y + Y-coordinate. + x + X-coordinate. + num + Number of characters. + attr + Attributes for the characters. + """) + + def test_two_top_groups_on_right(self): + function = self.parse_function(""" module foo foo.two_top_groups_on_right param: int [ - group1 : int + group1: int ] [ - group2 : int + group2: int ] - """ - err = ( - "Function 'two_top_groups_on_right' has an unsupported group " - "configuration. (Unexpected state 6.b)" + / + """) + dataset = ( + ('param', 0), + ('group1', 1), + ('group2', 2), ) - self.expect_failure(block, err) + for name, group in dataset: + with self.subTest(name=name, group=group): + p = function.parameters[name] + self.assertEqual(p.group, group) + self.assertEqual(p.kind, inspect.Parameter.POSITIONAL_ONLY) + self.checkDocstring(function, """ + two_top_groups_on_right(param, [group1,] [group2]) + """) def test_disallowed_grouping__parameter_after_group_on_right(self): block = """ @@ -4103,6 +4156,26 @@ def test_group_and_two_opt(self): self.assertEqual(fn(1, 2, 3, 4, 5), (True, 1, 2, 3, 4, 5)) self.assertRaises(TypeError, fn, 1, 2, 3, 4, 5, 6) + def test_two_groups_on_left(self): + # fn([a, b,] [c,] d) + fn = ac_tester.two_groups_on_left + self.assertRaises(TypeError, fn) + self.assertEqual(fn(1), (False, None, None, False, None, 1)) + self.assertEqual(fn(1, 2), (False, None, None, True, 1, 2)) + self.assertEqual(fn(1, 2, 3), (True, 1, 2, False, None, 3)) + self.assertEqual(fn(1, 2, 3, 4), (True, 1, 2, True, 3, 4)) + self.assertRaises(TypeError, fn, 1, 2, 3, 4, 5) + + def test_two_groups_on_right(self): + # fn(a, [b,] [c, d]) + fn = ac_tester.two_groups_on_right + self.assertRaises(TypeError, fn) + self.assertEqual(fn(1), (1, False, None, False, None, None)) + self.assertEqual(fn(1, 2), (1, True, 2, False, None, None)) + self.assertEqual(fn(1, 2, 3), (1, False, None, True, 2, 3)) + self.assertEqual(fn(1, 2, 3, 4), (1, True, 2, True, 3, 4)) + self.assertRaises(TypeError, fn, 1, 2, 3, 4, 5) + def test_gh_32092_oob(self): ac_tester.gh_32092_oob(1, 2, 3, 4, kw1=5, kw2=6) @@ -4705,21 +4778,21 @@ def test_permute_optional_groups(self): "expected": ((),), } noleft1 = { - "left": (), "required": ("b",), "right": ("c",), + "left": (), "required": ("b",), "right": (("c",),), "expected": ( ("b",), ("b", "c"), ), } noleft2 = { - "left": (), "required": ("b", "c",), "right": ("d",), + "left": (), "required": ("b", "c",), "right": (("d",),), "expected": ( ("b", "c"), ("b", "c", "d"), ), } noleft3 = { - "left": (), "required": ("b", "c",), "right": ("d", "e"), + "left": (), "required": ("b", "c",), "right": (("d", "e"),), "expected": ( ("b", "c"), ("b", "c", "d"), @@ -4727,21 +4800,21 @@ def test_permute_optional_groups(self): ), } noright1 = { - "left": ("a",), "required": ("b",), "right": (), + "left": (("a",),), "required": ("b",), "right": (), "expected": ( ("b",), ("a", "b"), ), } noright2 = { - "left": ("a",), "required": ("b", "c"), "right": (), + "left": (("a",),), "required": ("b", "c"), "right": (), "expected": ( ("b", "c"), ("a", "b", "c"), ), } noright3 = { - "left": ("a", "b"), "required": ("c",), "right": (), + "left": (("a", "b"),), "required": ("c",), "right": (), "expected": ( ("c",), ("b", "c"), @@ -4749,7 +4822,7 @@ def test_permute_optional_groups(self): ), } leftandright1 = { - "left": ("a",), "required": ("b",), "right": ("c",), + "left": (("a",),), "required": ("b",), "right": (("c",),), "expected": ( ("b",), ("a", "b"), # Prefer left. @@ -4757,7 +4830,7 @@ def test_permute_optional_groups(self): ), } leftandright2 = { - "left": ("a", "b"), "required": ("c", "d"), "right": ("e", "f"), + "left": (("a", "b"),), "required": ("c", "d"), "right": (("e", "f"),), "expected": ( ("c", "d"), ("b", "c", "d"), # Prefer left. @@ -4766,11 +4839,28 @@ def test_permute_optional_groups(self): ("a", "b", "c", "d", "e", "f"), ), } + independentleft = { + "left": (("a",), ("b",)), "required": ("c",), "right": (), + "expected": ( + ("c",), + ("b", "c"), + ("a", "b", "c"), + ), + } + independentright = { + "left": (), "required": ("a",), "right": (("b",), ("c",)), + "expected": ( + ("a",), + ("a", "b"), + ("a", "b", "c"), + ), + } dataset = ( empty, noleft1, noleft2, noleft3, noright1, noright2, noright3, leftandright1, leftandright2, + independentleft, independentright, ) for params in dataset: with self.subTest(**params): diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-04-19-05-41.gh-issue-64502.Rb2wKt.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-04-19-05-41.gh-issue-64502.Rb2wKt.rst new file mode 100644 index 00000000000000..d3e75435df9182 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-04-19-05-41.gh-issue-64502.Rb2wKt.rst @@ -0,0 +1,3 @@ +Argument Clinic now supports several optional groups on the same nesting +level, like in ``[y, x,] [n,] attr``. +Such groups can be omitted independently of each other. diff --git a/Modules/_testclinic.c b/Modules/_testclinic.c index 5742dd65f6742a..c53bf4a0875358 100644 --- a/Modules/_testclinic.c +++ b/Modules/_testclinic.c @@ -1259,6 +1259,58 @@ group_and_opt_impl(PyObject *module, int group_left_1, PyObject *a, } +/*[clinic input] +two_groups_on_left + + [ + a: object + b: object + ] + [ + c: object + ] + d: object + / + +[clinic start generated code]*/ + +static PyObject * +two_groups_on_left_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, int group_left_2, PyObject *c, + PyObject *d) +/*[clinic end generated code: output=3a43d6542864e3d8 input=79fc792669696ac7]*/ +{ + return pack_arguments_newref(6, group_left_1 ? Py_True : Py_False, a, b, + group_left_2 ? Py_True : Py_False, c, d); +} + + +/*[clinic input] +two_groups_on_right + + a: object + [ + b: object + ] + [ + c: object + d: object + ] + / + +[clinic start generated code]*/ + +static PyObject * +two_groups_on_right_impl(PyObject *module, PyObject *a, int group_right_1, + PyObject *b, int group_right_2, PyObject *c, + PyObject *d) +/*[clinic end generated code: output=045f60f127c6e448 input=96895285f29bb501]*/ +{ + return pack_arguments_newref(6, a, group_right_1 ? Py_True : Py_False, b, + group_right_2 ? Py_True : Py_False, c, d); +} + + /*[clinic input] group_and_two_opt @@ -2503,6 +2555,8 @@ static PyMethodDef tester_methods[] = { POSONLY_POSKW_VARPOS_ARRAY_METHODDEF GROUP_AND_OPT_METHODDEF GROUP_AND_TWO_OPT_METHODDEF + TWO_GROUPS_ON_LEFT_METHODDEF + TWO_GROUPS_ON_RIGHT_METHODDEF GH_32092_OOB_METHODDEF GH_32092_KW_PASS_METHODDEF diff --git a/Modules/clinic/_testclinic.c.h b/Modules/clinic/_testclinic.c.h index 19b215b0cca889..3fe32d704f0140 100644 --- a/Modules/clinic/_testclinic.c.h +++ b/Modules/clinic/_testclinic.c.h @@ -3520,6 +3520,120 @@ group_and_opt(PyObject *module, PyObject *args) return return_value; } +PyDoc_STRVAR(two_groups_on_left__doc__, +"two_groups_on_left([a, b,] [c,] d)"); + +#define TWO_GROUPS_ON_LEFT_METHODDEF \ + {"two_groups_on_left", (PyCFunction)two_groups_on_left, METH_VARARGS, two_groups_on_left__doc__}, + +static PyObject * +two_groups_on_left_impl(PyObject *module, int group_left_1, PyObject *a, + PyObject *b, int group_left_2, PyObject *c, + PyObject *d); + +static PyObject * +two_groups_on_left(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + int group_left_1 = 0; + PyObject *a = NULL; + PyObject *b = NULL; + int group_left_2 = 0; + PyObject *c = NULL; + PyObject *d; + + switch (PyTuple_GET_SIZE(args)) { + case 1: + if (!PyArg_ParseTuple(args, "O:two_groups_on_left", &d)) { + goto exit; + } + break; + case 2: + if (!PyArg_ParseTuple(args, "OO:two_groups_on_left", &c, &d)) { + goto exit; + } + group_left_2 = 1; + break; + case 3: + if (!PyArg_ParseTuple(args, "OOO:two_groups_on_left", &a, &b, &d)) { + goto exit; + } + group_left_1 = 1; + break; + case 4: + if (!PyArg_ParseTuple(args, "OOOO:two_groups_on_left", &a, &b, &c, &d)) { + goto exit; + } + group_left_1 = 1; + group_left_2 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "two_groups_on_left requires 1 to 4 arguments"); + goto exit; + } + return_value = two_groups_on_left_impl(module, group_left_1, a, b, group_left_2, c, d); + +exit: + return return_value; +} + +PyDoc_STRVAR(two_groups_on_right__doc__, +"two_groups_on_right(a, [b,] [c, d])"); + +#define TWO_GROUPS_ON_RIGHT_METHODDEF \ + {"two_groups_on_right", (PyCFunction)two_groups_on_right, METH_VARARGS, two_groups_on_right__doc__}, + +static PyObject * +two_groups_on_right_impl(PyObject *module, PyObject *a, int group_right_1, + PyObject *b, int group_right_2, PyObject *c, + PyObject *d); + +static PyObject * +two_groups_on_right(PyObject *module, PyObject *args) +{ + PyObject *return_value = NULL; + PyObject *a; + int group_right_1 = 0; + PyObject *b = NULL; + int group_right_2 = 0; + PyObject *c = NULL; + PyObject *d = NULL; + + switch (PyTuple_GET_SIZE(args)) { + case 1: + if (!PyArg_ParseTuple(args, "O:two_groups_on_right", &a)) { + goto exit; + } + break; + case 2: + if (!PyArg_ParseTuple(args, "OO:two_groups_on_right", &a, &b)) { + goto exit; + } + group_right_1 = 1; + break; + case 3: + if (!PyArg_ParseTuple(args, "OOO:two_groups_on_right", &a, &c, &d)) { + goto exit; + } + group_right_2 = 1; + break; + case 4: + if (!PyArg_ParseTuple(args, "OOOO:two_groups_on_right", &a, &b, &c, &d)) { + goto exit; + } + group_right_1 = 1; + group_right_2 = 1; + break; + default: + PyErr_SetString(PyExc_TypeError, "two_groups_on_right requires 1 to 4 arguments"); + goto exit; + } + return_value = two_groups_on_right_impl(module, a, group_right_1, b, group_right_2, c, d); + +exit: + return return_value; +} + PyDoc_STRVAR(group_and_two_opt__doc__, "group_and_two_opt([a, b, c,] d=None, e=None)"); @@ -4690,4 +4804,4 @@ _testclinic_TestClass_posonly_poskw_varpos_array_no_fastcall(PyObject *type, PyO exit: return return_value; } -/*[clinic end generated code: output=f6a3b617130c4e3a input=a9049054013a1b77]*/ +/*[clinic end generated code: output=d9d4091b2f2ed359 input=a9049054013a1b77]*/ diff --git a/Tools/clinic/libclinic/clanguage.py b/Tools/clinic/libclinic/clanguage.py index 3ee06307441fac..1581a19a4fd78a 100644 --- a/Tools/clinic/libclinic/clanguage.py +++ b/Tools/clinic/libclinic/clanguage.py @@ -288,29 +288,44 @@ def render_option_group_parsing( # What if the number of arguments leads us to an ambiguous result? # Clinic prefers groups on the left. So in the above example, # five arguments would map to B+C, not C+D. + # + # A nested group can only be omitted together with the group + # containing it, but groups on the same level, like G and H in + # + # [ G1 G2 ] [ H1 ] I1 I2 + # + # can be omitted independently of each other. out = [] parameters = list(f.parameters.values()) if isinstance(parameters[0].converter, self_converter): del parameters[0] + # Groups are collected into chains of nested groups. A group which + # is not nested in the preceding one starts a new chain. group: list[Parameter] | None = None - left = [] - right = [] + left: list[list[list[Parameter]]] = [] + right: list[list[list[Parameter]]] = [] required: list[Parameter] = [] last: int | Literal[Sentinels.unspecified] = unspecified + last_depth = 0 for p in parameters: group_id = p.group if group_id != last: last = group_id group = [] - if group_id < 0: - left.append(group) - elif group_id == 0: + if group_id == 0: group = required else: - right.append(group) + chains = left if group_id < 0 else right + nested = ((p.group_depth < last_depth) if group_id < 0 + else (p.group_depth > last_depth)) + if chains and nested: + chains[-1].append(group) + else: + chains.append([group]) + last_depth = p.group_depth assert group is not None group.append(p) diff --git a/Tools/clinic/libclinic/dsl_parser.py b/Tools/clinic/libclinic/dsl_parser.py index 90e2e0d3d9c928..4dcbc815cc6f25 100644 --- a/Tools/clinic/libclinic/dsl_parser.py +++ b/Tools/clinic/libclinic/dsl_parser.py @@ -112,8 +112,8 @@ class ParamState(enum.IntEnum): """Parameter parsing state. - [ [ a, b, ] c, ] d, e, f=3, [ g, h, [ i ] ] <- line - 01 2 3 4 5 6 <- state transitions + [ [ a, b, ] c, ] [ d, ] e, f=3, [ g, h, [ i ] ] [ j ] <- line + 01 2 3 12 3 4 5 6 5 6 <- state transitions """ # Before we've seen anything. # Legal transitions: to LEFT_SQUARE_BEFORE or REQUIRED @@ -251,7 +251,8 @@ class DSLParser: positional_only: bool deprecated_positional: VersionTuple | None deprecated_keyword: VersionTuple | None - group: int + group_stack: list[int] + group_count: int parameter_state: ParamState indent: IndentStack kind: FunctionKind @@ -291,7 +292,8 @@ def reset(self) -> None: self.positional_only = False self.deprecated_positional = None self.deprecated_keyword = None - self.group = 0 + self.group_stack = [] + self.group_count = 0 self.parameter_state: ParamState = ParamState.START self.indent = IndentStack() self.kind = CALLABLE @@ -829,6 +831,7 @@ def to_required(self) -> None: assert self.function is not None for p in self.function.parameters.values(): p.group = -p.group + self.group_count = 0 def state_parameter(self, line: str) -> None: assert isinstance(self.function, Function) @@ -888,7 +891,7 @@ def parse_parameter(self, line: str) -> None: case ParamState.LEFT_SQUARE_BEFORE: self.parameter_state = ParamState.GROUP_BEFORE case ParamState.GROUP_BEFORE: - if not self.group: + if not self.group_stack: self.to_required() case ParamState.GROUP_AFTER | ParamState.OPTIONAL: pass @@ -1082,7 +1085,7 @@ def bad_node(self, node: ast.AST) -> None: if isinstance(converter, self_converter): if len(self.function.parameters) == 1: - if self.group: + if self.group_stack: fail("A 'self' parameter cannot be in an optional group.") assert self.parameter_state is ParamState.REQUIRED assert value is unspecified @@ -1096,7 +1099,7 @@ def bad_node(self, node: ast.AST) -> None: if isinstance(converter, defining_class_converter): _lp = len(self.function.parameters) if _lp == 1: - if self.group: + if self.group_stack: fail("A 'defining_class' parameter cannot be in an optional group.") if self.function.cls is None: fail("A 'defining_class' parameter cannot be defined at module level.") @@ -1110,7 +1113,9 @@ def bad_node(self, node: ast.AST) -> None: p = Parameter(parameter_name, kind, function=self.function, - converter=converter, default=value, group=self.group, + converter=converter, default=value, + group=self.group_stack[-1] if self.group_stack else 0, + group_depth=len(self.group_stack), deprecated_positional=self.deprecated_positional) names = [k.name for k in self.function.parameters.values()] @@ -1189,26 +1194,34 @@ def parse_star(self, function: Function, version: VersionTuple | None) -> None: def parse_opening_square_bracket(self, function: Function) -> None: """Parse opening parameter group symbol '['.""" + # A group can only be nested in a group which does not contain + # parameters yet, but two groups on the same nesting level can + # follow each other. match self.parameter_state: case ParamState.START | ParamState.LEFT_SQUARE_BEFORE: self.parameter_state = ParamState.LEFT_SQUARE_BEFORE + case ParamState.GROUP_BEFORE if not self.group_stack: + self.parameter_state = ParamState.LEFT_SQUARE_BEFORE case ParamState.REQUIRED | ParamState.GROUP_AFTER: self.parameter_state = ParamState.GROUP_AFTER + case ParamState.RIGHT_SQUARE_AFTER if not self.group_stack: + self.parameter_state = ParamState.GROUP_AFTER case st: fail(f"Function {function.name!r} " f"has an unsupported group configuration. " f"(Unexpected state {st}.b)") - self.group += 1 + self.group_count += 1 + self.group_stack.append(self.group_count) function.docstring_only = True def parse_closing_square_bracket(self, function: Function) -> None: """Parse closing parameter group symbol ']'.""" - if not self.group: + if not self.group_stack: fail(f"Function {function.name!r} has a ']' without a matching '['.") - if not any(p.group == self.group for p in function.parameters.values()): + group = self.group_stack.pop() + if not any(p.group == group for p in function.parameters.values()): fail(f"Function {function.name!r} has an empty group. " "All groups must contain at least one parameter.") - self.group -= 1 match self.parameter_state: case ParamState.LEFT_SQUARE_BEFORE | ParamState.GROUP_BEFORE: self.parameter_state = ParamState.GROUP_BEFORE @@ -1268,7 +1281,7 @@ def parse_slash(self, function: Function, version: VersionTuple | None) -> None: ParamState.RIGHT_SQUARE_AFTER, ParamState.GROUP_BEFORE, } - if (self.parameter_state not in allowed) or self.group: + if (self.parameter_state not in allowed) or self.group_stack: fail(f"Function {function.name!r} has an unsupported group configuration. " f"(Unexpected state {self.parameter_state}.d)") # fixup preceding parameters @@ -1329,7 +1342,7 @@ def state_parameter_docstring(self, line: str) -> None: def state_function_docstring(self, line: str) -> None: assert self.function is not None - if self.group: + if self.group_stack: fail(f"Function {self.function.name!r} has a ']' without a matching '['.") if not self.valid_line(line): @@ -1364,16 +1377,25 @@ def format_docstring_signature( else: assert positional_only if positional_only: - p.right_bracket_count = abs(p.group) + p.right_bracket_count = p.group_depth else: # don't put any right brackets around non-positional-only parameters, ever. p.right_bracket_count = 0 right_bracket_count = 0 + last_group = 0 - def fix_right_bracket_count(desired: int) -> str: - nonlocal right_bracket_count + def fix_right_bracket_count(desired: int, group: int = 0) -> str: + nonlocal right_bracket_count, last_group s = '' + if (group != last_group and right_bracket_count and + ((desired >= right_bracket_count) if group < 0 else + (desired <= right_bracket_count))): + # The group is not nested in the previous group, + # close the brackets of the latter first. + s += ']' * right_bracket_count + right_bracket_count = 0 + last_group = group while right_bracket_count < desired: s += '[' right_bracket_count += 1 @@ -1441,7 +1463,8 @@ def add_parameter(text: str) -> None: added_star = True add_parameter('*,') - p_lines = [fix_right_bracket_count(p.right_bracket_count)] + p_lines = [fix_right_bracket_count(p.right_bracket_count, + p.group)] if isinstance(p.converter, self_converter): # annotate first parameter as being a "self". diff --git a/Tools/clinic/libclinic/function.py b/Tools/clinic/libclinic/function.py index 1c643caea98e3b..325633eb010608 100644 --- a/Tools/clinic/libclinic/function.py +++ b/Tools/clinic/libclinic/function.py @@ -205,7 +205,11 @@ class Parameter: converter: CConverter annotation: object = inspect.Parameter.empty docstring: str = '' + # Identifier of the optional group containing the parameter (0 if none). + # It is negative for groups before the required parameters. group: int = 0 + # Nesting level of that group (0 if none). + group_depth: int = 0 # (`None` signifies that there is no deprecation) deprecated_positional: VersionTuple | None = None deprecated_keyword: VersionTuple | None = None @@ -301,15 +305,18 @@ def permute_right_option_groups( def permute_optional_groups( - left: Sequence[Iterable[Parameter]], + left: Sequence[Sequence[Iterable[Parameter]]], required: Iterable[Parameter], - right: Sequence[Iterable[Parameter]] + right: Sequence[Sequence[Iterable[Parameter]]] ) -> tuple[ParamTuple, ...]: """ Generator function that computes the set of acceptable argument lists for the provided iterables of argument groups. (Actually it generates a tuple of tuples.) + "left" and "right" are sequences of chains of nested groups. + Groups of different chains are independent of each other. + Algorithm: prefer left options over right options. If required is empty, left must also be empty. @@ -319,10 +326,21 @@ def permute_optional_groups( if left: raise ValueError("required is empty but left is not") + left_options: list[ParamTuple] = [()] + for chain in left: + left_options = [option + t + for option in left_options + for t in permute_left_option_groups(chain)] + right_options: list[ParamTuple] = [()] + for chain in reversed(right): + right_options = [t + option + for option in right_options + for t in permute_right_option_groups(chain)] + accumulator: list[ParamTuple] = [] counts = set() - for r in permute_right_option_groups(right): - for l in permute_left_option_groups(left): + for r in right_options: + for l in left_options: t = l + required + r if len(t) in counts: continue From 1d90627f1660253b30e5efc2ca092a6231737993 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 14:03:30 +0300 Subject: [PATCH 8/8] gh-152548: Add options, env and timeout parameters to runInSubprocess() (GH-155163) They run the test subprocess with specific interpreter command line options and environment variables, and limit how long it may take. All are keyword-only. env is layered over the inherited environment; a None value unsets a variable. There is no timeout by default. Co-authored-by: Claude Opus 5 (1M context) --- Doc/library/test.rst | 15 +++++++- Lib/test/_isolated_sample.py | 37 +++++++++++++++++++ Lib/test/support/isolation.py | 67 +++++++++++++++++++++++++++-------- Lib/test/test_support.py | 26 ++++++++++++++ 4 files changed, 130 insertions(+), 15 deletions(-) diff --git a/Doc/library/test.rst b/Doc/library/test.rst index 660847ae3fe3c8..893154246ae4d8 100644 --- a/Doc/library/test.rst +++ b/Doc/library/test.rst @@ -963,7 +963,7 @@ The :mod:`!test.support` module defines the following functions: .. currentmodule:: test.support.isolation -.. decorator:: runInSubprocess() +.. decorator:: runInSubprocess(*, options=(), env=None, timeout=None) Decorator that runs the decorated test in a fresh interpreter subprocess, in isolation, so that it does not share global or interpreter state with the @@ -997,6 +997,19 @@ The :mod:`!test.support` module defines the following functions: :func:`~test.support.bigmemtest` and the like behave consistently in both processes. + *options* is a sequence of interpreter command line options + to run the subprocess with, + and *env* is a mapping of environment variables to set in it, + on top of the inherited environment. + A value of ``None`` in *env* unsets the variable. + Note that :option:`-E` and :option:`-I` make the subprocess ignore + the ``PYTHON*`` environment variables, including :envvar:`PYTHONPATH`. + + *timeout* is the number of seconds to wait for the subprocess; + the test is reported as an error if it does not complete in time. + By default there is no timeout, + and a hung test is left to the timeout of the test runner. + The test is skipped on platforms without subprocess support. diff --git a/Lib/test/_isolated_sample.py b/Lib/test/_isolated_sample.py index c89f7145e7328d..5853b654fc28cb 100644 --- a/Lib/test/_isolated_sample.py +++ b/Lib/test/_isolated_sample.py @@ -7,6 +7,7 @@ import atexit import os +import sys import time import unittest from test.support import isolation @@ -141,3 +142,39 @@ def test_pass(self): def test_dies(self): _die_at_exit() + + +@isolation.runInSubprocess(options=['-X', 'dev', '-W', 'error::BytesWarning']) +class OptionsSample(unittest.TestCase): + + def test_options_applied(self): + self.assertTrue(sys.flags.dev_mode) + self.assertIn('error::BytesWarning', sys.warnoptions) + + +class EnvSample(unittest.TestCase): + + @isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': 'set-by-test'}) + def test_env_set(self): + self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-test') + + @isolation.runInSubprocess(env={'_PYTHON_ISOLATION_PROBE': None}) + def test_env_unset(self): + self.assertNotIn('_PYTHON_ISOLATION_PROBE', os.environ) + + @isolation.runInSubprocess() + def test_env_inherited(self): + # Without env= the subprocess inherits the parent environment as it is. + self.assertEqual(os.environ.get('_PYTHON_ISOLATION_PROBE'), 'set-by-parent') + + +# TimeoutSample hangs this long, so that the timeout always fires first. +TIMEOUT_HANG = 60.0 +TIMEOUT = 0.5 + + +class TimeoutSample(unittest.TestCase): + + @isolation.runInSubprocess(timeout=TIMEOUT) + def test_hang(self): + time.sleep(TIMEOUT_HANG) diff --git a/Lib/test/support/isolation.py b/Lib/test/support/isolation.py index bc2189329c0399..bb4fa6b003cc20 100644 --- a/Lib/test/support/isolation.py +++ b/Lib/test/support/isolation.py @@ -78,7 +78,11 @@ def _decode(data): def _remote(detail): # Wrap the subprocess traceback the way concurrent.futures does, so it is - # clearly delimited when shown as the cause. + # clearly delimited when shown as the cause. Return None if the subprocess + # said nothing (a hung one usually does not), so that "raise ... from None" + # suppresses an empty cause. + if not detail: + return None return _RemoteTraceback(f'\n"""\n{detail}"""') @@ -90,7 +94,21 @@ def _check_subprocess_support(): raise unittest.SkipTest('requires subprocess support') -def _run_in_subprocess(module, qualname): +def _child_environ(env): + # Start from the inherited environment, so that *env* only has to name what + # the test changes. + if not env: + return None + environ = dict(os.environ) + for name, value in env.items(): + if value is None: + environ.pop(name, None) + else: + environ[name] = value + return environ + + +def _run_in_subprocess(module, qualname, options, env, timeout): """Run module.qualname (a test method or class) in a fresh subprocess. Return ``(payload, output, returncode)``, where *payload* is the decoded @@ -104,13 +122,22 @@ def _run_in_subprocess(module, qualname): os.close(fd) try: # Pass the config on the command line, not in the environment, so that - # the test cannot pass it on to the processes it spawns itself. Use - # marshal, not json: it is built in, so the child imports nothing that - # the test would not see in a normal test run. - cmd = [sys.executable, '-m', 'test.support.subprocess_runner', + # the test cannot pass it on to the processes it spawns itself, and so + # that it survives the -E and -I options. Use marshal, not json: it is + # built in, so the child imports nothing that the test would not see in + # a normal test run. + cmd = [sys.executable, *options, '-m', 'test.support.subprocess_runner', module, qualname, result_path, marshal.dumps(_child_config()).hex()] - proc = subprocess.run(cmd, capture_output=True) + try: + proc = subprocess.run(cmd, capture_output=True, + env=_child_environ(env), timeout=timeout) + except subprocess.TimeoutExpired as exc: + # Report the hang rather than leaving the test runner stuck. + output = _decode(exc.stdout) + _decode(exc.stderr) + raise _SubprocessTestError( + f'test did not complete in a subprocess ' + f'within {timeout} seconds') from _remote(output) try: with open(result_path, 'rb') as f: payload = marshal.load(f) @@ -173,7 +200,7 @@ def _check_returncode(returncode, output, what): raise exc from _remote(output) -def _isolate_method(func): +def _isolate_method(func, options, env, timeout): @functools.wraps(func) def wrapper(self, /, *args, **kwargs): if runningInSubprocess: @@ -183,7 +210,8 @@ def wrapper(self, /, *args, **kwargs): cls = type(self) qualname = f'{cls.__qualname__}.{func.__name__}' payload, output, returncode = _run_in_subprocess(cls.__module__, - qualname) + qualname, options, + env, timeout) if payload is None: exc = _SubprocessTestError( f'test did not complete in a subprocess (exit code {returncode})') @@ -196,7 +224,7 @@ def wrapper(self, /, *args, **kwargs): return wrapper -def _isolate_class(cls): +def _isolate_class(cls, options, env, timeout): # Unwrap to the plain functions so the replacements can call them with the # runtime cls; a bound classmethod would freeze the decoration-time class # and a subclass would run the fixtures bound to the base class. @@ -217,7 +245,8 @@ def setUpClass(cls): # Run the whole class in a single subprocess and stash the outcomes # for the test methods to replay. payload, output, returncode = _run_in_subprocess(cls.__module__, - cls.__qualname__) + cls.__qualname__, + options, env, timeout) if payload is None: exc = _SubprocessTestError( f'class did not complete in a subprocess (exit code {returncode})') @@ -283,7 +312,7 @@ def _addDuration(self, result, elapsed): return cls -def runInSubprocess(): +def runInSubprocess(*, options=(), env=None, timeout=None): """Decorator to run a test method or class in a fresh subprocess. The decorated test runs in a separate, fresh Python process, so it does not @@ -293,6 +322,16 @@ def runInSubprocess(): once there; when a method is decorated, only that method runs in a subprocess. Decorated methods must take no extra arguments. + *options* is a sequence of interpreter command line options for the + subprocess, and *env* is a mapping of environment variables to set in it, + on top of the inherited environment; a value of ``None`` unsets a variable. + Note that ``-E`` and ``-I`` make the subprocess ignore the ``PYTHON*`` + variables, including ``PYTHONPATH``. + + *timeout* is the number of seconds to wait for the subprocess; the test is + reported as an error if it does not complete in time. By default there is + no timeout, and a hung test is left to the timeout of the test runner. + A failure, error or skip of the whole test is reported for the test, and individual subtests (:meth:`~unittest.TestCase.subTest`) that fail or are skipped are reported individually. The original subprocess traceback is @@ -304,6 +343,6 @@ def runInSubprocess(): """ def decorator(obj): if isinstance(obj, type) and issubclass(obj, unittest.TestCase): - return _isolate_class(obj) - return _isolate_method(obj) + return _isolate_class(obj, options, env, timeout) + return _isolate_method(obj, options, env, timeout) return decorator diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 2317077b30ac38..7c59bb38aaee9a 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -1205,6 +1205,32 @@ def test_class_subprocess_dying_after_the_tests_is_reported(self): self.assertIn('tearDownClass', str(result.errors[0][0])) self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1]) + @support.requires_subprocess() + def test_options_passed_to_subprocess(self): + result = self._run('OptionsSample') + self.assertEqual(result.testsRun, 1) + self.assertEqual(result.failures, []) + self.assertEqual(result.errors, []) + + @support.requires_subprocess() + def test_env_passed_to_subprocess(self): + # The samples check the variable, so set it here to let them tell + # env= from the inherited environment. + with os_helper.EnvironmentVarGuard() as env: + env['_PYTHON_ISOLATION_PROBE'] = 'set-by-parent' + result = self._run('EnvSample') + self.assertEqual(result.testsRun, 3) + self.assertEqual(result.failures, []) + self.assertEqual(result.errors, []) + + @support.requires_subprocess() + def test_timeout_reported_as_error(self): + from test._isolated_sample import TIMEOUT + result = self._run('TimeoutSample') + self.assertEqual(result.testsRun, 1) + self.assertEqual(len(result.errors), 1) + self.assertIn(f'within {TIMEOUT} seconds', result.errors[0][1]) + def test_skipped_without_subprocess_support(self): # On a platform without subprocess support the test is skipped in the # parent, before any subprocess is spawned.