]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-64502: Fix Argument Clinic support of optional groups with defaults (GH-155191)
authorSerhiy Storchaka <storchaka@gmail.com>
Wed, 5 Aug 2026 06:21:21 +0000 (09:21 +0300)
committerGitHub <noreply@github.com>
Wed, 5 Aug 2026 06:21:21 +0000 (09:21 +0300)
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) <noreply@anthropic.com>
Lib/test/clinic.test.c
Lib/test/test_clinic.py
Misc/NEWS.d/next/Tools-Demos/2026-08-04-17-12-33.gh-issue-64502.Qv7mLp.rst [new file with mode: 0644]
Modules/_testclinic.c
Modules/clinic/_testclinic.c.h
Tools/c-analyzer/cpython/_parser.py
Tools/clinic/libclinic/clanguage.py

index 171570588e7a2b55231c6fe0e7bfbc62da53fb2e..870e4f6956ce4fd4424b61792c3366a9cc5f77ed 100644 (file)
@@ -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
index 93c284e58764f46daea561997d6fd3a6bceef653..a9b3273ae2d7e133c86a66aa08138fc8433cebb2 100644 (file)
@@ -330,6 +330,24 @@ class ClinicWholeFileTest(TestCase):
         """
         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 @@ class ClinicFunctionalTest(unittest.TestCase):
         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 (file)
index 0000000..da9647d
--- /dev/null
@@ -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.
index 66a375589ba38e5e66bc748862fb91bb229b35a5..5742dd65f6742a7dcad0d9530692111f92e7f867 100644 (file)
@@ -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
index 05615c1fdd81b9ca9d42ff83547a419e7ab10411..19b215b0cca8899cd746432f41a04c1704400dde 100644 (file)
@@ -3477,6 +3477,96 @@ exit:
     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]*/
index 2875f45cb8d3756cb216e4b9d42b1667dbe3e846..3d755765b967097524a6423643f92fba51c22a6e 100644 (file)
@@ -318,6 +318,7 @@ MAX_SIZES = {
     _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),
index 7f02c7790f015aafab3200f294b060f0a32afd2b..a76fddb7602001e10664d9b762c3f349cf0310c2 100644 (file)
@@ -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 import (
 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 @@ if TYPE_CHECKING:
     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 @@ class CLanguage(Language):
             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 @@ class CLanguage(Language):
             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 @@ class CLanguage(Language):
 
         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("}")