From: Vladimír Vondruš Date: Wed, 28 Jun 2023 12:11:52 +0000 (+0200) Subject: documentation/python: recognize / and * in pybind11 signatures. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~cjwatson/git?a=commitdiff_plain;h=962247e517b4f9381cfdafb4e268994d4edcb9a3;p=blog.git documentation/python: recognize / and * in pybind11 signatures. --- diff --git a/documentation/python.py b/documentation/python.py index 00faac68..8aa3afa5 100755 --- a/documentation/python.py +++ b/documentation/python.py @@ -813,7 +813,7 @@ def make_name_link(state: State, referrer_path: List[str], name) -> str: return '{}'.format(entry.url, ' '.join(entry.css_classes), relative_name) _pybind_name_rx = re.compile('[a-zA-Z0-9_]*') -_pybind_arg_name_rx = re.compile('[*a-zA-Z0-9_]+') +_pybind_arg_name_rx = re.compile('[/*a-zA-Z0-9_]+') _pybind_type_rx = re.compile('[a-zA-Z0-9_.]+') def _pybind11_extract_default_argument(string): @@ -1486,32 +1486,48 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]: else: out.is_staticmethod = False - # Guesstimate whether the arguments are positional-only or - # position-or-keyword. It's either all or none. This is a brown - # magic, sorry. - - # For instance methods positional-only argument names are either - # self (for the first argument) or arg(I-1) (for second - # argument and further). Also, the `self` argument is - # positional-or-keyword only if there are positional-or-keyword - # arguments afgter it, otherwise it's positional-only. - if inspect.isclass(parent) and not out.is_staticmethod: - assert args and args[0][0] == 'self' - - positional_only = True - for i, arg in enumerate(args[1:]): - if arg[0] != 'arg{}'.format(i): - positional_only = False - break - - # For static methods or free functions positional-only arguments - # are argI. + # If the arguments contain a literal * or / (which is only if + # py::pos_only{} or py::kw_only{} got explicitly used), it's + # following the usual logic: + for arg in args: + # If / is among the arguments, everything until the / is + # positional-only + if arg[0] == '/': + param_kind = 'POSITIONAL_ONLY' + break + # Otherwise, if * is among the arguments, everythign until the + # * is positional-or-keyword. Assuming pybind11 sanity, so + # not handling cases where * would be before / and such. + if arg[0] == '*': + param_kind = 'POSITIONAL_OR_KEYWORD' + break + + # If they don't contain either, guesstimate whether the arguments + # are positional-only or position-or-keyword. It's either all or none. + # This is a brown magic, sorry. else: - positional_only = True - for i, arg in enumerate(args): - if arg[0] != 'arg{}'.format(i): - positional_only = False - break + # For instance methods positional-only argument names are + # either self (for the first argument) or arg(I-1) (for second + # argument and further). Also, the `self` argument is + # positional-or-keyword only if there are positional-or-keyword + # arguments afgter it, otherwise it's positional-only. + if inspect.isclass(parent) and not out.is_staticmethod: + assert args and args[0][0] == 'self' + + param_kind = 'POSITIONAL_ONLY' + for i, arg in enumerate(args[1:]): + if arg[0] != 'arg{}'.format(i): + param_kind = 'POSITIONAL_OR_KEYWORD' + break + + # For static methods or free functions positional-only + # arguments are argI. + else: + param_kind = 'POSITIONAL_ONLY' + for i, arg in enumerate(args): + if arg[0] != 'arg{}'.format(i): + param_kind = 'POSITIONAL_OR_KEYWORD' + break param_names = [] param_types = [] @@ -1521,6 +1537,17 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]: param = Empty() param.name = arg_name param_names += [arg_name] + + # Skip * and / placeholders, update the param_kind instead + if arg_name == '/': + assert param_kind == 'POSITIONAL_ONLY' + param_kind = 'POSITIONAL_OR_KEYWORD' + continue + if arg_name == '*': + assert param_kind == 'POSITIONAL_OR_KEYWORD' + param_kind = 'KEYWORD_ONLY' + continue + # Don't include redundant type for the self argument if i == 0 and arg_name == 'self': param.type, param.type_link = None, None @@ -1530,6 +1557,7 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]: param.type, param.type_link = arg_type, arg_type_link param_types += [arg_type] signature += ['{}: {}'.format(arg_name, arg_type)] + if arg_default: # If the type is a registered enum, try to make a link to # the value -- for an enum of type `module.EnumType`, @@ -1552,7 +1580,7 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]: param.name = 'kwargs' param.kind = 'VAR_KEYWORD' else: - param.kind = 'POSITIONAL_ONLY' if positional_only else 'POSITIONAL_OR_KEYWORD' + param.kind = param_kind out.params += [param] diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.MyClass26.html b/documentation/test_python/pybind_signatures/pybind_signatures.MyClass26.html new file mode 100644 index 00000000..f2f55656 --- /dev/null +++ b/documentation/test_python/pybind_signatures/pybind_signatures.MyClass26.html @@ -0,0 +1,73 @@ + + + + + pybind_signatures.MyClass26 | My Python Project + + + + + +
+
+
+
+
+

