From bf461939251a6dcab6129366642e86fb09247101 Mon Sep 17 00:00:00 2001 From: Joshua Oreman Date: Wed, 6 Mar 2024 22:59:17 +0800 Subject: [PATCH] Add support for keyword-only arguments --- docs/api_core.rst | 24 ++++++ docs/changelog.rst | 4 + docs/functions.rst | 69 +++++++++++++++-- docs/porting.rst | 6 +- include/nanobind/nb_attr.h | 20 ++++- include/nanobind/nb_func.h | 80 ++++++++++++++++++-- src/nb_func.cpp | 76 ++++++++++++------- src/nb_internals.h | 2 +- tests/test_functions.cpp | 57 ++++++++++++++ tests/test_functions.py | 123 +++++++++++++++++++++++++++++++ tests/test_functions_ext.pyi.ref | 27 +++++++ 11 files changed, 445 insertions(+), 43 deletions(-) diff --git a/docs/api_core.rst b/docs/api_core.rst index edf9b623..bd689420 100644 --- a/docs/api_core.rst +++ b/docs/api_core.rst @@ -1739,6 +1739,30 @@ parameter of :cpp:func:`module_::def`, :cpp:func:`class_::def`, This policy matches `automatic` but falls back to `reference` when the return value is a pointer. +.. cpp:struct:: kw_only + + Indicate that all following function parameters are keyword-only. This + may only be used if you supply an :cpp:struct:`arg` annotation for each + parameters, because keyword-only parameters are useless if they don't have + names. For example, if you write + + .. code-block:: cpp + + int some_func(int one, const char* two); + + m.def("some_func", &some_func, + nb::arg("one"), nb::kw_only(), nb::arg("two")); + + then in Python you can write ``some_func(42, two="hi")``, or + ``some_func(one=42, two="hi")``, but not ``some_func(42, "hi")``. + + Just like in Python, any parameters appearing after variadic + :cpp:class:`*args ` are implicitly keyword-only. You don't + need to include the :cpp:struct:`kw_only` annotation in this case, + but if you do include it, it must be in the correct position: + immediately after the :cpp:struct:`arg` annotation for the variadic + :cpp:class:`*args ` parameter. + .. cpp:struct:: template for_getter When defining a property with a getter and a setter, you can use this to diff --git a/docs/changelog.rst b/docs/changelog.rst index dfcabea0..0a415c34 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -155,6 +155,10 @@ noteworthy: nanobind versions but was awkward to use, as it required the user to provide a custom type formatter. This release makes the interface more convenient. +* :ref:`Keyword-only arguments ` are now supported, and can be + indicated using the new :cpp:struct:`nb::kw_only() ` function + annotation. (PR `#448 `__). + * ABI version 14. .. rubric:: Footnote diff --git a/docs/functions.rst b/docs/functions.rst index dfb92ad1..db47a50a 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -255,11 +255,10 @@ The class :cpp:class:`nb::args ` derives from :cpp:class:`nb::tuple `. You may also use them individually or even combine them with ordinary -arguments. Note, however, that :cpp:class:`nb::args ` and -:cpp:class:`nb::kwargs ` must always be the last arguments of the -function, and in that order if both are specified. This is a restriction -compared to pybind11, which allowed more general arrangements. nanobind also -lacks the ``kw_only`` and ``pos_only`` annotations available in pybind11. +parameters. Note that :cpp:class:`nb::kwargs ` must be the last +parameter if it is specified, and any parameters after +:cpp:class:`nb::args ` are implicitly :ref:`keyword-only `, +just like in regular Python. .. _args_kwargs_2: @@ -301,6 +300,66 @@ Here is an example use of the above extension in Python: (1, 'positional') {'keyword': 'value'} + +.. _kw_only: + +Keyword-only parameters +----------------------- + +Python supports keyword-only parameters; these can't be filled positionally, +thus requiring the caller to specify their name. They can be used +to enforce more clarity at call sites if a function has +multiple paramaters that could be confused with each other, or to accept +named options alongside variadic ``*args``. + +.. code-block:: python + + def example(val: int, *, check: bool) -> None: + # val can be passed either way; check must be given as a keyword arg + pass + + example(val=42, check=True) # good + example(check=False, val=5) # good + example(100, check=True) # good + example(200, False) # TypeError: + # example() takes 1 positional argument but 2 were given + + def munge(*args: int, invert: bool = False) -> int: + return sum(args) * (-1 if invert else 1) + + munge(1, 2, 3) # 6 + munge(4, 5, 6, invert=True) # -15 + +nanobind provides a :cpp:struct:`nb::kw_only() ` annotation +that allows you to produce bindings that behave like these +examples. It must be placed before the :cpp:struct:`nb::arg() ` +annotation for the first keyword-only parameter; you can think of it +as equivalent to the bare ``*,`` in a Python function signature. For +example, the above examples could be written in C++ as: + +.. code-block:: cpp + + void example(int val, bool check); + int munge(nb::args args, bool invert); + + m.def("example", &example, + nb::arg("val"), nb::kw_only(), nb::arg("check")); + + // Parameters after *args are implicitly keyword-only: + m.def("munge", &munge, + nb::arg("args"), nb::arg("invert")); + + // But you can be explicit about it too, as long as you put the + // kw_only annotation in the correct position: + m.def("munge", &munge, + nb::arg("args"), nb::kw_only(), nb::arg("invert")); + +.. note:: nanobind does *not* support the ``pos_only()`` argument annotation + provided by pybind11, which marks the parameters before it as positional-only. + However, a parameter can be made effectively positional-only by giving it + no name (using an empty :cpp:struct:`nb::arg() ` specifier). + + .. _function_templates: Function templates diff --git a/docs/porting.rst b/docs/porting.rst index b3bfa3e6..431adfd7 100644 --- a/docs/porting.rst +++ b/docs/porting.rst @@ -334,8 +334,10 @@ Removed features include: executable or run several independent Python interpreters in the same process is unsupported. Nanobind caters to bindings only. Multi-interpreter support would require TLS lookups for nanobind data structures, which is undesirable. -- ○ **Function binding annotations**: the ``kw_only`` / ``pos_only`` argument - annotations were removed. +- ○ **Function binding annotations**: The ``pos_only`` argument + annotation was removed. However, the same behavior can be achieved by + creating unnamed arguments; see the discussion in the section on + :ref:`keyword-only arguments `. - ○ **Metaclasses**: creating types with custom metaclasses is unsupported. - ○ **Module-local bindings**: support was removed (both for types and exceptions). - ○ **Custom allocation**: C++ classes with an overloaded or deleted ``operator diff --git a/include/nanobind/nb_attr.h b/include/nanobind/nb_attr.h index 1775a6fe..43aec3f1 100644 --- a/include/nanobind/nb_attr.h +++ b/include/nanobind/nb_attr.h @@ -60,6 +60,7 @@ struct is_operator {}; struct is_arithmetic {}; struct is_final {}; struct is_generic {}; +struct kw_only {}; template struct keep_alive {}; template struct supplement {}; @@ -156,8 +157,20 @@ template struct func_data_prelim { /// Supplementary flags uint32_t flags; - /// Total number of function call arguments - uint32_t nargs; + /// Total number of parameters accepted by the C++ function; nb::args + /// and nb::kwargs parameters are counted as one each. If the + /// 'has_args' flag is set, then there is one arg_data structure + /// for each of these. + uint16_t nargs; + + /// Number of paramters to the C++ function that may be filled from + /// Python positional arguments without additional ceremony. nb::args and + /// nb::kwargs parameters are not counted in this total, nor are any + /// parameters after nb::args or after a nb::kw_only annotation. + /// The parameters counted here may be either named (nb::arg("name")) + /// or unnamed (nb::arg()). If unnamed, they are effectively positional-only. + /// nargs_pos is always <= nargs. + uint16_t nargs_pos; // ------- Extra fields ------- @@ -270,6 +283,9 @@ NB_INLINE void func_extra_apply(F &f, const arg_v &a, size_t &index) { arg.none = a.none_; } +template +NB_INLINE void func_extra_apply(F &, kw_only, size_t &) {} + template NB_INLINE void func_extra_apply(F &, call_guard, size_t &) {} diff --git a/include/nanobind/nb_func.h b/include/nanobind/nb_func.h index 3682c410..09ea1652 100644 --- a/include/nanobind/nb_func.h +++ b/include/nanobind/nb_func.h @@ -26,6 +26,15 @@ bool from_python_keep_alive(Caster &c, PyObject **args, uint8_t *args_flags, return true; } +// Return the number of nb::arg and nb::arg_v types in the first I types Ts. +// Invoke with std::make_index_sequence() to provide +// an index pack 'Is' that parallels the types pack Ts. +template +constexpr size_t count_args_before_index(std::index_sequence) { + static_assert(sizeof...(Is) == sizeof...(Ts)); + return ((Is < I && (std::is_same_v || std::is_same_v)) + ... + 0); +} + template NB_INLINE PyObject *func_create(Func &&func, Return (*)(Args...), @@ -45,7 +54,9 @@ NB_INLINE PyObject *func_create(Func &&func, Return (*)(Args...), (void) is; - // Detect locations of nb::args / nb::kwargs (if exists) + // Detect locations of nb::args / nb::kwargs (if they exist). + // Find the first and last occurrence of each; we'll later make sure these + // match, in order to guarantee there's only one instance. static constexpr size_t args_pos_1 = index_1_v, args>...>, args_pos_n = index_n_v, args>...>, @@ -60,16 +71,59 @@ NB_INLINE PyObject *func_create(Func &&func, Return (*)(Args...), (std::is_same_v + ... + 0) != 0; constexpr bool is_getter_det = (std::is_same_v + ... + 0) != 0; + constexpr bool has_arg_annotations = nargs_provided > 0 && !is_getter_det; + + // Detect location of nb::kw_only annotation, if supplied. As with args/kwargs + // we find the first and last location and later verify they match each other. + // Note this is an index in Extra... while args/kwargs_pos_* are indices in + // Args... . + constexpr size_t + kwonly_pos_1 = index_1_v...>, + kwonly_pos_n = index_n_v...>; + // Arguments after nb::args are implicitly keyword-only even if there is no + // nb::kw_only annotation + constexpr bool explicit_kw_only = kwonly_pos_1 != sizeof...(Extra); + constexpr bool implicit_kw_only = args_pos_1 + 1 < kwargs_pos_1; // A few compile-time consistency checks static_assert(args_pos_1 == args_pos_n && kwargs_pos_1 == kwargs_pos_n, "Repeated use of nb::kwargs or nb::args in the function signature!"); - static_assert(nargs_provided == 0 || nargs_provided + is_method_det == nargs || is_getter_det, + static_assert(!has_arg_annotations || nargs_provided + is_method_det == nargs, "The number of nb::arg annotations must match the argument count!"); static_assert(kwargs_pos_1 == nargs || kwargs_pos_1 + 1 == nargs, "nb::kwargs must be the last element of the function signature!"); - static_assert(args_pos_1 == nargs || args_pos_1 + 1 == kwargs_pos_1, - "nb::args must follow positional arguments and precede nb::kwargs!"); + static_assert(args_pos_1 == nargs || args_pos_1 < kwargs_pos_1, + "nb::args must precede nb::kwargs if both are present!"); + static_assert(has_arg_annotations || (!implicit_kw_only && !explicit_kw_only), + "Keyword-only arguments must have names!"); + + // Find the index in Args... of the first keyword-only parameter. Since + // the 'self' parameter doesn't get a nb::arg annotation, we must adjust + // by 1 for methods. Note that nargs_before_kw_only is only used if + // a kw_only annotation exists (i.e., if explicit_kw_only is true); + // the conditional is just to save the compiler some effort otherwise. + constexpr size_t nargs_before_kw_only = + explicit_kw_only + ? is_method_det + count_args_before_index( + std::make_index_sequence()) + : nargs; + + if constexpr (explicit_kw_only) { + static_assert(kwonly_pos_1 == kwonly_pos_n, + "Repeated use of nb::kw_only annotation!"); + + // If both kw_only and *args are specified, kw_only must be + // immediately after the nb::arg for *args. + static_assert(args_pos_1 == nargs || nargs_before_kw_only == args_pos_1 + 1, + "Arguments after nb::args are implicitly keyword-only; any " + "nb::kw_only() annotation must be positioned to reflect that!"); + + // If both kw_only and **kwargs are specified, kw_only must be + // before the nb::arg for **kwargs. + static_assert(nargs_before_kw_only < kwargs_pos_1, + "Variadic nb::kwargs are implicitly keyword-only; any " + "nb::kw_only() annotation must be positioned to reflect that!"); + } // Collect function signature information for the docstring using cast_out = make_caster< @@ -96,8 +150,7 @@ NB_INLINE PyObject *func_create(Func &&func, Return (*)(Args...), f.flags = (args_pos_1 < nargs ? (uint32_t) func_flags::has_var_args : 0) | (kwargs_pos_1 < nargs ? (uint32_t) func_flags::has_var_kwargs : 0) | (ReturnRef ? (uint32_t) func_flags::return_ref : 0) | - (nargs_provided && - !is_getter_det ? (uint32_t) func_flags::has_args : 0); + (has_arg_annotations ? (uint32_t) func_flags::has_args : 0); /* Store captured function inside 'func_data_prelim' if there is space. Issues with aliasing are resolved via separate compilation of libnanobind. */ @@ -166,6 +219,21 @@ NB_INLINE PyObject *func_create(Func &&func, Return (*)(Args...), f.descr_types = descr_types; f.nargs = nargs; + // Set nargs_pos to the number of C++ function parameters (Args...) that + // can be filled from Python positional arguments in a one-to-one fashion. + // This ends at: + // - the location of the variadic *args parameter, if present; otherwise + // - the location of the first keyword-only parameter, if any; otherwise + // - the location of the variadic **kwargs parameter, if present; otherwise + // - the end of the parameter list + // It's correct to give *args priority over kw_only because we verified + // above that kw_only comes afterward if both are present. It's correct + // to give kw_only priority over **kwargs because we verified above that + // kw_only comes before if both are present. + f.nargs_pos = args_pos_1 < nargs ? args_pos_1 : + explicit_kw_only ? nargs_before_kw_only : + kwargs_pos_1 < nargs ? kwargs_pos_1 : nargs; + // Fill remaining fields of 'f' size_t arg_index = 0; (void) arg_index; diff --git a/src/nb_func.cpp b/src/nb_func.cpp index bc8e701e..8b08a65a 100644 --- a/src/nb_func.cpp +++ b/src/nb_func.cpp @@ -199,8 +199,6 @@ PyObject *nb_func_new(const void *in_) noexcept { bool has_scope = f->flags & (uint32_t) func_flags::has_scope, has_name = f->flags & (uint32_t) func_flags::has_name, has_args = f->flags & (uint32_t) func_flags::has_args, - has_var_args = f->flags & (uint32_t) func_flags::has_var_args, - has_var_kwargs = f->flags & (uint32_t) func_flags::has_var_kwargs, has_keep_alive = f->flags & (uint32_t) func_flags::has_keep_alive, has_doc = f->flags & (uint32_t) func_flags::has_doc, has_signature = f->flags & (uint32_t) func_flags::has_signature, @@ -277,13 +275,13 @@ PyObject *nb_func_new(const void *in_) noexcept { check(func, "nb::detail::nb_func_new(\"%s\"): alloc. failed (1).", name_cstr); - func->max_nargs_pos = f->nargs; - func->complex_call = has_args || has_var_args || has_var_kwargs || has_keep_alive; + func->max_nargs = f->nargs; + func->complex_call = f->nargs_pos < f->nargs || has_args || has_keep_alive; if (func_prev) { func->complex_call |= ((nb_func *) func_prev)->complex_call; - func->max_nargs_pos = std::max(func->max_nargs_pos, - ((nb_func *) func_prev)->max_nargs_pos); + func->max_nargs = std::max(func->max_nargs, + ((nb_func *) func_prev)->max_nargs); func_data *cur = nb_func_data(func), *prev = nb_func_data(func_prev); @@ -299,7 +297,7 @@ PyObject *nb_func_new(const void *in_) noexcept { internals->funcs.erase(it); } - func->complex_call |= func->max_nargs_pos >= NB_MAXARGS_SIMPLE; + func->complex_call |= func->max_nargs >= NB_MAXARGS_SIMPLE; func->vectorcall = func->complex_call ? nb_func_vectorcall_complex : nb_func_vectorcall_simple; @@ -507,7 +505,7 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, /* The following lines allocate memory on the stack, which is very efficient but also potentially dangerous since it can be used to generate stack overflows. We refuse unrealistically large number of 'kwargs' (the - 'max_nargs_pos' value is fine since it is specified by the bindings) */ + 'max_nargs' value is fine since it is specified by the bindings) */ if (nkwargs_in > 1024) { PyErr_SetString(PyExc_TypeError, "nanobind::detail::nb_func_vectorcall(): too many (> " @@ -523,9 +521,9 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, cleanup_list cleanup(self_arg); // Preallocate stack memory for function dispatch - size_t max_nargs_pos = ((nb_func *) self)->max_nargs_pos; - PyObject **args = (PyObject **) alloca(max_nargs_pos * sizeof(PyObject *)); - uint8_t *args_flags = (uint8_t *) alloca(max_nargs_pos * sizeof(uint8_t)); + size_t max_nargs = ((nb_func *) self)->max_nargs; + PyObject **args = (PyObject **) alloca(max_nargs * sizeof(PyObject *)); + uint8_t *args_flags = (uint8_t *) alloca(max_nargs * sizeof(uint8_t)); bool *kwarg_used = (bool *) alloca(nkwargs_in * sizeof(bool)); /* The logic below tries to find a suitable overload using two passes @@ -535,7 +533,7 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, The following is done per overload during a pass - 1. Copy positional arguments while checking that named positional + 1. Copy individual arguments while checking that named positional arguments weren't *also* specified as kwarg. Substitute missing entries using keyword arguments or default argument values provided in the bindings, if available. @@ -563,8 +561,17 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, has_var_args = f->flags & (uint32_t) func_flags::has_var_args, has_var_kwargs = f->flags & (uint32_t) func_flags::has_var_kwargs; - /// Number of positional arguments - size_t nargs_pos = f->nargs - has_var_args - has_var_kwargs; + // Number of C++ parameters eligible to be filled from individual + // Python positional arguments + size_t nargs_pos = f->nargs_pos; + + // Number of C++ parameters in total, except for a possible trailing + // nb::kwargs. All of these are eligible to be filled from individual + // Python arguments (keyword always, positional until index nargs_pos) + // except for a potential nb::args, which exists at index nargs_pos + // if has_var_args is true. We'll skip that one in the individual-args + // loop, and go back and fill it later with the unused positionals. + size_t nargs_step1 = f->nargs - has_var_kwargs; if (nargs_in > nargs_pos && !has_var_args) continue; // Too many positional arguments given for this overload @@ -575,14 +582,24 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, memset(kwarg_used, 0, nkwargs_in * sizeof(bool)); - // 1. Copy positional arguments, potentially substitute kwargs/defaults + // 1. Copy individual arguments, potentially substitute kwargs/defaults size_t i = 0; - for (; i < nargs_pos; ++i) { + for (; i < nargs_step1; ++i) { + if (has_var_args && i == nargs_pos) + continue; // skip nb::args parameter, will be handled below + PyObject *arg = nullptr; bool arg_convert = pass == 1, arg_none = false; - if (i < nargs_in) + // If i >= nargs_pos, then this is a keyword-only parameter. + // (We skipped any *args parameter using the test above, + // and we set the bounds of nargs_step1 to not include any + // **kwargs parameter.) In that case we don't want to take + // a positional arg (which might validly exist and be + // destined for the *args) but we do still want to look for + // a matching keyword arg. + if (i < nargs_in && i < nargs_pos) arg = args_in[i]; if (has_args) { @@ -625,8 +642,8 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, args_flags[i] = arg_convert ? (uint8_t) cast_flags::convert : (uint8_t) 0; } - // Skip this overload if positional arguments were unavailable - if (i != nargs_pos) + // Skip this overload if any arguments were unavailable + if (i != nargs_step1) continue; // Deal with remaining positional arguments @@ -654,8 +671,8 @@ static PyObject *nb_func_vectorcall_complex(PyObject *self, PyDict_SetItem(dict, key, args_in[nargs_in + j]); } - args[nargs_pos + has_var_args] = dict; - args_flags[nargs_pos + has_var_args] = 0; + args[nargs_step1] = dict; + args_flags[nargs_step1] = 0; cleanup.append(dict); } else if (kwargs_in) { bool success = true; @@ -944,11 +961,16 @@ static uint32_t nb_func_render_signature(const func_data *f, break; } - if (has_var_args && arg_index + 1 + has_var_kwargs == f->nargs) { + if (arg_index == f->nargs_pos) { buf.put("*"); - buf.put_dstr(arg_name ? arg_name : "args"); - pc += 5; // strlen("tuple") - break; + if (has_var_args) { + buf.put_dstr(arg_name ? arg_name : "args"); + pc += 5; // strlen("tuple") + break; + } else { + buf.put(", "); + // fall through to render the first keyword-only arg + } } if (is_method && arg_index == 0) { @@ -1026,7 +1048,7 @@ static uint32_t nb_func_render_signature(const func_data *f, arg_index++; - if (arg_index == f->nargs - has_var_args - has_var_kwargs && !has_args) + if (arg_index == f->nargs_pos && !has_args) buf.put(", /"); break; @@ -1297,4 +1319,4 @@ NB_NOINLINE char *type_name(const std::type_info *t) { } NAMESPACE_END(detail) -NAMESPACE_END(NB_NAMESPACE) \ No newline at end of file +NAMESPACE_END(NB_NAMESPACE) diff --git a/src/nb_internals.h b/src/nb_internals.h index a648291f..03bc5f1f 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -82,7 +82,7 @@ static_assert(sizeof(nb_inst) == sizeof(PyObject) + sizeof(uint32_t) * 2); struct nb_func { PyObject_VAR_HEAD PyObject* (*vectorcall)(PyObject *, PyObject * const*, size_t, PyObject *); - uint32_t max_nargs_pos; + uint32_t max_nargs; // maximum value of func_data::nargs for any overload bool complex_call; }; diff --git a/tests/test_functions.cpp b/tests/test_functions.cpp index 42735bfb..31a2e105 100644 --- a/tests/test_functions.cpp +++ b/tests/test_functions.cpp @@ -245,4 +245,61 @@ NB_MODULE(test_functions_ext, m) { static int imut = 10; static const int iconst = 100; m.def("test_ptr_return", []() { return std::make_pair(&imut, &iconst); }); + + // These are caught at compile time, uncomment and rebuild to verify: + + // No nb::arg annotations: + //m.def("bad_args1", [](nb::args, int) {}); + + // kw_only in wrong place (1): + //m.def("bad_args2", [](nb::args, int) {}, nb::kw_only(), "args"_a, "i"_a); + + // kw_only in wrong place (2): + //m.def("bad_args3", [](nb::args, int) {}, "args"_a, "i"_a, nb::kw_only()); + + // kw_only in wrong place (3): + //m.def("bad_args4", [](int, nb::kwargs) {}, "i"_a, "kwargs"_a, nb::kw_only()); + + // kw_only specified twice: + //m.def("bad_args5", [](int, int) {}, nb::kw_only(), "i"_a, nb::kw_only(), "j"_a); + + m.def("test_args_kwonly", + [](int i, double j, nb::args args, int z) { + return nb::make_tuple(i, j, args, z); + }, "i"_a, "j"_a, "args"_a, "z"_a); + m.def("test_args_kwonly_kwargs", + [](int i, double j, nb::args args, int z, nb::kwargs kwargs) { + return nb::make_tuple(i, j, args, z, kwargs); + }, "i"_a, "j"_a, "args"_a, nb::kw_only(), "z"_a, "kwargs"_a); + m.def("test_kwonly_kwargs", + [](int i, double j, nb::kwargs kwargs) { + return nb::make_tuple(i, j, kwargs); + }, "i"_a, nb::kw_only(), "j"_a, "kwargs"_a); + + m.def("test_kw_only_all", + [](int i, int j) { return nb::make_tuple(i, j); }, + nb::kw_only(), "i"_a, "j"_a); + m.def("test_kw_only_some", + [](int i, int j, int k) { return nb::make_tuple(i, j, k); }, + nb::arg(), nb::kw_only(), "j"_a, "k"_a); + m.def("test_kw_only_with_defaults", + [](int i, int j, int k, int z) { return nb::make_tuple(i, j, k, z); }, + nb::arg() = 3, "j"_a = 4, nb::kw_only(), "k"_a = 5, "z"_a); + m.def("test_kw_only_mixed", + [](int i, int j) { return nb::make_tuple(i, j); }, + "i"_a, nb::kw_only(), "j"_a); + + struct kw_only_methods { + kw_only_methods(int _v) : v(_v) {} + int v; + }; + nb::class_(m, "kw_only_methods") + .def(nb::init(), nb::kw_only(), "v"_a) + .def_rw("v", &kw_only_methods::v) + .def("method_2k", + [](kw_only_methods&, int i, int j) { return nb::make_tuple(i, j); }, + nb::kw_only(), "i"_a = 1, "j"_a = 2) + .def("method_1p1k", + [](kw_only_methods&, int i, int j) { return nb::make_tuple(i, j); }, + "i"_a = 1, nb::kw_only(), "j"_a = 2); } diff --git a/tests/test_functions.py b/tests/test_functions.py index db17cea1..0f70a271 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -441,5 +441,128 @@ def test40_nb_signature(): ) +def test41_kw_only(): + # (i, j, *args, z) + assert t.test_args_kwonly(2, 2.5, z=22) == (2, 2.5, (), 22) + assert t.test_args_kwonly(2, 2.5, "a", "b", z=22) == (2, 2.5, ("a", "b"), 22) + assert t.test_args_kwonly(z=22, i=4, j=16) == (4, 16.0, (), 22) + assert ( + t.test_args_kwonly.__doc__ + == "test_args_kwonly(i: int, j: float, *args, z: int) -> tuple" + ) + with pytest.raises(TypeError): + t.test_args_kwonly(2, 2.5, 22) # missing z= keyword + + # (i, j, *args, z, **kwargs) + assert t.test_args_kwonly_kwargs(i=1, k=4, j=10, z=-1, y=9) == ( + 1, 10, (), -1, {"k": 4, "y": 9} + ) + assert t.test_args_kwonly_kwargs(1, 2, 3, 4, z=11, y=12) == ( + 1, 2, (3, 4), 11, {"y": 12} + ) + with pytest.raises(TypeError): + t.test_args_kwonly_kwargs(1, 2, 3, 4, 5) + assert ( + t.test_args_kwonly_kwargs.__doc__ + == "test_args_kwonly_kwargs(i: int, j: float, *args, z: int, **kwargs) -> tuple" + ) + + # (i, *, j, **kwargs) + assert t.test_kwonly_kwargs(j=2, i=1) == (1, 2, {}) + assert t.test_kwonly_kwargs(j=2, i=1, z=10) == (1, 2, {"z": 10}) + assert t.test_kwonly_kwargs(1, j=2) == (1, 2, {}) + assert t.test_kwonly_kwargs(1, j=2, z=10) == (1, 2, {"z": 10}) + with pytest.raises(TypeError): + t.test_kwonly_kwargs(1, 2) + with pytest.raises(TypeError): + t.test_kwonly_kwargs(1, 2, j=3) + with pytest.raises(TypeError): + t.test_kwonly_kwargs(1, 2, z=10) + assert ( + t.test_kwonly_kwargs.__doc__ + == "test_kwonly_kwargs(i: int, *, j: float, **kwargs) -> tuple" + ) + + # (*, i, j) + assert t.test_kw_only_all(i=1, j=2) == (1, 2) + assert t.test_kw_only_all(j=1, i=2) == (2, 1) + with pytest.raises(TypeError): + t.test_kw_only_all(i=1) + with pytest.raises(TypeError): + t.test_kw_only_all(1, 2) + assert ( + t.test_kw_only_all.__doc__ + == "test_kw_only_all(*, i: int, j: int) -> tuple" + ) + + # (__arg0, *, j, k) + assert t.test_kw_only_some(1, k=3, j=2) == (1, 2, 3) + assert ( + t.test_kw_only_some.__doc__ + == "test_kw_only_some(arg0: int, *, j: int, k: int) -> tuple" + ) + + # (__arg0=3, j=4, *, k=5, z) + assert t.test_kw_only_with_defaults(z=8) == (3, 4, 5, 8) + assert t.test_kw_only_with_defaults(2, z=8) == (2, 4, 5, 8) + assert t.test_kw_only_with_defaults(2, j=7, k=8, z=9) == (2, 7, 8, 9) + assert t.test_kw_only_with_defaults(2, 7, z=9, k=8) == (2, 7, 8, 9) + with pytest.raises(TypeError): + t.test_kw_only_with_defaults(2, 7, 8, z=9) + assert ( + t.test_kw_only_with_defaults.__doc__ + == "test_kw_only_with_defaults(arg0: int = 3, j: int = 4, *, k: int = 5, z: int) -> tuple" + ) + + # (i, *, j) + assert t.test_kw_only_mixed(1, j=2) == (1, 2) + assert t.test_kw_only_mixed(j=2, i=3) == (3, 2) + assert t.test_kw_only_mixed(i=2, j=3) == (2, 3) + with pytest.raises(TypeError): + t.test_kw_only_mixed(i=1) + with pytest.raises(TypeError): + t.test_kw_only_mixed(1, i=2) + assert ( + t.test_kw_only_mixed.__doc__ + == "test_kw_only_mixed(i: int, *, j: int) -> tuple" + ) + + with pytest.raises(TypeError): + t.kw_only_methods(42) + + val = t.kw_only_methods(v=42) + assert val.v == 42 + + # (self, *, i, j) + assert val.method_2k() == (1, 2) + assert val.method_2k(i=3) == (3, 2) + assert val.method_2k(j=4) == (1, 4) + assert val.method_2k(i=3, j=4) == (3, 4) + assert val.method_2k(j=3, i=4) == (4, 3) + with pytest.raises(TypeError): + val.method_2k(1) + with pytest.raises(TypeError): + val.method_2k(1, j=2) + assert ( + t.kw_only_methods.method_2k.__doc__ + == "method_2k(self, *, i: int = 1, j: int = 2) -> tuple" + ) + + # (self, i, *, j) + assert val.method_1p1k() == (1, 2) + assert val.method_1p1k(i=3) == (3, 2) + assert val.method_1p1k(j=4) == (1, 4) + assert val.method_1p1k(i=3, j=4) == (3, 4) + assert val.method_1p1k(j=3, i=4) == (4, 3) + assert val.method_1p1k(3) == (3, 2) + assert val.method_1p1k(3, j=4) == (3, 4) + with pytest.raises(TypeError): + val.method_2k(1, 2) + assert ( + t.kw_only_methods.method_1p1k.__doc__ + == "method_1p1k(self, i: int = 1, *, j: int = 2) -> tuple" + ) + + def test42_ptr_return(): assert t.test_ptr_return() == (10, 100) diff --git a/tests/test_functions_ext.pyi.ref b/tests/test_functions_ext.pyi.ref index a6894675..2c4c2eeb 100644 --- a/tests/test_functions_ext.pyi.ref +++ b/tests/test_functions_ext.pyi.ref @@ -20,6 +20,19 @@ def identity_u64(arg: int, /) -> int: ... def identity_u8(arg: int, /) -> int: ... +class kw_only_methods: + def __init__(self, *, v: int) -> None: ... + + @property + def v(self) -> int: ... + + @v.setter + def v(self, arg: int, /) -> None: ... + + def method_2k(self, *, i: int = 1, j: int = 2) -> tuple: ... + + def method_1p1k(self, i: int = 1, *, j: int = 2) -> tuple: ... + def test_01() -> None: ... def test_02(j: int = 8, k: int = 1) -> int: ... @@ -114,6 +127,10 @@ def test_34(self, y: int) -> int: ... def test_35() -> object: ... +def test_args_kwonly(i: int, j: float, *args, z: int) -> tuple: ... + +def test_args_kwonly_kwargs(i: int, j: float, *args, z: int, **kwargs) -> tuple: ... + def test_bad_tuple() -> tuple: ... def test_call_1(arg: object, /) -> object: ... @@ -140,6 +157,16 @@ def test_iter_list(arg: list, /) -> list: ... def test_iter_tuple(arg: tuple, /) -> list: ... +def test_kw_only_all(*, i: int, j: int) -> tuple: ... + +def test_kw_only_mixed(i: int, *, j: int) -> tuple: ... + +def test_kw_only_some(arg0: int, *, j: int, k: int) -> tuple: ... + +def test_kw_only_with_defaults(arg0: int = 3, j: int = 4, *, k: int = 5, z: int) -> tuple: ... + +def test_kwonly_kwargs(i: int, *, j: float, **kwargs) -> tuple: ... + def test_list(arg: list, /) -> None: ... def test_print() -> None: ...