chiark / gitweb /
documentation/python: recognize / and * in pybind11 signatures.
authorVladimír Vondruš <mosra@centrum.cz>
Wed, 28 Jun 2023 12:11:52 +0000 (14:11 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Wed, 28 Jun 2023 14:01:01 +0000 (16:01 +0200)
documentation/python.py
documentation/test_python/pybind_signatures/pybind_signatures.MyClass26.html [new file with mode: 0644]
documentation/test_python/pybind_signatures/pybind_signatures.cpp
documentation/test_python/pybind_signatures/pybind_signatures.html
documentation/test_python/test_pybind.py

index 00faac6819ef923c9641b74a35d93ab03d6cfbe2..8aa3afa54d28568fa1e54c602506c0065b92f10a 100755 (executable)
@@ -813,7 +813,7 @@ def make_name_link(state: State, referrer_path: List[str], name) -> str:
     return '<a href="{}" class="{}">{}</a>'.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 (file)
index 0000000..f2f5565
--- /dev/null
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>pybind_signatures.MyClass26 | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="pybind_signatures.html">pybind_signatures</a>.<wbr/></span>MyClass26 <span class="m-thin">class</span>
+        </h1>
+        <p>Testing pybind 2.6 features</p>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#staticmethods">Static methods</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="staticmethods">
+          <h2><a href="#staticmethods">Static methods</a></h2>
+          <dl class="m-doc">
+            <dt id="keyword_only">
+              <span class="m-doc-wrap-bumper">def <a href="#keyword_only" class="m-doc-self">keyword_only</a>(</span><span class="m-doc-wrap">b: float,<span class="m-text m-dim"> *,</span>
+              keyword: str = &#x27;no&#x27;) -&gt; int</span>
+            </dt>
+            <dd>Keyword-only arguments</dd>
+            <dt id="positional_keyword_only">
+              <span class="m-doc-wrap-bumper">def <a href="#positional_keyword_only" class="m-doc-self">positional_keyword_only</a>(</span><span class="m-doc-wrap">a: int<span class="m-text m-dim">, /</span>,
+              b: float,<span class="m-text m-dim"> *,</span>
+              keyword: str = &#x27;no&#x27;) -&gt; int</span>
+            </dt>
+            <dd>Positional and keyword-only arguments</dd>
+            <dt id="positional_only">
+              <span class="m-doc-wrap-bumper">def <a href="#positional_only" class="m-doc-self">positional_only</a>(</span><span class="m-doc-wrap">a: int<span class="m-text m-dim">, /</span>,
+              b: float) -&gt; int</span>
+            </dt>
+            <dd>Positional-only arguments</dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt id="is_pybind26">
+              <a href="#is_pybind26" class="m-doc-self">is_pybind26</a> = True
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
index bc8c9c345c1b363c57997179879bd54dbd9eef5b..595d5685ed87112a856e14a3af371484476686c3 100644 (file)
@@ -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<class T, class U> 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_<MyClass26> 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
 }
index 687d6b00b55906330e27433c205a579caebf04e2..43ef942b50abf4ea20a659a8b640305e60f545dc 100644 (file)
@@ -42,6 +42,8 @@
             <dd>My fun class!</dd>
             <dt>class <a href="pybind_signatures.MyClass23.html" class="m-doc">MyClass23</a></dt>
             <dd>Testing pybind 2.3 features</dd>
+            <dt>class <a href="pybind_signatures.MyClass26.html" class="m-doc">MyClass26</a></dt>
+            <dd>Testing pybind 2.6 features</dd>
           </dl>
         </section>
         <section id="functions">
index 3f13206eb65995e5e752ea6dc41119cb1fc66f09..2905aa61a8b6e48e04cceb3fdc38a518370c5985 100644 (file)
@@ -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):