From: Vladimír Vondruš Date: Sat, 13 Jul 2019 20:04:17 +0000 (+0200) Subject: documentation/python: support write-only properties. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~cjwatson/git?a=commitdiff_plain;h=9e65ab305dac23d9be08d3e344c10e9370ec0a7f;p=blog.git documentation/python: support write-only properties. Supported by Python through a slightly crazy syntax and in pybind11 since 2.3. --- diff --git a/doc/documentation/python.rst b/doc/documentation/python.rst index 0d21fb6c..200fb852 100644 --- a/doc/documentation/python.rst +++ b/doc/documentation/python.rst @@ -1090,7 +1090,8 @@ Property Description :py:`property.id` Property ID [4]_ :py:`property.type` Property getter return type annotation [1]_ :py:`property.summary` Doc summary -:py:`property.is_writable` If the property is writable +:py:`property.is_gettable` If the property is gettable +:py:`property.is_settable` If the property is settable :py:`property.is_deletable` If the property is deletable with :py:`del` :py:`property.has_details` If there is enough content for the full description block. Currently always set to diff --git a/documentation/python.py b/documentation/python.py index 4f35ddc8..32c7cb50 100755 --- a/documentation/python.py +++ b/documentation/python.py @@ -1027,31 +1027,65 @@ def extract_property_doc(state: State, path: List[str], property): out.name = path[-1] out.id = state.config['ID_FORMATTER'](EntryType.PROPERTY, path[-1:]) # TODO: external summary for properties - out.summary = extract_summary(state, {}, [], property.__doc__) + out.is_gettable = property.fget is not None + if property.fget or (property.fset and property.__doc__): + out.summary = extract_summary(state, {}, [], property.__doc__) + else: + assert property.fset + out.summary = extract_summary(state, {}, [], property.fset.__doc__) out.is_settable = property.fset is not None out.is_deletable = property.fdel is not None out.has_details = False + # For the type, if the property is gettable, get it from getters's return + # type. For write-only properties get it from setter's second argument + # annotation. + try: - signature = inspect.signature(property.fget) + if property.fget: + signature = inspect.signature(property.fget) + + # First try to get fully dereferenced type hints (with strings + # converted to actual annotations). If that fails (e.g. because a + # type doesn't exist), we'll take the non-dereferenced annotations + # from inspect instead. This is deliberately done *after* + # inspecting the signature because pybind11 properties would throw + # TypeError from typing.get_type_hints(). This way they throw + # ValueError from inspect and we don't need to handle TypeError in + # get_type_hints_or_nothing(). + type_hints = get_type_hints_or_nothing(state, path, property.fget) - # First try to get fully dereferenced type hints (with strings - # converted to actual annotations). If that fails (e.g. because a type - # doesn't exist), we'll take the non-dereferenced annotations from - # inspect instead. This is deliberately done *after* inspecting the - # signature because pybind11 properties would throw TypeError from - # typing.get_type_hints(). This way they throw ValueError from inspect - # and we don't need to handle TypeError in get_type_hints_or_nothing(). - if property.fget: type_hints = get_type_hints_or_nothing(state, path, property.fget) - - if 'return' in type_hints: - out.type = extract_annotation(state, path, type_hints['return']) + if 'return' in type_hints: + out.type = extract_annotation(state, path, type_hints['return']) + else: + out.type = extract_annotation(state, path, signature.return_annotation) else: - out.type = extract_annotation(state, path, signature.return_annotation) + assert property.fset + signature = inspect.signature(property.fset) + + # Same as the lengthy comment above + type_hints = get_type_hints_or_nothing(state, path, property.fset) + + # Get second parameter name, then try to fetch it from type_hints + # and if that fails get its annotation from the non-dereferenced + # version + value_parameter = list(signature.parameters.values())[1] + if value_parameter.name in type_hints: + out.type = extract_annotation(state, path, type_hints[value_parameter.name]) + else: + out.type = extract_annotation(state, path, value_parameter.annotation) + except ValueError: # pybind11 properties have the type in the docstring if state.config['PYBIND11_COMPATIBILITY']: - out.type = parse_pybind_signature(state, path, property.fget.__doc__)[3] + if property.fget: + out.type = parse_pybind_signature(state, path, property.fget.__doc__)[3] + else: + assert property.fset + parsed_args = parse_pybind_signature(state, path, property.fset.__doc__)[2] + # If argument parsing failed, we're screwed + if len(parsed_args) == 1: out.type = None + else: out.type = parsed_args[1][2] else: out.type = None diff --git a/documentation/templates/python/entry-property.html b/documentation/templates/python/entry-property.html index b608938a..ebaa1ea1 100644 --- a/documentation/templates/python/entry-property.html +++ b/documentation/templates/python/entry-property.html @@ -1,4 +1,4 @@
- {{ property.name }}{% if property.type %}: {{ property.type }}{% endif %} get{% if property.is_settable %} set{% endif %}{% if property.is_deletable %} del{% endif %} + {{ property.name }}{% if property.type %}: {{ property.type }}{% endif %} {% if property.is_gettable and property.is_settable %}get set{% elif property.is_gettable %}get{% else %}set{% endif %}{% if property.is_deletable %} del{% endif %}
{{ property.summary }}
diff --git a/documentation/test_python/inspect_string/inspect_string.Foo.html b/documentation/test_python/inspect_string/inspect_string.Foo.html index ead3e321..a029bd11 100644 --- a/documentation/test_python/inspect_string/inspect_string.Foo.html +++ b/documentation/test_python/inspect_string/inspect_string.Foo.html @@ -117,6 +117,10 @@ writable_property get set
Writable property
+
+ writeonly_property set +
+
Write-only property
diff --git a/documentation/test_python/inspect_string/inspect_string/__init__.py b/documentation/test_python/inspect_string/inspect_string/__init__.py index 3c5a019b..425511bc 100644 --- a/documentation/test_python/inspect_string/inspect_string/__init__.py +++ b/documentation/test_python/inspect_string/inspect_string/__init__.py @@ -96,6 +96,12 @@ class Foo: def deletable_property(self): pass + def writeonly_property(self, a): + """Write-only property""" + pass + + writeonly_property = property(None, writeonly_property) + @property def _private_property(self): """A private property""" diff --git a/documentation/test_python/inspect_type_links/inspect_type_links.second.Foo.html b/documentation/test_python/inspect_type_links/inspect_type_links.second.Foo.html index 42c1277d..33ea06d9 100644 --- a/documentation/test_python/inspect_type_links/inspect_type_links.second.Foo.html +++ b/documentation/test_python/inspect_type_links/inspect_type_links.second.Foo.html @@ -50,6 +50,18 @@ type_property_string_nested: typing.Tuple[Foo, typing.List[Enum], typing.Any] get
A property
+
+ type_property_writeonly: Enum set +
+
A writeonly property
+
+ type_property_writeonly_string_invalid: Foo.Bar set +
+
A writeonly property with invalid string type
+
+ type_property_writeonly_string_nested: typing.Tuple[Foo, typing.List[Enum], typing.Any] set +
+
A writeonly property with a string nested type
diff --git a/documentation/test_python/inspect_type_links/inspect_type_links/second.py b/documentation/test_python/inspect_type_links/inspect_type_links/second.py index 6ac7f75d..b77762cc 100644 --- a/documentation/test_python/inspect_type_links/inspect_type_links/second.py +++ b/documentation/test_python/inspect_type_links/inspect_type_links/second.py @@ -28,6 +28,18 @@ class Foo: def type_property_string_invalid(self) -> 'FooBar': """A property""" + def type_property_writeonly(self, a: Enum): + """A writeonly property""" + type_property_writeonly = property(None, type_property_writeonly) + + def type_property_writeonly_string_nested(self, a: 'Tuple[Foo, List[Enum], Any]'): + """A writeonly property with a string nested type""" + type_property_writeonly_string_nested = property(None, type_property_writeonly_string_nested) + + def type_property_writeonly_string_invalid(self, a: 'Foo.Bar'): + """A writeonly property with invalid string type""" + type_property_writeonly_string_invalid = property(None, type_property_writeonly_string_invalid) + # Has to be here, because if it would be globally, it would prevent all # other data annotations from being retrieved TYPE_DATA_STRING_INVALID: 'Foo.Bar' = 3 diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.MyClass23.html b/documentation/test_python/pybind_signatures/pybind_signatures.MyClass23.html new file mode 100644 index 00000000..4bb551e2 --- /dev/null +++ b/documentation/test_python/pybind_signatures/pybind_signatures.MyClass23.html @@ -0,0 +1,65 @@ + + + + + pybind_signatures.MyClass23 | My Python Project + + + + + +
+
+ + diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.cpp b/documentation/test_python/pybind_signatures/pybind_signatures.cpp index 79cd63e4..ab69133d 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.cpp +++ b/documentation/test_python/pybind_signatures/pybind_signatures.cpp @@ -33,6 +33,12 @@ struct MyClass { private: float _foo = 0.0f; }; +struct MyClass23 { + void setFoo(float) {} + + void setFooCrazy(const Crazy<3, int>&) {} +}; + void duck(py::args, py::kwargs) {} template void tenOverloads(T, U) {} @@ -68,4 +74,22 @@ PYBIND11_MODULE(pybind_signatures, m) { .def("instance_function_kwargs", &MyClass::instanceFunction, "Instance method with position or keyword args", py::arg("hey"), py::arg("what") = "") .def("another", &MyClass::another, "Instance method with no args, 'self' is thus position-only") .def_property("foo", &MyClass::foo, &MyClass::setFoo, "A read/write property"); + + py::class_ pybind23{m, "MyClass23", "Testing pybind 2.3 features"}; + + /* Checker so the Python side can detect if testing pybind 2.3 features is + feasible */ + pybind23.attr("is_pybind23") = + #if PYBIND11_VERSION_MAJOR*100 + PYBIND11_VERSION_MINOR >= 203 + true + #else + false + #endif + ; + + #if PYBIND11_VERSION_MAJOR*100 + PYBIND11_VERSION_MINOR >= 203 + pybind23 + .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 } diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.html b/documentation/test_python/pybind_signatures/pybind_signatures.html index 6e31c86b..f610009c 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.html +++ b/documentation/test_python/pybind_signatures/pybind_signatures.html @@ -40,6 +40,8 @@
class MyClass
My fun class!
+
class MyClass23
+
Testing pybind 2.3 features
diff --git a/documentation/test_python/test_pybind.py b/documentation/test_python/test_pybind.py index b4fd37ad..68d1b563 100644 --- a/documentation/test_python/test_pybind.py +++ b/documentation/test_python/test_pybind.py @@ -192,6 +192,11 @@ class Signatures(BaseInspectTestCase): self.assertEqual(*self.actual_expected_contents('pybind_signatures.html')) self.assertEqual(*self.actual_expected_contents('pybind_signatures.MyClass.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')) + class Enums(BaseInspectTestCase): def __init__(self, *args, **kwargs): super().__init__(__file__, 'enums', *args, **kwargs)