Skip to content

Commit 1b45059

Browse files
gh-78416: Support required keyword-only arguments in PyArg_ParseTupleAndKeywords()
The new format unit "%" starts the keyword-only arguments which are required until "|", so that they can be mixed with optional arguments. Unlike "$", it does not depend on whether "|" was specified before it. Argument Clinic uses it if "$" cannot express the signature, so that such functions can now use the limited C API. It also no longer rejects a required keyword-only parameter after an optional positional one.
1 parent 5e0c502 commit 1b45059

14 files changed

Lines changed: 362 additions & 33 deletions

File tree

Doc/c-api/arg.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,18 +402,33 @@ inside nested parentheses. They are:
402402
For example, the format string ``"OO|OO"`` corresponds to the Python
403403
signature ``f(a, b, c=None, d=None)``.
404404

405+
:c:func:`PyArg_ParseTupleAndKeywords` only:
406+
after ``%`` it indicates that the remaining keyword-only arguments are optional.
407+
405408
``$``
406409
:c:func:`PyArg_ParseTupleAndKeywords` only:
407410
Indicates that the remaining arguments in the Python argument list are
408411
keyword-only.
409412
They are optional if ``|`` was specified before ``$``, and required otherwise.
410413
``|`` cannot be specified after ``$``.
414+
Use ``%`` to mix required and optional keyword-only arguments.
411415
For example, the format string ``"O|O$O"`` corresponds to the Python
412416
signature ``f(a, b=None, *, c=None)``,
413417
and the format string ``"OO$OO"`` corresponds to ``f(a, b, *, c, d)``.
414418

415419
.. versionadded:: 3.3
416420

