From fd31f65534905269dc0e10889089e0ff4962b944 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 17:50:25 +0300 Subject: [PATCH 1/2] gh-108271, gh-108270: Argument Clinic: parameter aliases and deprecation A parameter can be given an alternative name by declaring a keyword-only parameter with a default value which shares the C name of a preceding one: a: object = None * b as a: object = None Only one of the alternative names can be used in a call; passing both is a TypeError. An alias is not shown in the signature. The `[until X.Y]` prefix marks a parameter which will be removed in that release. Passing it emits a DeprecationWarning, and the generated code warns at compile time when that release is reached. A deprecated parameter must have a default value, and only the last positional-only parameters can be deprecated, because removing one would leave no way to pass those which follow it. --- Lib/test/test_clinic.py | 187 ++++++++++++++++ ...-08-05-18-16-23.gh-issue-108271.Yvq2Tv.rst | 3 + ...-08-05-18-16-24.gh-issue-108270.kVDJSS.rst | 3 + Modules/_testclinic.c | 73 ++++++ Modules/clinic/_testclinic.c.h | 154 ++++++++++++- Modules/clinic/_testclinic_depr.c.h | 208 +++++++++++++++++- Tools/c-analyzer/cpython/_parser.py | 2 +- Tools/clinic/libclinic/clanguage.py | 3 +- Tools/clinic/libclinic/converter.py | 14 ++ Tools/clinic/libclinic/dsl_parser.py | 57 ++++- Tools/clinic/libclinic/function.py | 2 + Tools/clinic/libclinic/parse_args.py | 83 ++++++- 12 files changed, 775 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-23.gh-issue-108271.Yvq2Tv.rst create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-24.gh-issue-108270.kVDJSS.rst diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index cb4507dcac2336d..2f33b020b206996 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -2207,6 +2207,128 @@ def test_depr_slash_duplicate2(self): err = "Function 'bar': '/ [from 3.14]' must precede '/ [from 3.15]'" self.expect_failure(block, err, lineno=5) + def test_alias(self): + function = self.parse_function(""" + module foo + foo.bar + a: int + * + b as a: int = 0 + Docstring. + """) + _, a, b = function.parameters.values() + self.assertIsNone(a.converter.alias_of) + self.assertIs(b.converter.alias_of, a) + self.assertEqual(function.docstring.splitlines()[0], + "bar($module, /, a)") + + def test_alias_must_be_keyword_only(self): + block = """ + module foo + foo.bar + a: int + b as a: int = 0 + Docstring. + """ + err = "Alias 'b' of the parameter 'a' must be keyword-only." + self.expect_failure(block, err, lineno=3) + + def test_alias_must_have_default(self): + block = """ + module foo + foo.bar + a: int + * + b as a: int + Docstring. + """ + err = "Alias 'b' of the parameter 'a' must have a default value." + self.expect_failure(block, err, lineno=4) + + def test_alias_deprecated(self): + function = self.parse_function(""" + module foo + foo.bar + a: int + * + [until 3.14] b as a: int = 0 + Docstring. + """) + _, a, b = function.parameters.values() + self.assertIsNone(a.deprecated_until) + self.assertEqual(b.deprecated_until, (3, 14)) + + def test_deprecated_last_positional_only_parameters(self): + function = self.parse_function(""" + module foo + foo.bar + a: int = 0 + [until 3.14] b: int = 0 + [until 3.14] c: int = 0 + / + d: int = 0 + Docstring. + """) + _, a, b, c, d = function.parameters.values() + self.assertIsNone(a.deprecated_until) + self.assertEqual(b.deprecated_until, (3, 14)) + self.assertEqual(c.deprecated_until, (3, 14)) + self.assertIsNone(d.deprecated_until) + + def test_deprecated_non_last_positional_only_parameter(self): + block = """ + module foo + foo.bar + [until 3.14] a: int = 0 + b: int = 0 + / + Docstring. + """ + err = ("Parameter 'b' cannot follow the deprecated parameter 'a': " + "only the last positional-only parameters can be deprecated.") + self.expect_failure(block, err, lineno=4) + + def test_deprecated_non_positional_only_parameters(self): + # The following parameters can still be passed by keyword. + function = self.parse_function(""" + module foo + foo.bar + [until 3.14] a: int = 0 + b: int = 0 + * + [until 3.14] c: int = 0 + d: int = 0 + Docstring. + """) + _, a, b, c, d = function.parameters.values() + self.assertEqual(a.deprecated_until, (3, 14)) + self.assertIsNone(b.deprecated_until) + self.assertEqual(c.deprecated_until, (3, 14)) + self.assertIsNone(d.deprecated_until) + + def test_deprecated_parameter_without_default(self): + block = """ + module foo + foo.bar + [until 3.14] a: int + Docstring. + """ + err = "Deprecated parameter 'a' must have a default value." + self.expect_failure(block, err, lineno=2) + + def test_deprecated_invalid_format(self): + block = """ + module foo + foo.bar + [until 3] a: int = 0 + Docstring. + """ + err = ( + "Function 'bar': expected format '[until major.minor]' " + "where 'major' and 'minor' are integers; got '3'" + ) + self.expect_failure(block, err, lineno=2) + def test_single_slash(self): block = """ module foo @@ -4584,6 +4706,51 @@ def test_depr_multi(self): 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_alias_pos(self): + fn = ac_tester.alias_pos + self.assertIsNone(fn()) + self.assertEqual(fn(1), 1) + self.assertEqual(fn(a=1), 1) + self.assertEqual(fn(b=1), 1) + self.assertEqual(fn.__text_signature__, "($module, /, a=None)") + errmsg = re.escape( + "argument for alias_pos() given by name ('b') and position (1)") + self.assertRaisesRegex(TypeError, errmsg, fn, 1, b=2) + errmsg = re.escape( + "argument for alias_pos() given by name ('b') and name ('a')") + self.assertRaisesRegex(TypeError, errmsg, fn, a=1, b=2) + + def test_alias_kwonly(self): + fn = ac_tester.alias_kwonly + self.assertIsNone(fn()) + self.assertEqual(fn(a=1), 1) + self.assertEqual(fn(b=1), 1) + self.assertEqual(fn.__text_signature__, "($module, /, *, a=None)") + self.assertRaises(TypeError, fn, 1) + errmsg = re.escape( + "argument for alias_kwonly() given by name ('b') and name ('a')") + self.assertRaisesRegex(TypeError, errmsg, fn, a=1, b=2) + + def test_depr_alias(self): + fn = ac_tester.depr_alias + self.assertEqual(fn(1), 1) + self.assertEqual(fn(a=1), 1) + errmsg = ("Passing the argument 'b' to depr_alias() is deprecated. " + "Use 'a' instead. It will be removed in Python 3.14.") + self.check_depr(re.escape(errmsg), fn, b=1) + + def test_depr_param(self): + fn = ac_tester.depr_param + self.assertEqual(fn(), (None, None, None, None)) + self.assertEqual(fn(1), (1, None, None, None)) + def errmsg(name): + return re.escape(f"Passing the argument {name!r} to depr_param() " + f"is deprecated. " + f"It will be removed in Python 3.14.") + self.check_depr(errmsg('b'), fn, 1, 2) + self.check_depr(errmsg('c'), fn, 1, 2, 3) + self.check_depr(errmsg('d'), fn, 1, d=4) + def test_lone_kwds(self): with self.assertRaises(TypeError): ac_tester.lone_kwds(1, 2) @@ -4667,6 +4834,26 @@ def test_limited_capi_double(self): self.assertIn("double f;", generated) self.assertIn("f = PyFloat_AsDouble", generated) + def test_limited_capi_alias(self): + block = self.wrap_clinic_input(""" + func + a: object = None + * + b as a: object = None + """) + err = ("Parameter 'b' cannot be an alias: " + "the arguments are not parsed one by one.") + _expect_failure(self, self.clinic.parse, block, err) + + def test_limited_capi_deprecated(self): + block = self.wrap_clinic_input(""" + func + [until 3.14] a: object = None + """) + err = ("Parameter 'a' cannot be deprecated: " + "the arguments are not parsed one by one.") + _expect_failure(self, self.clinic.parse, block, err) + try: import _testclinic_limited diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-23.gh-issue-108271.Yvq2Tv.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-23.gh-issue-108271.Yvq2Tv.rst new file mode 100644 index 000000000000000..3e9d07600223b91 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-23.gh-issue-108271.Yvq2Tv.rst @@ -0,0 +1,3 @@ +Argument Clinic: add support for parameter aliases. +A keyword-only parameter with a default value which shares the C name of a +preceding parameter declares an alternative name for it. diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-24.gh-issue-108270.kVDJSS.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-24.gh-issue-108270.kVDJSS.rst new file mode 100644 index 000000000000000..07ba709e3898a3e --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-05-18-16-24.gh-issue-108270.kVDJSS.rst @@ -0,0 +1,3 @@ +Argument Clinic: add support for deprecating a parameter with the ``[until +X.Y]`` marker. +Passing such argument emits a :exc:`DeprecationWarning`. diff --git a/Modules/_testclinic.c b/Modules/_testclinic.c index c53bf4a08753586..47dcf79cb7b5969 100644 --- a/Modules/_testclinic.c +++ b/Modules/_testclinic.c @@ -1499,6 +1499,40 @@ clone_with_conv_f2_impl(PyObject *module, custom_t path) } +/*[clinic input] +alias_pos + + a: object = None + * + b as a: object = None + +[clinic start generated code]*/ + +static PyObject * +alias_pos_impl(PyObject *module, PyObject *a) +/*[clinic end generated code: output=f6cd3c7f098a894d input=8018ee6c26e3f435]*/ +{ + return Py_NewRef(a); +} + + +/*[clinic input] +alias_kwonly + + * + a: object = None + b as a: object = None + +[clinic start generated code]*/ + +static PyObject * +alias_kwonly_impl(PyObject *module, PyObject *a) +/*[clinic end generated code: output=9a6d4202ba972f46 input=8ad2d6c0f326571d]*/ +{ + return Py_NewRef(a); +} + + /*[clinic input] class _testclinic.TestClass "PyObject *" "&PyBaseObject_Type" [clinic start generated code]*/ @@ -2375,6 +2409,40 @@ depr_kwd_multi_impl(PyObject *module, PyObject *a, PyObject *b, PyObject *c, } +/*[clinic input] +depr_alias + a: object = None + * + [until 3.14] b as a: object = None +[clinic start generated code]*/ + +static PyObject * +depr_alias_impl(PyObject *module, PyObject *a) +/*[clinic end generated code: output=85e89838716d9423 input=92efd3f244c2ec3f]*/ +{ + return Py_NewRef(a); +} + + +/*[clinic input] +depr_param + a: object = None + [until 3.14] b: object = None + [until 3.14] c: object = None + / + * + [until 3.14] d: object = None +[clinic start generated code]*/ + +static PyObject * +depr_param_impl(PyObject *module, PyObject *a, PyObject *b, PyObject *c, + PyObject *d) +/*[clinic end generated code: output=5a42b461851c467b input=f689a85166408359]*/ +{ + return pack_arguments_newref(4, a, b, c, d); +} + + /*[clinic input] depr_multi a: object @@ -2569,6 +2637,9 @@ static PyMethodDef tester_methods[] = { CLONE_WITH_CONV_F1_METHODDEF CLONE_WITH_CONV_F2_METHODDEF + ALIAS_POS_METHODDEF + ALIAS_KWONLY_METHODDEF + DEPR_STAR_POS0_LEN1_METHODDEF DEPR_STAR_POS0_LEN2_METHODDEF DEPR_STAR_POS0_LEN3_WITH_KWD_METHODDEF @@ -2589,6 +2660,8 @@ static PyMethodDef tester_methods[] = { DEPR_KWD_NOINLINE_METHODDEF DEPR_KWD_MULTI_METHODDEF DEPR_MULTI_METHODDEF + DEPR_ALIAS_METHODDEF + DEPR_PARAM_METHODDEF LONE_KWDS_METHODDEF KWDS_WITH_POS_ONLY_METHODDEF diff --git a/Modules/clinic/_testclinic.c.h b/Modules/clinic/_testclinic.c.h index 3fe32d704f0140f..3cfe0353b352220 100644 --- a/Modules/clinic/_testclinic.c.h +++ b/Modules/clinic/_testclinic.c.h @@ -4259,6 +4259,158 @@ clone_with_conv_f2(PyObject *module, PyObject *const *args, Py_ssize_t nargs, Py return return_value; } +PyDoc_STRVAR(alias_pos__doc__, +"alias_pos($module, /, a=None)\n" +"--\n" +"\n"); + +#define ALIAS_POS_METHODDEF \ + {"alias_pos", _PyCFunction_CAST(alias_pos), METH_FASTCALL|METH_KEYWORDS, alias_pos__doc__}, + +static PyObject * +alias_pos_impl(PyObject *module, PyObject *a); + +static PyObject * +alias_pos(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { _Py_LATIN1_CHR('a'), _Py_LATIN1_CHR('b'), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"a", "b", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "alias_pos", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[2]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 0; + PyObject *a = Py_None; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 0, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + if (!noptargs) { + goto skip_optional_pos; + } + if (args[0]) { + a = args[0]; + if (!--noptargs) { + goto skip_optional_pos; + } + } +skip_optional_pos: + if (!noptargs) { + goto skip_optional_kwonly; + } + if (args[0]) { + PyErr_Format(PyExc_TypeError, + "argument for alias_pos() given by " + "name ('b') and %s", 0 < nargs ? "position (1)" : "name ('a')"); + goto exit; + } + a = args[1]; +skip_optional_kwonly: + return_value = alias_pos_impl(module, a); + +exit: + return return_value; +} + +PyDoc_STRVAR(alias_kwonly__doc__, +"alias_kwonly($module, /, *, a=None)\n" +"--\n" +"\n"); + +#define ALIAS_KWONLY_METHODDEF \ + {"alias_kwonly", _PyCFunction_CAST(alias_kwonly), METH_FASTCALL|METH_KEYWORDS, alias_kwonly__doc__}, + +static PyObject * +alias_kwonly_impl(PyObject *module, PyObject *a); + +static PyObject * +alias_kwonly(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { _Py_LATIN1_CHR('a'), _Py_LATIN1_CHR('b'), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"a", "b", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "alias_kwonly", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[2]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 0; + PyObject *a = Py_None; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 0, /*maxpos*/ 0, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + if (!noptargs) { + goto skip_optional_kwonly; + } + if (args[0]) { + a = args[0]; + if (!--noptargs) { + goto skip_optional_kwonly; + } + } + if (args[0]) { + PyErr_Format(PyExc_TypeError, + "argument for alias_kwonly() given by " + "name ('b') and name ('a')"); + goto exit; + } + a = args[1]; +skip_optional_kwonly: + return_value = alias_kwonly_impl(module, a); + +exit: + return return_value; +} + PyDoc_STRVAR(_testclinic_TestClass_get_defining_class__doc__, "get_defining_class($self, /)\n" "--\n" @@ -4804,4 +4956,4 @@ _testclinic_TestClass_posonly_poskw_varpos_array_no_fastcall(PyObject *type, PyO exit: return return_value; } -/*[clinic end generated code: output=d9d4091b2f2ed359 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=762d789be7c878e2 input=a9049054013a1b77]*/ diff --git a/Modules/clinic/_testclinic_depr.c.h b/Modules/clinic/_testclinic_depr.c.h index e2db4fd87ed26b7..14006e85877773d 100644 --- a/Modules/clinic/_testclinic_depr.c.h +++ b/Modules/clinic/_testclinic_depr.c.h @@ -2365,6 +2365,212 @@ depr_kwd_multi(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje return return_value; } +PyDoc_STRVAR(depr_alias__doc__, +"depr_alias($module, /, a=None)\n" +"--\n" +"\n"); + +#define DEPR_ALIAS_METHODDEF \ + {"depr_alias", _PyCFunction_CAST(depr_alias), METH_FASTCALL|METH_KEYWORDS, depr_alias__doc__}, + +static PyObject * +depr_alias_impl(PyObject *module, PyObject *a); + +// Emit compiler warnings when we get to Python 3.14. +#if PY_VERSION_HEX >= 0x030e00C0 +# error "Update the clinic input of 'depr_alias'." +#elif PY_VERSION_HEX >= 0x030e00A0 +# ifdef _MSC_VER +# pragma message ("Update the clinic input of 'depr_alias'.") +# else +# warning "Update the clinic input of 'depr_alias'." +# endif +#endif + +static PyObject * +depr_alias(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { _Py_LATIN1_CHR('a'), _Py_LATIN1_CHR('b'), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"a", "b", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "depr_alias", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[2]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 0; + PyObject *a = Py_None; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 0, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + if (!noptargs) { + goto skip_optional_pos; + } + if (args[0]) { + a = args[0]; + if (!--noptargs) { + goto skip_optional_pos; + } + } +skip_optional_pos: + if (!noptargs) { + goto skip_optional_kwonly; + } + if (args[0]) { + PyErr_Format(PyExc_TypeError, + "argument for depr_alias() given by " + "name ('b') and %s", 0 < nargs ? "position (1)" : "name ('a')"); + goto exit; + } + if (PyErr_WarnEx(PyExc_DeprecationWarning, + "Passing the argument 'b' to depr_alias() is deprecated. Use 'a' " + "instead. It will be removed in Python 3.14.", 1)) + { + goto exit; + } + a = args[1]; +skip_optional_kwonly: + return_value = depr_alias_impl(module, a); + +exit: + return return_value; +} + +PyDoc_STRVAR(depr_param__doc__, +"depr_param($module, a=None, b=None, c=None, /, *, d=None)\n" +"--\n" +"\n"); + +#define DEPR_PARAM_METHODDEF \ + {"depr_param", _PyCFunction_CAST(depr_param), METH_FASTCALL|METH_KEYWORDS, depr_param__doc__}, + +static PyObject * +depr_param_impl(PyObject *module, PyObject *a, PyObject *b, PyObject *c, + PyObject *d); + +// Emit compiler warnings when we get to Python 3.14. +#if PY_VERSION_HEX >= 0x030e00C0 +# error "Update the clinic input of 'depr_param'." +#elif PY_VERSION_HEX >= 0x030e00A0 +# ifdef _MSC_VER +# pragma message ("Update the clinic input of 'depr_param'.") +# else +# warning "Update the clinic input of 'depr_param'." +# endif +#endif + +static PyObject * +depr_param(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 1 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { _Py_LATIN1_CHR('d'), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"", "", "", "d", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "depr_param", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[4]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 0; + PyObject *a = Py_None; + PyObject *b = Py_None; + PyObject *c = Py_None; + PyObject *d = Py_None; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 0, /*maxpos*/ 3, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + if (nargs < 1) { + goto skip_optional_posonly; + } + noptargs--; + a = args[0]; + if (nargs < 2) { + goto skip_optional_posonly; + } + noptargs--; + if (PyErr_WarnEx(PyExc_DeprecationWarning, + "Passing the argument 'b' to depr_param() is deprecated. It will " + "be removed in Python 3.14.", 1)) + { + goto exit; + } + b = args[1]; + if (nargs < 3) { + goto skip_optional_posonly; + } + noptargs--; + if (PyErr_WarnEx(PyExc_DeprecationWarning, + "Passing the argument 'c' to depr_param() is deprecated. It will " + "be removed in Python 3.14.", 1)) + { + goto exit; + } + c = args[2]; +skip_optional_posonly: + if (!noptargs) { + goto skip_optional_kwonly; + } + if (PyErr_WarnEx(PyExc_DeprecationWarning, + "Passing the argument 'd' to depr_param() is deprecated. It will " + "be removed in Python 3.14.", 1)) + { + goto exit; + } + d = args[3]; +skip_optional_kwonly: + return_value = depr_param_impl(module, a, b, c, d); + +exit: + return return_value; +} + PyDoc_STRVAR(depr_multi__doc__, "depr_multi($module, a, /, b, c, d, e, f, *, g)\n" "--\n" @@ -2474,4 +2680,4 @@ depr_multi(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject * exit: return return_value; } -/*[clinic end generated code: output=2231bec0ed196830 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=3ab67fb69331be33 input=a9049054013a1b77]*/ diff --git a/Tools/c-analyzer/cpython/_parser.py b/Tools/c-analyzer/cpython/_parser.py index 3d755765b967097..489043103aa9b5b 100644 --- a/Tools/c-analyzer/cpython/_parser.py +++ b/Tools/c-analyzer/cpython/_parser.py @@ -345,7 +345,7 @@ def format_tsv_lines(lines): _abs('Modules/_ssl_data_300.h'): (80_000, 10_000), _abs('Modules/_ssl_data_111.h'): (80_000, 10_000), _abs('Modules/cjkcodecs/mappings_*.h'): (160_000, 2_000), - _abs('Modules/clinic/_testclinic.c.h'): (125_000, 5_000), + _abs('Modules/clinic/_testclinic.c.h'): (135_000, 5_500), _abs('Modules/unicodedata_db.h'): (180_000, 3_000), _abs('Modules/unicodename_db.h'): (1_200_000, 15_000), _abs('Objects/unicodetype_db.h'): (240_000, 3_000), diff --git a/Tools/clinic/libclinic/clanguage.py b/Tools/clinic/libclinic/clanguage.py index 1581a19a4fd78ab..f3e66d6c27cc50e 100644 --- a/Tools/clinic/libclinic/clanguage.py +++ b/Tools/clinic/libclinic/clanguage.py @@ -102,7 +102,8 @@ def compiler_deprecated_warning( ) -> str | None: minversion: VersionTuple | None = None for p in parameters: - for version in p.deprecated_positional, p.deprecated_keyword: + for version in (p.deprecated_positional, p.deprecated_keyword, + p.deprecated_until): if version and (not minversion or minversion > version): minversion = version if not minversion: diff --git a/Tools/clinic/libclinic/converter.py b/Tools/clinic/libclinic/converter.py index c10235237d4b716..ec6451a96e3a596 100644 --- a/Tools/clinic/libclinic/converter.py +++ b/Tools/clinic/libclinic/converter.py @@ -278,11 +278,18 @@ def converter_init(self) -> None: def c_default_init(self) -> None: return + # An alternative name of a preceding parameter: they share + # the same C variable. + alias_of: Parameter | None = None + def is_optional(self) -> bool: return (self.default is not unspecified) def _render_self(self, parameter: Parameter, data: CRenderData) -> None: self.parameter = parameter + if self.alias_of is not None: + # Everything is rendered for the aliased parameter. + return name = self.parser_name # impl_arguments @@ -304,6 +311,13 @@ def _render_non_self( self.parameter = parameter name = self.name + if self.alias_of is not None: + # Only the keyword is new, the rest is rendered for the + # aliased parameter. + data.keywords.append(parameter.name) + data.format_units.append(self.format_unit) + return + # declarations d = self.declaration(in_parser=True) data.declarations.append(d) diff --git a/Tools/clinic/libclinic/dsl_parser.py b/Tools/clinic/libclinic/dsl_parser.py index 4dcbc815cc6f25b..7d469e57ed463a3 100644 --- a/Tools/clinic/libclinic/dsl_parser.py +++ b/Tools/clinic/libclinic/dsl_parser.py @@ -251,6 +251,7 @@ class DSLParser: positional_only: bool deprecated_positional: VersionTuple | None deprecated_keyword: VersionTuple | None + deprecated_until: VersionTuple | None group_stack: list[int] group_count: int parameter_state: ParamState @@ -264,6 +265,7 @@ class DSLParser: target_critical_section: list[str] disable_fastcall: bool from_version_re = re.compile(r'([*/]) +\[from +(.+)\]') + until_version_re = re.compile(r'\[until +(.+?)\] +(.+)') permit_long_summary = False permit_long_docstring_body = False @@ -292,6 +294,7 @@ def reset(self) -> None: self.positional_only = False self.deprecated_positional = None self.deprecated_keyword = None + self.deprecated_until = None self.group_stack = [] self.group_count = 0 self.parameter_state: ParamState = ParamState.START @@ -865,6 +868,12 @@ def state_parameter(self, line: str) -> None: line = match[1] version = self.parse_version(match[2]) + self.deprecated_until = None + match = self.until_version_re.fullmatch(line) + if match: + self.deprecated_until = self.parse_version(match[1], 'until') + line = match[2] + func = self.function match line: case '*': @@ -1114,6 +1123,7 @@ def bad_node(self, node: ast.AST) -> None: p = Parameter(parameter_name, kind, function=self.function, converter=converter, default=value, + deprecated_until=self.deprecated_until, group=self.group_stack[-1] if self.group_stack else 0, group_depth=len(self.group_stack), deprecated_positional=self.deprecated_positional) @@ -1124,6 +1134,26 @@ def bad_node(self, node: ast.AST) -> None: elif names and parameter_name == names[0] and c_name is None: fail(f"Parameter {parameter_name!r} requires a custom C name") + # A parameter which shares the C variable of a preceding parameter + # is an alternative name (an alias) of it. + for existing in self.function.parameters.values(): + if existing.converter.name == converter.name: + if not self.keyword_only: + fail(f"Alias {parameter_name!r} of the parameter " + f"{existing.name!r} must be keyword-only.") + if value is unspecified: + fail(f"Alias {parameter_name!r} of the parameter " + f"{existing.name!r} must have a default value.") + converter.alias_of = existing + break + + # A deprecated parameter is going away, so calls which do not pass + # it must already be valid. + if self.deprecated_until is not None and value is unspecified: + fail(f"Deprecated parameter {parameter_name!r} " + f"must have a default value.") + + key = f"{parameter_name}_as_{c_name}" if c_name else parameter_name self.function.parameters[key] = p @@ -1153,17 +1183,18 @@ def parse_converter( "Annotations must be either a name, a function call, or a string." ) - def parse_version(self, thenceforth: str) -> VersionTuple: - """Parse Python version in `[from ...]` marker.""" + def parse_version(self, version: str, marker: str = 'from') -> VersionTuple: + """Parse Python version in `[from ...]` or `[until ...]` marker.""" assert isinstance(self.function, Function) try: - major, minor = thenceforth.split(".") + major, minor = version.split(".") return int(major), int(minor) except ValueError: fail( - f"Function {self.function.name!r}: expected format '[from major.minor]' " - f"where 'major' and 'minor' are integers; got {thenceforth!r}" + f"Function {self.function.name!r}: expected format " + f"'[{marker} major.minor]' where 'major' and 'minor' are " + f"integers; got {version!r}" ) def parse_star(self, function: Function, version: VersionTuple | None) -> None: @@ -1285,12 +1316,23 @@ def parse_slash(self, function: Function, version: VersionTuple | None) -> None: fail(f"Function {function.name!r} has an unsupported group configuration. " f"(Unexpected state {self.parameter_state}.d)") # fixup preceding parameters + deprecated = None for p in function.parameters.values(): if p.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: if version is None: p.kind = inspect.Parameter.POSITIONAL_ONLY elif p.deprecated_keyword is None: p.deprecated_keyword = version + if p.kind is inspect.Parameter.POSITIONAL_ONLY: + # A positional-only argument can only be passed after all + # preceding ones, so removing a parameter would leave no + # way to pass those which follow it. + if p.deprecated_until is not None: + deprecated = p + elif deprecated is not None: + fail(f"Parameter {p.name!r} cannot follow the deprecated " + f"parameter {deprecated.name!r}: only the last " + f"positional-only parameters can be deprecated.") def state_parameter_docstring_start(self, line: str) -> None: assert self.indent.margin is not None, "self.margin.infer() has not yet been called to set the margin" @@ -1602,7 +1644,10 @@ def format_docstring(self) -> str: lines.insert(0, '{signature}') # finalize docstring - params = f.render_parameters + # An alias is not shown in the signature: only one of the + # alternative names can be used in a call. + params = [p for p in f.render_parameters + if p.converter.alias_of is None] parameters = self.format_docstring_parameters(params) signature = self.format_docstring_signature(f, params) docstring = "\n".join(lines) diff --git a/Tools/clinic/libclinic/function.py b/Tools/clinic/libclinic/function.py index 325633eb010608f..09ef640cbaedfac 100644 --- a/Tools/clinic/libclinic/function.py +++ b/Tools/clinic/libclinic/function.py @@ -213,6 +213,8 @@ class Parameter: # (`None` signifies that there is no deprecation) deprecated_positional: VersionTuple | None = None deprecated_keyword: VersionTuple | None = None + # The release in which the parameter will be removed. + deprecated_until: VersionTuple | None = None right_bracket_count: int = dc.field(init=False, default=0) def __repr__(self) -> str: diff --git a/Tools/clinic/libclinic/parse_args.py b/Tools/clinic/libclinic/parse_args.py index bca87ecd75100ce..785a3bf2604e8ad 100644 --- a/Tools/clinic/libclinic/parse_args.py +++ b/Tools/clinic/libclinic/parse_args.py @@ -282,6 +282,9 @@ def __init__(self, func: Function, codegen: CodeGen) -> None: self.max_pos = 0 self.min_kw_only = 0 for i, p in enumerate(self.parameters, 1): + if p.converter.alias_of is not None: + # An alias fills the slot of the parameter which it aliases. + continue if p.is_keyword_only(): assert not p.is_positional_only() if not p.is_optional(): @@ -568,6 +571,8 @@ def parse_pos_only(self) -> None: use_parser_code = False parser_code = [] break + if p.deprecated_until is not None: + parsearg = self.render_deprecated(p, parsearg) if has_optional or p.is_optional(): has_optional = True parser_code.append(libclinic.normalize_snippet(""" @@ -585,8 +590,7 @@ def parse_pos_only(self) -> None: 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() + self.use_converters() if self.limited_capi: self.fastcall = False @@ -649,6 +653,71 @@ def parse_var_keyword(self) -> None: parser_code.append(libclinic.normalize_snippet(self._parse_kwarg(), indent=4)) self.parser_body(*parser_code) + def use_converters(self) -> None: + """Prepare for parsing all arguments by a single call. + + Such call leaves nowhere to put the code checking a particular + argument. + """ + for p in self.parameters: + if p.converter.alias_of is not None: + fail(f"Parameter {p.name!r} cannot be an alias: " + f"the arguments are not parsed one by one.") + if p.deprecated_until is not None: + fail(f"Parameter {p.name!r} cannot be deprecated: " + f"the arguments are not parsed one by one.") + p.converter.use_converter() + + def render_alias(self, p: Parameter, argname_fmt: str, + parsearg: str) -> str: + """Prepend the code checking that the alias is not in conflict. + + Only one of the alternative names can be used in a call. + """ + aliased = p.converter.alias_of + assert aliased is not None + i = self.parameters.index(aliased) + other = f"name ('{aliased.name}')" + arg = '' + if i < self.max_pos: + # The other name can be used for a positional argument too. + arg = f', {i} < nargs ? "position ({i + 1})" : "{other}"' + other = '%s' + return '\n'.join([ + libclinic.normalize_snippet(f""" + if ({argname_fmt % i}) {{{{ + PyErr_Format(PyExc_TypeError, + "argument for {self.func.name}() given by " + "name ('{p.name}') and {other}"{arg}); + goto exit; + }}}} + """), + libclinic.normalize_snippet(parsearg), + ]) + + def render_deprecated(self, p: Parameter, parsearg: str) -> str: + """Prepend the code warning that the parameter is going away.""" + assert p.deprecated_until is not None + major, minor = p.deprecated_until + aliased = p.converter.alias_of + instead = "" if aliased is None else f"Use {aliased.name!r} instead. " + message = (f"Passing the argument {p.name!r} to " + f"{self.func.fulldisplayname}() is deprecated. {instead}" + f"It will be removed in Python {major}.{minor}.") + code = [ + libclinic.normalize_snippet(""" + if (PyErr_WarnEx(PyExc_DeprecationWarning, + {}, 1)) + {{{{ + goto exit; + }}}} + """.format( + libclinic.wrapped_c_string_literal( + message, width=64, subsequent_indent=24))), + libclinic.normalize_snippet(parsearg), + ] + return '\n'.join(code) + def parse_general(self, clang: CLanguage) -> None: parsearg: str | None deprecated_positionals: dict[int, Parameter] = {} @@ -732,6 +801,13 @@ def parse_general(self, clang: CLanguage) -> None: "parameter (after clang)") displayname = p.get_displayname(i+1) parsearg = p.converter.parse_arg(argname_fmt % i, displayname, limited_capi=self.limited_capi) + if parsearg is not None: + # The conflict is reported before warning about the + # deprecated name which caused it. + if p.deprecated_until is not None: + parsearg = self.render_deprecated(p, parsearg) + if p.converter.alias_of is not None: + parsearg = self.render_alias(p, argname_fmt, parsearg) if parsearg is None: parser_code = [] use_parser_code = False @@ -788,8 +864,7 @@ def parse_general(self, clang: CLanguage) -> None: if self.varpos: parser_code.append(libclinic.normalize_snippet(self._parse_vararg(), indent=4)) else: - for parameter in self.parameters: - parameter.converter.use_converter() + self.use_converters() self.declarations = declare_parser(self.func, codegen=self.codegen, hasformat=True) From 7f06c22f1124336c8fe1e9e01556200bd717a0ea Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 19:10:20 +0300 Subject: [PATCH 2/2] Do not check two deprecation warnings with assertWarnsRegex() It re-emits the warnings which do not match, so the warning for the other parameter was raised as an error. --- Lib/test/test_clinic.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index 2f33b020b206996..74921757695bdc3 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -15,6 +15,7 @@ import re import sys import unittest +import warnings test_tools.skip_if_missing('clinic') with test_tools.imports_under_tool('clinic'): @@ -4748,8 +4749,15 @@ def errmsg(name): f"is deprecated. " f"It will be removed in Python 3.14.") self.check_depr(errmsg('b'), fn, 1, 2) - self.check_depr(errmsg('c'), fn, 1, 2, 3) self.check_depr(errmsg('d'), fn, 1, d=4) + # Each deprecated parameter is reported on its own. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + self.assertEqual(fn(1, 2, 3), (1, 2, 3, None)) + self.assertEqual(len(caught), 2) + for warning, name in zip(caught, 'bc'): + self.assertIs(warning.category, DeprecationWarning) + self.assertRegex(str(warning.message), errmsg(name)) def test_lone_kwds(self): with self.assertRaises(TypeError):