+ pybind_signatures.MyClass26 class +

+

Testing pybind 2.6 features

+ +
+

Static methods

+
+
+ def keyword_only(b: float, *, + keyword: str = 'no') -> int +
+
Keyword-only arguments
+
+ def positional_keyword_only(a: int, /, + b: float, *, + keyword: str = 'no') -> int +
+
Positional and keyword-only arguments
+
+ def positional_only(a: int, /, + b: float) -> int +
+
Positional-only arguments
+
+
+
+

Data

+
+
+ is_pybind26 = True +
+
+
+
+
+
+
+
+ + diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.cpp b/documentation/test_python/pybind_signatures/pybind_signatures.cpp index bc8c9c34..595d5685 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.cpp +++ b/documentation/test_python/pybind_signatures/pybind_signatures.cpp @@ -45,6 +45,12 @@ struct MyClass23 { void setFooCrazy(const Crazy<3, int>&) {} }; +struct MyClass26 { + static int positionalOnly(int, float) { return 1; } + static int keywordOnly(float, const std::string&) { return 2; } + static int positionalKeywordOnly(int, float, const std::string&) { return 3; } +}; + void duck(py::args, py::kwargs) {} template void tenOverloads(T, U) {} @@ -117,4 +123,23 @@ could be another, but it's not added yet.)"); .def_property("writeonly", nullptr, &MyClass23::setFoo, "A write-only property") .def_property("writeonly_crazy", nullptr, &MyClass23::setFooCrazy, "A write-only property with a type that can't be parsed"); #endif + + py::class_ pybind26{m, "MyClass26", "Testing pybind 2.6 features"}; + + /* Checker so the Python side can detect if testing pybind 2.6 features is + feasible */ + pybind26.attr("is_pybind26") = + #if PYBIND11_VERSION_MAJOR*100 + PYBIND11_VERSION_MINOR >= 206 + true + #else + false + #endif + ; + + #if PYBIND11_VERSION_MAJOR*100 + PYBIND11_VERSION_MINOR >= 206 + pybind26 + .def_static("positional_only", &MyClass26::positionalOnly, "Positional-only arguments", py::arg("a"), py::pos_only{}, py::arg("b")) + .def_static("keyword_only", &MyClass26::keywordOnly, "Keyword-only arguments", py::arg("b"), py::kw_only{}, py::arg("keyword") = "no") + .def_static("positional_keyword_only", &MyClass26::positionalKeywordOnly, "Positional and keyword-only arguments", py::arg("a"), py::pos_only{}, py::arg("b"), py::kw_only{}, py::arg("keyword") = "no"); + #endif } diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.html b/documentation/test_python/pybind_signatures/pybind_signatures.html index 687d6b00..43ef942b 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.html +++ b/documentation/test_python/pybind_signatures/pybind_signatures.html @@ -42,6 +42,8 @@
My fun class!
class MyClass23
Testing pybind 2.3 features
+
class MyClass26
+
Testing pybind 2.6 features
diff --git a/documentation/test_python/test_pybind.py b/documentation/test_python/test_pybind.py index 3f13206e..2905aa61 100644 --- a/documentation/test_python/test_pybind.py +++ b/documentation/test_python/test_pybind.py @@ -128,6 +128,17 @@ class Signature(unittest.TestCase): ('**kwargs', None, None, None), ], None, None)) + def test_keyword_positional_only(self): + self.assertEqual(parse_pybind_signature(self.state, [], + 'foo(a: int, /, b: float, *, keyword: str)'), + ('foo', '', [ + ('a', 'int', 'int', None), + ('/', None, None, None), + ('b', 'float', 'float', None), + ('*', None, None, None), + ('keyword', 'str', 'str', None), + ], None, None)) + def test_default_values(self): self.assertEqual(parse_pybind_signature(self.state, [], 'foo(a: float = 1.0, b: str = \'hello\')'), @@ -298,6 +309,40 @@ class Signatures(BaseInspectTestCase): with self.assertRaises(TypeError): pybind_signatures.MyClass.another(self=a) + def test_explicit_positional_args(self): + sys.path.append(self.path) + import pybind_signatures + + # Similar to above, but these functions have explicit py::pos_only and + # py::kw_only placeholders + + if not pybind_signatures.MyClass26.is_pybind26: + self.skipTest("only on pybind 2.6+") + + # The a: int argument is always before the / and thus shouldn't be + # callable with a keyword + self.assertEqual(pybind_signatures.MyClass26.positional_only(1, 3.0), 1) + self.assertEqual(pybind_signatures.MyClass26.positional_keyword_only(1, 3.0), 3) + with self.assertRaises(TypeError): + pybind_signatures.MyClass26.positional_only(a=1, b=3.0) + with self.assertRaises(TypeError): + pybind_signatures.MyClass26.positional_keyword_only(a=1, b=3.0) + + # The b argument is always between / and * and thus should be callable + # both without (done above/below) and with + self.assertEqual(pybind_signatures.MyClass26.positional_only(1, b=3.0), 1) + self.assertEqual(pybind_signatures.MyClass26.keyword_only(b=3.0), 2) + self.assertEqual(pybind_signatures.MyClass26.positional_keyword_only(1, b=3.0), 3) + + # The keyword: str argument is always after the / and thus shouldn't be + # callable without a keyword + self.assertEqual(pybind_signatures.MyClass26.keyword_only(3.0, keyword='yes'), 2) + self.assertEqual(pybind_signatures.MyClass26.positional_keyword_only(1, 3.0, keyword='yes'), 3) + with self.assertRaises(TypeError): + pybind_signatures.MyClass26.keyword_only(3.0, 'yes') + with self.assertRaises(TypeError): + pybind_signatures.MyClass26.positional_keyword_only(1, 3.0, 'yes') + def test(self): sys.path.append(self.path) import pybind_signatures @@ -309,10 +354,10 @@ class Signatures(BaseInspectTestCase): self.assertEqual(*self.actual_expected_contents('pybind_signatures.MyClass.html')) self.assertEqual(*self.actual_expected_contents('false_positives.html')) - sys.path.append(self.path) - import pybind_signatures if pybind_signatures.MyClass23.is_pybind23: self.assertEqual(*self.actual_expected_contents('pybind_signatures.MyClass23.html')) + if pybind_signatures.MyClass26.is_pybind26: + self.assertEqual(*self.actual_expected_contents('pybind_signatures.MyClass26.html')) class Enums(BaseInspectTestCase): def test(self):