421+
``%``
422+
:c:func:`PyArg_ParseTupleAndKeywords` only:
423+
Indicates that the remaining arguments in the Python argument list are
424+
keyword-only and required.
425+
They become optional after ``|``.
426+
Unlike ``$``, it does not depend on whether ``|`` was specified before it.
427+
For example, the format string ``"O|O%O|O"`` corresponds to the Python
428+
signature ``f(a, b=None, *, c, d=None)``.
429+
430+
.. versionadded:: next
431+
417432
``:``
418433
The list of format units ends here; the string after the colon is used as the
419434
function name in error messages (the "associated value" of the exception that

Doc/whatsnew/3.16.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,13 @@ C API changes
845845
New features
846846
------------
847847

848-
* TODO
848+
* :c:func:`PyArg_ParseTupleAndKeywords` now supports required keyword-only
849+
arguments mixed with optional arguments.
850+
The new format unit ``%`` starts the keyword-only arguments which are
851+
required until ``|``.
852+
For example, the format string ``"O|O%O|O"`` corresponds to the Python
853+
signature ``f(a, b=None, *, c, d=None)``.
854+
(Contributed by Serhiy Storchaka in :gh:`78416`.)
849855

850856
Porting to Python 3.16
851857
----------------------

Include/cpython/modsupport.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ typedef struct _PyArg_Parser {
3131
int pos; /* number of positional-only arguments */
3232
int min; /* minimal number of arguments */
3333
int max; /* maximal number of positional arguments */
34+
int minkw; /* index of the first optional keyword-only argument */
3435
PyObject *kwtuple; /* tuple of keyword parameter names */
3536
struct _PyArg_Parser *next;
3637
} _PyArg_Parser;

Lib/test/test_capi/test_getargs.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import string
22
import sys
33
import unittest
4+
from functools import partial
45
from test import support
56
from test.support import import_helper
67
from test.support import script_helper
@@ -825,6 +826,80 @@ def __hash__(self):
825826
getargs_keyword_only(1, 2, **{BadStr("monster"): 666})
826827

827828

829+
class RequiredKeywordOnly_TestCase(unittest.TestCase):
830+
# '%' marks the start of the keyword-only arguments, which are required
831+
# until '|', so that required and optional ones can be mixed.
832+
833+
def parse(self, format, args, kwargs, keywords=('a', 'b', 'c', 'd')):
834+
return _testcapi.parse_tuple_and_keywords(args, kwargs,
835+
format, list(keywords))
836+
837+
def test_required_after_optional(self):
838+
# f(a, b=None, *, c, d=None)
839+
parse = partial(self.parse, "O|O%O|O")
840+
self.assertEqual(parse((1, 2), {'c': 3, 'd': 4}), (1, 2, 3, 4))
841+
self.assertEqual(parse((1,), {'c': 3}), (1, None, 3, None))
842+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
843+
parse((1,), {})
844+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
845+
parse((1,), {'d': 4})
846+
with self.assertRaisesRegex(TypeError, "at most 2 positional"):
847+
parse((1, 2, 3), {'c': 3})
848+
849+
def test_all_keyword_only_required(self):
850+
# f(a, b=None, *, c, d)
851+
parse = partial(self.parse, "O|O%OO")
852+
self.assertEqual(parse((1,), {'c': 3, 'd': 4}), (1, None, 3, 4))
853+
with self.assertRaisesRegex(TypeError, "missing required argument 'd'"):
854+
parse((1,), {'c': 3})
855+
856+
def test_all_positional_required(self):
857+
# f(a, b, *, c, d=None)
858+
parse = partial(self.parse, "OO%O|O")
859+
self.assertEqual(parse((1, 2), {'c': 3}), (1, 2, 3, None))
860+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
861+
parse((1, 2), {})
862+
863+
def test_cached_parser(self):
864+
# The same format, parsed once and cached in a _PyArg_Parser.
865+
f = _testcapi.getargs_fast_required_kwonly
866+
self.assertEqual(f(1, 2, c=3, d=4), (1, 2, 3, 4))
867+
self.assertEqual(f(1, c=3), (1, None, 3, None))
868+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
869+
f(1)
870+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
871+
f(1, d=4)
872+
873+
def test_invalid_format(self):
874+
for format, msg in (
875+
("O%O%O", r"\$ specified twice"),
876+
("O%O$O", r"\$ specified twice"),
877+
("O%O|O|O", r"\| specified twice"),
878+
("O$O|O", r"\$ before \|"),
879+
):
880+
with self.subTest(format=format):
881+
n = format.count('O')
882+
npos = len(format) - len(format.lstrip('O'))
883+
args = tuple(range(npos))
884+
kwargs = {'abcd'[i]: i for i in range(npos, n)}
885+
with self.assertRaisesRegex(SystemError, msg):
886+
self.parse(format, args, kwargs, 'abcd'[:n])
887+
888+
def test_unchanged_meaning_of_dollar(self):
889+
# '$' still inherits the state of the positional arguments.
890+
parse = partial(self.parse, "O|O$O", keywords=('a', 'b', 'c'))
891+
self.assertEqual(parse((1,), {}), (1, None, None))
892+
parse = partial(self.parse, "OO$O", keywords=('a', 'b', 'c'))
893+
self.assertEqual(parse((1, 2), {'c': 3}), (1, 2, 3))
894+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
895+
parse((1, 2), {})
896+
# The same with a cached parser.
897+
f = _testcapi.getargs_fast_kwonly
898+
self.assertEqual(f(1, 2, c=3, d=4), (1, 2, 3, 4))
899+
with self.assertRaisesRegex(TypeError, "missing required argument 'c'"):
900+
f(1, 2)
901+
902+
828903
class PositionalOnlyAndKeywords_TestCase(unittest.TestCase):
829904
from _testcapi import getargs_positional_only_and_keywords as getargs
830905

@@ -1164,8 +1239,8 @@ def test_skipitem(self):
11641239

11651240
# skip parentheses, the error reporting is inconsistent about them
11661241
# skip 'e' and 'w', they're always two-character codes
1167-
# skip '|' and '$', they don't represent arguments anyway
1168-
if c in '()ew|$':
1242+
# skip '|', '$' and '%', they don't represent arguments anyway
1243+
if c in '()ew|$%':
11691244
continue
11701245

11711246
# test the format unit when not skipped

Lib/test/test_clinic.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2207,6 +2207,36 @@ def test_depr_slash_duplicate2(self):
22072207
err = "Function 'bar': '/ [from 3.14]' must precede '/ [from 3.15]'"
22082208
self.expect_failure(block, err, lineno=5)
22092209

2210+
def test_required_keyword_only_after_optional(self):
2211+
function = self.parse_function("""
2212+
module foo
2213+
foo.bar
2214+
a: int
2215+
b: int = 0
2216+
*
2217+
c: int
2218+
d: int = 0
2219+
Docstring.
2220+
""")
2221+
_, a, b, c, d = function.parameters.values()
2222+
self.assertFalse(a.converter.is_optional())
2223+
self.assertTrue(b.converter.is_optional())
2224+
self.assertFalse(c.converter.is_optional())
2225+
self.assertTrue(d.converter.is_optional())
2226+
2227+
def test_optional_before_required_keyword_only(self):
2228+
block = """
2229+
module foo
2230+
foo.bar
2231+
*
2232+
a: int = 0
2233+
b: int
2234+
Docstring.
2235+
"""
2236+
err = ("Can't have a parameter without a default ('b') "
2237+
"after a parameter with a default!")
2238+
self.expect_failure(block, err, lineno=4)
2239+
22102240
def test_single_slash(self):
22112241
block = """
22122242
module foo
@@ -4664,6 +4694,41 @@ def test_limited_capi_float(self):
46644694
self.assertIn("float f;", generated)
46654695
self.assertIn("f = (float) PyFloat_AsDouble", generated)
46664696

4697+
def test_limited_capi_required_keyword_only(self):
4698+
block = self.wrap_clinic_input("""
4699+
func
4700+
a: object
4701+
b: object = None
4702+
*
4703+
c: object
4704+
d: object = None
4705+
""")
4706+
generated = self.clinic.parse(block)
4707+
# '$' cannot express this, only '%' can.
4708+
self.assertIn('"O|O%O|O:func"', generated)
4709+
4710+
def test_limited_capi_optional_after_required_keyword_only(self):
4711+
block = self.wrap_clinic_input("""
4712+
func
4713+
a: object
4714+
*
4715+
b: object
4716+
c: object = None
4717+
""")
4718+
generated = self.clinic.parse(block)
4719+
self.assertIn('"O%O|O:func"', generated)
4720+
4721+
def test_limited_capi_keyword_only(self):
4722+
# '$' is still used if it can express the signature.
4723+
block = self.wrap_clinic_input("""
4724+
func
4725+
a: object
4726+
*
4727+
b: object
4728+
""")
4729+
generated = self.clinic.parse(block)
4730+
self.assertIn('"O$O:func"', generated)
4731+
46674732
def test_limited_capi_double(self):
46684733
block = self.wrap_clinic_input("""
46694734
func
@@ -4720,6 +4785,18 @@ def test_my_double_sum(self):
47204785
with self.assertRaises(TypeError):
47214786
func(1., "2")
47224787

4788+
def test_required_kwonly(self):
4789+
# test a required keyword-only parameter after an optional one
4790+
func = _testclinic_limited.required_kwonly
4791+
self.assertEqual(func(1, 2, c=3, d=4), (1, 2, 3, 4))
4792+
self.assertEqual(func(1, c=3), (1, None, 3, None))
4793+
with self.assertRaisesRegex(TypeError, "argument 'c'"):
4794+
func(1, 2)
4795+
with self.assertRaisesRegex(TypeError, "argument 'c'"):
4796+
func(1, 2, d=4)
4797+
with self.assertRaises(TypeError):
4798+
func(1, 2, 3)
4799+
47234800
def test_get_file_descriptor(self):
47244801
# test 'file descriptor' converter: call PyObject_AsFileDescriptor()
47254802
get_fd = _testclinic_limited.get_file_descriptor
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:c:func:`PyArg_ParseTupleAndKeywords` now supports required keyword-only arguments mixed with optional arguments.
2+
The new format unit ``%`` starts the keyword-only arguments which are required until ``|``.
3+
For example, the format string ``"O|O%O|O"`` corresponds to the Python signature ``f(a, b=None, *, c, d=None)``.

Modules/_testcapi/getargs.c

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,42 @@ gh_99240_clear_args(PyObject *self, PyObject *args)
765765
Py_RETURN_NONE;
766766
}
767767

