]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-64490: Argument Clinic: Add support for ``**kwds`` (#138344)
authorAdam Turner <9087854+AA-Turner@users.noreply.github.com>
Thu, 18 Sep 2025 13:31:42 +0000 (14:31 +0100)
committerGitHub <noreply@github.com>
Thu, 18 Sep 2025 13:31:42 +0000 (13:31 +0000)
This adds a scaffold of support, initially only working with
strictly positional-only arguments. The FASTCALL calling
convention is not yet supported.

Lib/test/test_clinic.py
Modules/_testclinic.c
Modules/clinic/_testclinic_kwds.c.h [new file with mode: 0644]
Tools/clinic/libclinic/__init__.py
Tools/clinic/libclinic/converter.py
Tools/clinic/libclinic/converters.py
Tools/clinic/libclinic/dsl_parser.py
Tools/clinic/libclinic/function.py
Tools/clinic/libclinic/parse_args.py

index 2cc1aaea0ecbe774b383b2c988ec34c324d5d00c..d54dd546ea36fb0c4117dd448e81b2314af8d1af 100644 (file)
@@ -357,6 +357,32 @@ class ClinicWholeFileTest(TestCase):
         """
         self.expect_failure(block, err, lineno=6)
 
+    def test_double_star_after_var_keyword(self):
+        err = "Function 'my_test_func' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        block = """
+            /*[clinic input]
+            my_test_func
+
+                pos_arg: object
+                **kwds: dict
+                **
+            [clinic start generated code]*/
+        """
+        self.expect_failure(block, err, lineno=5)
+
+    def test_var_keyword_after_star(self):
+        err = "Function 'my_test_func' has an invalid parameter declaration: '**'"
+        block = """
+            /*[clinic input]
+            my_test_func
+
+                pos_arg: object
+                **
+                **kwds: dict
+            [clinic start generated code]*/
+        """
+        self.expect_failure(block, err, lineno=5)
+
     def test_module_already_got_one(self):
         err = "Already defined module 'm'!"
         block = """
@@ -748,6 +774,16 @@ class ClinicWholeFileTest(TestCase):
             """)
             self.clinic.parse(raw)
 
+    def test_var_keyword_non_dict(self):
+        err = "'var_keyword_object' is not a valid converter"
+        block = """
+            /*[clinic input]
+            my_test_func
+
+                **kwds: object
+            [clinic start generated code]*/
+        """
+        self.expect_failure(block, err, lineno=4)
 
 class ParseFileUnitTest(TestCase):
     def expect_parsing_failure(
@@ -1608,6 +1644,11 @@ class ClinicParserTest(TestCase):
                 [
                 a: object
                 ]
+        """, """
+            with_kwds
+                [
+                **kwds: dict
+                ]
         """)
         err = (
             "You cannot use optional groups ('[' and ']') unless all "
@@ -1991,6 +2032,44 @@ class ClinicParserTest(TestCase):
         err = "Function 'bar': '/' must precede '*'"
         self.expect_failure(block, err)
 
+    def test_slash_after_var_keyword(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               y: int
+               **kwds: dict
+               z: int
+               /
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
+    def test_star_after_var_keyword(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               y: int
+               **kwds: dict
+               z: int
+               *
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
+    def test_parameter_after_var_keyword(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               y: int
+               **kwds: dict
+               z: int
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
     def test_depr_star_must_come_after_slash(self):
         block = """
             module foo
@@ -2079,6 +2158,16 @@ class ClinicParserTest(TestCase):
         """
         self.expect_failure(block, err, lineno=3)
 
+    def test_parameters_no_more_than_one_var_keyword(self):
+        err = "Encountered parameter line when not expecting parameters: **var_keyword_2: dict"
+        block = """
+            module foo
+            foo.bar
+               **var_keyword_1: dict
+               **var_keyword_2: dict
+        """
+        self.expect_failure(block, err, lineno=3)
+
     def test_function_not_at_column_0(self):
         function = self.parse_function("""
               module foo
@@ -2513,6 +2602,14 @@ class ClinicParserTest(TestCase):
         """
         self.expect_failure(block, err, lineno=1)
 
+    def test_var_keyword_cannot_take_default_value(self):
+        err = "Function 'fn' has an invalid parameter declaration:"
+        block = """
+            fn
+                **kwds: dict = None
+        """
+        self.expect_failure(block, err, lineno=1)
+
     def test_default_is_not_of_correct_type(self):
         err = ("int_converter: default value 2.5 for field 'a' "
                "is not of type 'int'")
@@ -2610,6 +2707,43 @@ class ClinicParserTest(TestCase):
         """
         self.expect_failure(block, err, lineno=2)
 
+    def test_var_keyword_with_pos_or_kw(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               **kwds: dict
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
+    def test_var_keyword_with_kw_only(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               /
+               *
+               y: int
+               **kwds: dict
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
+    def test_var_keyword_with_pos_or_kw_and_kw_only(self):
+        block = """
+            module foo
+            foo.bar
+               x: int
+               /
+               y: int
+               *
+               z: int
+               **kwds: dict
+        """
+        err = "Function 'bar' has an invalid parameter declaration (**kwargs?): '**kwds: dict'"
+        self.expect_failure(block, err)
+
     def test_allow_negative_accepted_by_py_ssize_t_converter_only(self):
         errmsg = re.escape("converter_init() got an unexpected keyword argument 'allow_negative'")
         unsupported_converters = [converter_name for converter_name in converters.keys()
@@ -3954,6 +4088,49 @@ class ClinicFunctionalTest(unittest.TestCase):
         check("a", b="b", c="c", d="d", e="e", f="f", g="g")
         self.assertRaises(TypeError, fn, a="a", b="b", c="c", d="d", e="e", f="f", g="g")
 
+    def test_lone_kwds(self):
+        with self.assertRaises(TypeError):
+            ac_tester.lone_kwds(1, 2)
+        self.assertEqual(ac_tester.lone_kwds(), ({},))
+        self.assertEqual(ac_tester.lone_kwds(y='y'), ({'y': 'y'},))
+        kwds = {'y': 'y', 'z': 'z'}
+        self.assertEqual(ac_tester.lone_kwds(y='y', z='z'), (kwds,))
+        self.assertEqual(ac_tester.lone_kwds(**kwds), (kwds,))
+
+    def test_kwds_with_pos_only(self):
+        with self.assertRaises(TypeError):
+            ac_tester.kwds_with_pos_only()
+        with self.assertRaises(TypeError):
+            ac_tester.kwds_with_pos_only(y='y')
+        with self.assertRaises(TypeError):
+            ac_tester.kwds_with_pos_only(1, y='y')
+        self.assertEqual(ac_tester.kwds_with_pos_only(1, 2), (1, 2, {}))
+        self.assertEqual(ac_tester.kwds_with_pos_only(1, 2, y='y'), (1, 2, {'y': 'y'}))
+        kwds = {'y': 'y', 'z': 'z'}
+        self.assertEqual(ac_tester.kwds_with_pos_only(1, 2, y='y', z='z'), (1, 2, kwds))
+        self.assertEqual(ac_tester.kwds_with_pos_only(1, 2, **kwds), (1, 2, kwds))
+
+    def test_kwds_with_stararg(self):
+        self.assertEqual(ac_tester.kwds_with_stararg(), ((), {}))
+        self.assertEqual(ac_tester.kwds_with_stararg(1, 2), ((1, 2), {}))
+        self.assertEqual(ac_tester.kwds_with_stararg(y='y'), ((), {'y': 'y'}))
+        args = (1, 2)
+        kwds = {'y': 'y', 'z': 'z'}
+        self.assertEqual(ac_tester.kwds_with_stararg(1, 2, y='y', z='z'), (args, kwds))
+        self.assertEqual(ac_tester.kwds_with_stararg(*args, **kwds), (args, kwds))
+
+    def test_kwds_with_pos_only_and_stararg(self):
+        with self.assertRaises(TypeError):
+            ac_tester.kwds_with_pos_only_and_stararg()
+        with self.assertRaises(TypeError):
+            ac_tester.kwds_with_pos_only_and_stararg(y='y')
+        self.assertEqual(ac_tester.kwds_with_pos_only_and_stararg(1, 2), (1, 2, (), {}))
+        self.assertEqual(ac_tester.kwds_with_pos_only_and_stararg(1, 2, y='y'), (1, 2, (), {'y': 'y'}))
+        args = ('lobster', 'thermidor')
+        kwds = {'y': 'y', 'z': 'z'}
+        self.assertEqual(ac_tester.kwds_with_pos_only_and_stararg(1, 2, 'lobster', 'thermidor', y='y', z='z'), (1, 2, args, kwds))
+        self.assertEqual(ac_tester.kwds_with_pos_only_and_stararg(1, 2, *args, **kwds), (1, 2, args, kwds))
+
 
 class LimitedCAPIOutputTests(unittest.TestCase):
 
index 69adf7d1a0a95066d7b739c77bf7e4acc28c79ed..5c196c0dd0fb01c44df919614fab8aaebb6a0db0 100644 (file)
@@ -2308,6 +2308,88 @@ depr_multi_impl(PyObject *module, PyObject *a, PyObject *b, PyObject *c,
 #undef _SAVED_PY_VERSION
 
 
+/*[clinic input]
+output pop
+[clinic start generated code]*/
+/*[clinic end generated code: output=da39a3ee5e6b4b0d input=e7c7c42daced52b0]*/
+
+
+/*[clinic input]
+output push
+destination kwarg new file '{dirname}/clinic/_testclinic_kwds.c.h'
+output everything kwarg
+output docstring_prototype suppress
+output parser_prototype suppress
+output impl_definition block
+[clinic start generated code]*/
+/*[clinic end generated code: output=da39a3ee5e6b4b0d input=02965b54b3981cc4]*/
+
+#include "clinic/_testclinic_kwds.c.h"
+
+
+/*[clinic input]
+lone_kwds
+    **kwds: dict
+[clinic start generated code]*/
+
+static PyObject *
+lone_kwds_impl(PyObject *module, PyObject *kwds)
+/*[clinic end generated code: output=572549c687a0432e input=6ef338b913ecae17]*/
+{
+    return pack_arguments_newref(1, kwds);
+}
+
+
+/*[clinic input]
+kwds_with_pos_only
+    a: object
+    b: object
+    /
+    **kwds: dict
+[clinic start generated code]*/
+
+static PyObject *
+kwds_with_pos_only_impl(PyObject *module, PyObject *a, PyObject *b,
+                        PyObject *kwds)
+/*[clinic end generated code: output=573096d3a7efcce5 input=da081a5d9ae8878a]*/
+{
+    return pack_arguments_newref(3, a, b, kwds);
+}
+
+
+/*[clinic input]
+kwds_with_stararg
+    *args: tuple
+    **kwds: dict
+[clinic start generated code]*/
+
+static PyObject *
+kwds_with_stararg_impl(PyObject *module, PyObject *args, PyObject *kwds)
+/*[clinic end generated code: output=d4b0064626a25208 input=1be404572d685859]*/
+{
+    return pack_arguments_newref(2, args, kwds);
+}
+
+
+/*[clinic input]
+kwds_with_pos_only_and_stararg
+    a: object
+    b: object
+    /
+    *args: tuple
+    **kwds: dict
+[clinic start generated code]*/
+
+static PyObject *
+kwds_with_pos_only_and_stararg_impl(PyObject *module, PyObject *a,
+                                    PyObject *b, PyObject *args,
+                                    PyObject *kwds)
+/*[clinic end generated code: output=af7df7640c792246 input=2fe330c7981f0829]*/
+{
+    return pack_arguments_newref(4, a, b, args, kwds);
+}
+
+
 /*[clinic input]
 output pop
 [clinic start generated code]*/
@@ -2404,6 +2486,12 @@ static PyMethodDef tester_methods[] = {
     DEPR_KWD_NOINLINE_METHODDEF
     DEPR_KWD_MULTI_METHODDEF
     DEPR_MULTI_METHODDEF
+
+    LONE_KWDS_METHODDEF
+    KWDS_WITH_POS_ONLY_METHODDEF
+    KWDS_WITH_STARARG_METHODDEF
+    KWDS_WITH_POS_ONLY_AND_STARARG_METHODDEF
+
     {NULL, NULL}
 };
 
diff --git a/Modules/clinic/_testclinic_kwds.c.h b/Modules/clinic/_testclinic_kwds.c.h
new file mode 100644 (file)
index 0000000..e2fd4d9
--- /dev/null
@@ -0,0 +1,184 @@
+/*[clinic input]
+preserve
+[clinic start generated code]*/
+
+#if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
+#  include "pycore_gc.h"          // PyGC_Head
+#endif
+#include "pycore_abstract.h"      // _PyNumber_Index()
+#include "pycore_long.h"          // _PyLong_UnsignedShort_Converter()
+#include "pycore_modsupport.h"    // _PyArg_CheckPositional()
+#include "pycore_runtime.h"       // _Py_ID()
+#include "pycore_tuple.h"         // _PyTuple_FromArray()
+
+PyDoc_STRVAR(lone_kwds__doc__,
+"lone_kwds($module, /, **kwds)\n"
+"--\n"
+"\n");
+
+#define LONE_KWDS_METHODDEF    \
+    {"lone_kwds", _PyCFunction_CAST(lone_kwds), METH_VARARGS|METH_KEYWORDS, lone_kwds__doc__},
+
+static PyObject *
+lone_kwds_impl(PyObject *module, PyObject *kwds);
+
+static PyObject *
+lone_kwds(PyObject *module, PyObject *args, PyObject *kwargs)
+{
+    PyObject *return_value = NULL;
+    PyObject *__clinic_kwds = NULL;
+
+    if (!_PyArg_NoPositional("lone_kwds", args)) {
+        goto exit;
+    }
+    if (kwargs == NULL) {
+        __clinic_kwds = PyDict_New();
+        if (__clinic_kwds == NULL) {
+            goto exit;
+        }
+    }
+    else {
+        __clinic_kwds = Py_NewRef(kwargs);
+    }
+    return_value = lone_kwds_impl(module, __clinic_kwds);
+
+exit:
+    /* Cleanup for kwds */
+    Py_XDECREF(__clinic_kwds);
+
+    return return_value;
+}
+
+PyDoc_STRVAR(kwds_with_pos_only__doc__,
+"kwds_with_pos_only($module, a, b, /, **kwds)\n"
+"--\n"
+"\n");
+
+#define KWDS_WITH_POS_ONLY_METHODDEF    \
+    {"kwds_with_pos_only", _PyCFunction_CAST(kwds_with_pos_only), METH_VARARGS|METH_KEYWORDS, kwds_with_pos_only__doc__},
+
+static PyObject *
+kwds_with_pos_only_impl(PyObject *module, PyObject *a, PyObject *b,
+                        PyObject *kwds);
+
+static PyObject *
+kwds_with_pos_only(PyObject *module, PyObject *args, PyObject *kwargs)
+{
+    PyObject *return_value = NULL;
+    PyObject *a;
+    PyObject *b;
+    PyObject *__clinic_kwds = NULL;
+
+    if (!_PyArg_CheckPositional("kwds_with_pos_only", PyTuple_GET_SIZE(args), 2, 2)) {
+        goto exit;
+    }
+    a = PyTuple_GET_ITEM(args, 0);
+    b = PyTuple_GET_ITEM(args, 1);
+    if (kwargs == NULL) {
+        __clinic_kwds = PyDict_New();
+        if (__clinic_kwds == NULL) {
+            goto exit;
+        }
+    }
+    else {
+        __clinic_kwds = Py_NewRef(kwargs);
+    }
+    return_value = kwds_with_pos_only_impl(module, a, b, __clinic_kwds);
+
+exit:
+    /* Cleanup for kwds */
+    Py_XDECREF(__clinic_kwds);
+
+    return return_value;
+}
+
+PyDoc_STRVAR(kwds_with_stararg__doc__,
+"kwds_with_stararg($module, /, *args, **kwds)\n"
+"--\n"
+"\n");
+
+#define KWDS_WITH_STARARG_METHODDEF    \
+    {"kwds_with_stararg", _PyCFunction_CAST(kwds_with_stararg), METH_VARARGS|METH_KEYWORDS, kwds_with_stararg__doc__},
+
+static PyObject *
+kwds_with_stararg_impl(PyObject *module, PyObject *args, PyObject *kwds);
+
+static PyObject *
+kwds_with_stararg(PyObject *module, PyObject *args, PyObject *kwargs)
+{
+    PyObject *return_value = NULL;
+    PyObject *__clinic_args = NULL;
+    PyObject *__clinic_kwds = NULL;
+
+    __clinic_args = Py_NewRef(args);
+    if (kwargs == NULL) {
+        __clinic_kwds = PyDict_New();
+        if (__clinic_kwds == NULL) {
+            goto exit;
+        }
+    }
+    else {
+        __clinic_kwds = Py_NewRef(kwargs);
+    }
+    return_value = kwds_with_stararg_impl(module, __clinic_args, __clinic_kwds);
+
+exit:
+    /* Cleanup for args */
+    Py_XDECREF(__clinic_args);
+    /* Cleanup for kwds */
+    Py_XDECREF(__clinic_kwds);
+
+    return return_value;
+}
+
+PyDoc_STRVAR(kwds_with_pos_only_and_stararg__doc__,
+"kwds_with_pos_only_and_stararg($module, a, b, /, *args, **kwds)\n"
+"--\n"
+"\n");
+
+#define KWDS_WITH_POS_ONLY_AND_STARARG_METHODDEF    \
+    {"kwds_with_pos_only_and_stararg", _PyCFunction_CAST(kwds_with_pos_only_and_stararg), METH_VARARGS|METH_KEYWORDS, kwds_with_pos_only_and_stararg__doc__},
+
+static PyObject *
+kwds_with_pos_only_and_stararg_impl(PyObject *module, PyObject *a,
+                                    PyObject *b, PyObject *args,
+                                    PyObject *kwds);
+
+static PyObject *
+kwds_with_pos_only_and_stararg(PyObject *module, PyObject *args, PyObject *kwargs)
+{
+    PyObject *return_value = NULL;
+    PyObject *a;
+    PyObject *b;
+    PyObject *__clinic_args = NULL;
+    PyObject *__clinic_kwds = NULL;
+
+    if (!_PyArg_CheckPositional("kwds_with_pos_only_and_stararg", PyTuple_GET_SIZE(args), 2, PY_SSIZE_T_MAX)) {
+        goto exit;
+    }
+    a = PyTuple_GET_ITEM(args, 0);
+    b = PyTuple_GET_ITEM(args, 1);
+    __clinic_args = PyTuple_GetSlice(args, 2, PY_SSIZE_T_MAX);
+    if (!__clinic_args) {
+        goto exit;
+    }
+    if (kwargs == NULL) {
+        __clinic_kwds = PyDict_New();
+        if (__clinic_kwds == NULL) {
+            goto exit;
+        }
+    }
+    else {
+        __clinic_kwds = Py_NewRef(kwargs);
+    }
+    return_value = kwds_with_pos_only_and_stararg_impl(module, a, b, __clinic_args, __clinic_kwds);
+
+exit:
+    /* Cleanup for args */
+    Py_XDECREF(__clinic_args);
+    /* Cleanup for kwds */
+    Py_XDECREF(__clinic_kwds);
+
+    return return_value;
+}
+/*[clinic end generated code: output=e4dea1070e003f5d input=a9049054013a1b77]*/
index 7c5cede23966777edeed90f511db2e78492002d2..9e9bdeadcc0fe1299704cc0b04138ef99a064031 100644 (file)
@@ -84,6 +84,7 @@ CLINIC_PREFIXED_ARGS: Final = frozenset(
         "argsbuf",
         "fastargs",
         "kwargs",
+        "kwds",
         "kwnames",
         "nargs",
         "noptargs",
index 2c93dda35410308344456873e66e45e2ce228f9e..ac66e79f93b73543e037d1b5d3e11509495cf947 100644 (file)
@@ -274,7 +274,7 @@ class CConverter(metaclass=CConverterAutoRegister):
             data.modifications.append('/* modifications for ' + name + ' */\n' + modifications.rstrip())
 
         # keywords
-        if parameter.is_vararg():
+        if parameter.is_variable_length():
             pass
         elif parameter.is_positional_only():
             data.keywords.append('')
index d9f93b93d7587568cd82c612f77e417525d5375a..3154299e31b4dce23389b330df375ad58071c77f 100644 (file)
@@ -1300,3 +1300,37 @@ class varpos_array_converter(VarPosCConverter):
             {paramname} = {start};
             {self.length_name} = {size};
             """
+
+
+# Converters for var-keyword parameters.
+
+class VarKeywordCConverter(CConverter):
+    format_unit = ''
+
+    def parse_arg(self, argname: str, displayname: str, *, limited_capi: bool) -> str | None:
+        raise AssertionError('should never be called')
+
+    def parse_var_keyword(self) -> str:
+        raise NotImplementedError
+
+
+class var_keyword_dict_converter(VarKeywordCConverter):
+    type = 'PyObject *'
+    c_default = 'NULL'
+
+    def cleanup(self) -> str:
+        return f'Py_XDECREF({self.parser_name});\n'
+
+    def parse_var_keyword(self) -> str:
+        param_name = self.parser_name
+        return f"""
+            if (kwargs == NULL) {{{{
+                {param_name} = PyDict_New();
+                if ({param_name} == NULL) {{{{
+                    goto exit;
+                }}}}
+            }}}}
+            else {{{{
+                {param_name} = Py_NewRef(kwargs);
+            }}}}
+            """
index f9587d20383c7aa71787c78f2c2f978f5a87ebfe..0d83baeba9e50802d45aecd7977779a7c9c221a7 100644 (file)
@@ -246,6 +246,7 @@ class IndentStack:
 class DSLParser:
     function: Function | None
     state: StateKeeper
+    expecting_parameters: bool
     keyword_only: bool
     positional_only: bool
     deprecated_positional: VersionTuple | None
@@ -285,6 +286,7 @@ class DSLParser:
     def reset(self) -> None:
         self.function = None
         self.state = self.state_dsl_start
+        self.expecting_parameters = True
         self.keyword_only = False
         self.positional_only = False
         self.deprecated_positional = None
@@ -876,6 +878,10 @@ class DSLParser:
     def parse_parameter(self, line: str) -> None:
         assert self.function is not None
 
+        if not self.expecting_parameters:
+            fail('Encountered parameter line when not expecting '
+                 f'parameters: {line}')
+
         match self.parameter_state:
             case ParamState.START | ParamState.REQUIRED:
                 self.to_required()
@@ -909,27 +915,40 @@ class DSLParser:
         if len(function_args.args) > 1:
             fail(f"Function {self.function.name!r} has an "
                  f"invalid parameter declaration (comma?): {line!r}")
-        if function_args.kwarg:
-            fail(f"Function {self.function.name!r} has an "
-                 f"invalid parameter declaration (**kwargs?): {line!r}")
 
+        is_vararg = is_var_keyword = False
         if function_args.vararg:
             self.check_previous_star()
             self.check_remaining_star()
             is_vararg = True
             parameter = function_args.vararg
+        elif function_args.kwarg:
+            # If the existing parameters are all positional only or ``*args``
+            # (var-positional), then we allow ``**kwds`` (var-keyword).
+            # Currently, pos-or-keyword or keyword-only arguments are not
+            # allowed with the ``**kwds`` converter.
+            has_non_positional_param = any(
+                p.is_positional_or_keyword() or p.is_keyword_only()
+                for p in self.function.parameters.values()
+            )
+            if has_non_positional_param:
+                fail(f"Function {self.function.name!r} has an "
+                     f"invalid parameter declaration (**kwargs?): {line!r}")
+            is_var_keyword = True
+            parameter = function_args.kwarg
         else:
-            is_vararg = False
             parameter = function_args.args[0]
 
         parameter_name = parameter.arg
         name, legacy, kwargs = self.parse_converter(parameter.annotation)
         if is_vararg:
-            name = 'varpos_' + name
+            name = f'varpos_{name}'
+        elif is_var_keyword:
+            name = f'var_keyword_{name}'
 
         value: object
         if not function_args.defaults:
-            if is_vararg:
+            if is_vararg or is_var_keyword:
                 value = NULL
             else:
                 if self.parameter_state is ParamState.OPTIONAL:
@@ -1065,6 +1084,8 @@ class DSLParser:
         kind: inspect._ParameterKind
         if is_vararg:
             kind = inspect.Parameter.VAR_POSITIONAL
+        elif is_var_keyword:
+            kind = inspect.Parameter.VAR_KEYWORD
         elif self.keyword_only:
             kind = inspect.Parameter.KEYWORD_ONLY
         else:
@@ -1118,6 +1139,8 @@ class DSLParser:
 
         if is_vararg:
             self.keyword_only = True
+        if is_var_keyword:
+            self.expecting_parameters = False
 
     @staticmethod
     def parse_converter(
@@ -1159,6 +1182,9 @@ class DSLParser:
         The 'version' parameter signifies the future version from which
         the marker will take effect (None means it is already in effect).
         """
+        if not self.expecting_parameters:
+            fail("Encountered '*' when not expecting parameters")
+
         if version is None:
             self.check_previous_star()
             self.check_remaining_star()
@@ -1214,6 +1240,9 @@ class DSLParser:
         The 'version' parameter signifies the future version from which
         the marker will take effect (None means it is already in effect).
         """
+        if not self.expecting_parameters:
+            fail("Encountered '/' when not expecting parameters")
+
         if version is None:
             if self.deprecated_keyword:
                 fail(f"Function {function.name!r}: '/' must precede '/ [from ...]'")
@@ -1450,11 +1479,13 @@ class DSLParser:
                 if p.is_vararg():
                     p_lines.append("*")
                     added_star = True
+                if p.is_var_keyword():
+                    p_lines.append("**")
 
                 name = p.converter.signature_name or p.name
                 p_lines.append(name)
 
-                if not p.is_vararg() and p.converter.is_optional():
+                if not p.is_variable_length() and p.converter.is_optional():
                     p_lines.append('=')
                     value = p.converter.py_default
                     if not value:
@@ -1583,8 +1614,11 @@ class DSLParser:
 
         for p in reversed(self.function.parameters.values()):
             if self.keyword_only:
-                if (p.kind == inspect.Parameter.KEYWORD_ONLY or
-                    p.kind == inspect.Parameter.VAR_POSITIONAL):
+                if p.kind in {
+                    inspect.Parameter.KEYWORD_ONLY,
+                    inspect.Parameter.VAR_POSITIONAL,
+                    inspect.Parameter.VAR_KEYWORD
+                }:
                     return
             elif self.deprecated_positional:
                 if p.deprecated_positional == self.deprecated_positional:
index 4280af0c4c9b4950f257d90eb57ea7c2b1030bb5..f981f0bcaf89f0f1b520cc4ef87ca37d9af4f6f3 100644 (file)
@@ -220,9 +220,18 @@ class Parameter:
     def is_positional_only(self) -> bool:
         return self.kind == inspect.Parameter.POSITIONAL_ONLY
 
+    def is_positional_or_keyword(self) -> bool:
+        return self.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
+
     def is_vararg(self) -> bool:
         return self.kind == inspect.Parameter.VAR_POSITIONAL
 
+    def is_var_keyword(self) -> bool:
+        return self.kind == inspect.Parameter.VAR_KEYWORD
+
+    def is_variable_length(self) -> bool:
+        return self.is_vararg() or self.is_var_keyword()
+
     def is_optional(self) -> bool:
         return not self.is_vararg() and (self.default is not unspecified)
 
index 0e15d2f163b81617934210dd11d5d56f3d2bd322..bca87ecd75100cefe40f8d761139d0be6da4eda6 100644 (file)
@@ -36,7 +36,7 @@ def declare_parser(
 
     num_keywords = len([
         p for p in f.parameters.values()
-        if not p.is_positional_only() and not p.is_vararg()
+        if p.is_positional_or_keyword() or p.is_keyword_only()
     ])
 
     condition = '#if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)'
@@ -220,6 +220,7 @@ class ParseArgsCodeGen:
     max_pos: int = 0
     min_kw_only: int = 0
     varpos: Parameter | None = None
+    var_keyword: Parameter | None = None
 
     docstring_prototype: str
     docstring_definition: str
@@ -255,13 +256,24 @@ class ParseArgsCodeGen:
                 del self.parameters[i]
                 break
 
+        for i, p in enumerate(self.parameters):
+            if p.is_var_keyword():
+                self.var_keyword = p
+                del self.parameters[i]
+                break
+
         self.converters = [p.converter for p in self.parameters]
 
         if self.func.critical_section:
             self.codegen.add_include('pycore_critical_section.h',
                                      'Py_BEGIN_CRITICAL_SECTION()')
+
+        # Use fastcall if not disabled, except if in a __new__ or
+        # __init__ method, or if there is a **kwargs parameter.
         if self.func.disable_fastcall:
             self.fastcall = False
+        elif self.var_keyword is not None:
+            self.fastcall = False
         else:
             self.fastcall = not self.is_new_or_init()
 
@@ -469,6 +481,12 @@ class ParseArgsCodeGen:
                               fastcall=self.fastcall,
                               limited_capi=self.limited_capi)
 
+    def _parse_kwarg(self) -> str:
+        assert self.var_keyword is not None
+        c = self.var_keyword.converter
+        assert isinstance(c, libclinic.converters.VarKeywordCConverter)
+        return c.parse_var_keyword()
+
     def parse_pos_only(self) -> None:
         if self.fastcall:
             # positional-only, but no option groups
@@ -564,6 +582,8 @@ class ParseArgsCodeGen:
                 parser_code.append("skip_optional:")
             if self.varpos:
                 parser_code.append(libclinic.normalize_snippet(self._parse_vararg(), indent=4))
+            elif self.var_keyword:
+                parser_code.append(libclinic.normalize_snippet(self._parse_kwarg(), indent=4))
         else:
             for parameter in self.parameters:
                 parameter.converter.use_converter()
@@ -590,6 +610,45 @@ class ParseArgsCodeGen:
                     """, indent=4)]
         self.parser_body(*parser_code)
 
+    def parse_var_keyword(self) -> None:
+        self.flags = "METH_VARARGS|METH_KEYWORDS"
+        self.parser_prototype = PARSER_PROTOTYPE_KEYWORD
+        nargs = 'PyTuple_GET_SIZE(args)'
+
+        parser_code = []
+        max_args = NO_VARARG if self.varpos else self.max_pos
+        if self.varpos is None and self.min_pos == self.max_pos == 0:
+            self.codegen.add_include('pycore_modsupport.h',
+                                     '_PyArg_NoPositional()')
+            parser_code.append(libclinic.normalize_snippet("""
+                if (!_PyArg_NoPositional("{name}", args)) {{
+                    goto exit;
+                }}
+                """, indent=4))
+        elif self.min_pos or max_args != NO_VARARG:
+            self.codegen.add_include('pycore_modsupport.h',
+                                     '_PyArg_CheckPositional()')
+            parser_code.append(libclinic.normalize_snippet(f"""
+                if (!_PyArg_CheckPositional("{{name}}", {nargs}, {self.min_pos}, {max_args})) {{{{
+                    goto exit;
+                }}}}
+                """, indent=4))
+
+        for i, p in enumerate(self.parameters):
+            parse_arg = p.converter.parse_arg(
+                f'PyTuple_GET_ITEM(args, {i})',
+                p.get_displayname(i+1),
+                limited_capi=self.limited_capi,
+            )
+            assert parse_arg is not None
+            parser_code.append(libclinic.normalize_snippet(parse_arg, indent=4))
+
+        if self.varpos:
+            parser_code.append(libclinic.normalize_snippet(self._parse_vararg(), indent=4))
+        if self.var_keyword:
+            parser_code.append(libclinic.normalize_snippet(self._parse_kwarg(), indent=4))
+        self.parser_body(*parser_code)
+
     def parse_general(self, clang: CLanguage) -> None:
         parsearg: str | None
         deprecated_positionals: dict[int, Parameter] = {}
@@ -921,12 +980,14 @@ class ParseArgsCodeGen:
         # previous call to parser_body. this is used for an awful hack.
         self.parser_body_fields: tuple[str, ...] = ()
 
-        if not self.parameters and not self.varpos:
+        if not self.parameters and not self.varpos and not self.var_keyword:
             self.parse_no_args()
         elif self.use_meth_o():
             self.parse_one_arg()
         elif self.has_option_groups():
             self.parse_option_groups()
+        elif self.var_keyword is not None:
+            self.parse_var_keyword()
         elif (not self.requires_defining_class
               and self.pos_only == len(self.parameters)):
             self.parse_pos_only()