From 9ad21a06c0d731d99347ed6707cb189ac7aba4c9 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 1 Sep 2026 23:24:13 -0400 Subject: [PATCH] fix: correct args_convert flags for kw-only args after py::args The `*args` stub was pushed into `call.args` in step 2, but its convert flag was appended after all of the keyword-only flags in step 4a. Each keyword-only argument thus received the flag of its successor, so `py::arg("x").noconvert()` applied to the wrong argument. Push the flag together with the stub, and append it in step 4a only when the tuple itself is appended. The second-pass scan also stopped at `pos_args`, so an overload whose only convertible arguments are keyword-only never got a converting pass. Scan all of the flags instead. Fixes items 1 and 2 of #6159. --- include/pybind11/pybind11.h | 7 +++++-- tests/test_kwargs_and_defaults.cpp | 17 +++++++++++++++++ tests/test_kwargs_and_defaults.py | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index f57514ae28..00b3627f9c 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -1156,6 +1156,7 @@ class cpp_function : public function { // replaced later if (func.has_args && call.args.size() == func.nargs_pos) { call.args.push_back(none()); + call.args_convert.push_back(false); } call.args.push_back(value); @@ -1187,10 +1188,11 @@ class cpp_function : public function { } if (call.args.size() <= func.nargs_pos) { call.args.push_back(call.args_ref); + call.args_convert.push_back(false); } else { + // The stub pushed in step 2 already has its convert flag. call.args[func.nargs_pos] = call.args_ref; } - call.args_convert.push_back(false); } // 4b. If we have a py::kwargs, pass on any remaining kwargs @@ -1246,7 +1248,8 @@ class cpp_function : public function { // The (overloaded) call failed; if the call has at least one argument that // permits conversion (i.e. it hasn't been explicitly specified `.noconvert()`) // then add this call to the list of second pass overloads to try. - for (size_t i = func.is_method ? 1 : 0; i < pos_args; i++) { + // Keyword-only arguments count too, so scan all of the flags. + for (size_t i = func.is_method ? 1 : 0; i < second_pass_convert.size(); i++) { if (second_pass_convert[i]) { // Found one: swap the converting flags back in and store the call for // the second pass. diff --git a/tests/test_kwargs_and_defaults.cpp b/tests/test_kwargs_and_defaults.cpp index 831947f160..d6e9bd4dad 100644 --- a/tests/test_kwargs_and_defaults.cpp +++ b/tests/test_kwargs_and_defaults.cpp @@ -148,6 +148,23 @@ TEST_SUBMODULE(kwargs_and_defaults, m) { "j"_a = 3.14159, "z"_a = 42); + // test_args_kwonly_noconvert + m.def( + "args_kwonly_noconvert", + [](const py::args &args, bool x, bool y) { return py::make_tuple(args, x, y); }, + py::arg("x").noconvert(), + py::arg("y")); + + // test_args_kwonly_second_pass + m.def( + "args_kwonly_second_pass", + [](const py::args &, const std::string &s) { return "str: " + s; }, + "s"_a); + m.def( + "args_kwonly_second_pass", + [](const py::args &, bool b) { return std::string(b ? "bool: True" : "bool: False"); }, + "b"_a); + // test_args_refcount // PyPy needs a garbage collection to get the reference count values to match CPython's behaviour // PyPy uses the top few bits for REFCNT_FROM_PYPY & REFCNT_FROM_PYPY_LIGHT, so truncate diff --git a/tests/test_kwargs_and_defaults.py b/tests/test_kwargs_and_defaults.py index a7745d1ecb..e6baa399a9 100644 --- a/tests/test_kwargs_and_defaults.py +++ b/tests/test_kwargs_and_defaults.py @@ -249,6 +249,20 @@ def test_mixed_args_and_kwargs(msg): assert m.args_kwonly_kwargs_defaults(5, 6, 7, m=8, z=9) == (5, 6, (7,), 9, {"m": 8}) +def test_args_kwonly_noconvert(): + # The noconvert flag must stay on x, the argument it was given for. + assert m.args_kwonly_noconvert(1, 2, x=True, y=1) == ((1, 2), True, True) + with pytest.raises(TypeError): + m.args_kwonly_noconvert(x=1, y=True) + + +def test_args_kwonly_second_pass(): + # Only the keyword-only argument can convert, so the second pass must run. + assert m.args_kwonly_second_pass(s="a") == "str: a" + assert m.args_kwonly_second_pass(b=True) == "bool: True" + assert m.args_kwonly_second_pass(b=1) == "bool: True" + + def test_keyword_only_args(msg): assert m.kw_only_all(i=1, j=2) == (1, 2) assert m.kw_only_all(j=1, i=2) == (2, 1)