768+
/* f(a, b=None, *, c, d=None) parsed with a cached _PyArg_Parser. */
769+
static PyObject *
770+
getargs_fast_required_kwonly(PyObject *self, PyObject *args, PyObject *kwargs)
771+
{
772+
static const char * const keywords[] = {"a", "b", "c", "d", NULL};
773+
static _PyArg_Parser parser = {
774+
.format = "O|O%O|O:getargs_fast_required_kwonly",
775+
.keywords = keywords,
776+
};
777+
PyObject *a, *b = Py_None, *c, *d = Py_None;
778+
if (!_PyArg_ParseTupleAndKeywordsFast(args, kwargs, &parser,
779+
&a, &b, &c, &d))
780+
{
781+
return NULL;
782+
}
783+
return Py_BuildValue("OOOO", a, b, c, d);
784+
}
785+
786+
/* f(a, b, *, c, d) parsed with a cached _PyArg_Parser. */
787+
static PyObject *
788+
getargs_fast_kwonly(PyObject *self, PyObject *args, PyObject *kwargs)
789+
{
790+
static const char * const keywords[] = {"a", "b", "c", "d", NULL};
791+
static _PyArg_Parser parser = {
792+
.format = "OO$OO:getargs_fast_kwonly",
793+
.keywords = keywords,
794+
};
795+
PyObject *a, *b, *c, *d;
796+
if (!_PyArg_ParseTupleAndKeywordsFast(args, kwargs, &parser,
797+
&a, &b, &c, &d))
798+
{
799+
return NULL;
800+
}
801+
return Py_BuildValue("OOOO", a, b, c, d);
802+
}
803+
768804
static PyMethodDef test_methods[] = {
769805
{"get_args", get_args, METH_VARARGS},
770806
{"get_kwargs", _PyCFunction_CAST(get_kwargs), METH_VARARGS|METH_KEYWORDS},
@@ -809,6 +845,10 @@ static PyMethodDef test_methods[] = {
809845
{"getargs_z_hash", getargs_z_hash, METH_VARARGS},
810846
{"getargs_z_star", getargs_z_star, METH_VARARGS},
811847
{"parse_tuple_and_keywords", parse_tuple_and_keywords, METH_VARARGS},
848+
{"getargs_fast_required_kwonly",
849+
_PyCFunction_CAST(getargs_fast_required_kwonly), METH_VARARGS|METH_KEYWORDS},
850+
{"getargs_fast_kwonly", _PyCFunction_CAST(getargs_fast_kwonly),
851+
METH_VARARGS|METH_KEYWORDS},
812852
{"gh_99240_clear_args", gh_99240_clear_args, METH_VARARGS},
813853
{"test_w_code_invalid", test_w_code_invalid, METH_NOARGS},
814854
{NULL},

Modules/_testclinic_limited.c

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,31 @@ static PyMethodDef tester_methods[] = {
129129
MY_FLOAT_SUM_METHODDEF
130130
MY_DOUBLE_SUM_METHODDEF
131131
GET_FILE_DESCRIPTOR_METHODDEF
132+
REQUIRED_KWONLY_METHODDEF
132133
{NULL, NULL}
133134
};
134135

136+
/*[clinic input]
137+
required_kwonly
138+
139+
a: object
140+
b: object = None
141+
*
142+
c: object
143+
d: object = None
144+
145+
Mix an optional parameter with a required keyword-only one.
146+
[clinic start generated code]*/
147+
148+
static PyObject *
149+
required_kwonly_impl(PyObject *module, PyObject *a, PyObject *b, PyObject *c,
150+
PyObject *d)
151+
/*[clinic end generated code: output=8e9a974afa614cd6 input=297ec6487f6373dd]*/
152+
{
153+
return Py_BuildValue("OOOO", a, b, c, d);
154+
}
155+
156+
135157
static struct PyModuleDef _testclinic_module = {
136158
PyModuleDef_HEAD_INIT,
137159
.m_name = "_testclinic_limited",

Modules/clinic/_testclinic_limited.c.h

Lines changed: 33 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)