From a23e2ffe73c7505ec73f8fc1ad68b384b253a8fc Mon Sep 17 00:00:00 2001 From: Ashley Whetter Date: Tue, 27 Sep 2022 15:57:44 -0700 Subject: [PATCH] Render Python properties with the property directive Fixes #352. --- CHANGELOG.rst | 14 +- autoapi/documenters.py | 32 ++- autoapi/mappers/python/__init__.py | 1 + autoapi/mappers/python/mapper.py | 5 +- autoapi/mappers/python/objects.py | 44 +++- autoapi/mappers/python/parser.py | 16 +- autoapi/templates/python/class.rst | 8 + autoapi/templates/python/function.rst | 3 - autoapi/templates/python/method.rst | 8 - autoapi/templates/python/property.rst | 15 ++ docs/reference/templates.rst | 4 + setup.cfg | 2 +- .../pyexample/autoapi/example/index.rst | 185 ++++++++++++++++ tests/python/pyexample/autoapi/index.rst | 11 + tests/python/pyexample/conf.py | 1 + tests/python/pyexample/example/example.py | 5 + .../pymovedconfpy/autoapi/example/index.rst | 207 ++++++++++++++++++ tests/python/pymovedconfpy/autoapi/index.rst | 11 + tests/python/test_pyintegration.py | 3 + 19 files changed, 530 insertions(+), 45 deletions(-) create mode 100644 autoapi/templates/python/property.rst create mode 100644 tests/python/pyexample/autoapi/example/index.rst create mode 100644 tests/python/pyexample/autoapi/index.rst create mode 100644 tests/python/pymovedconfpy/autoapi/example/index.rst create mode 100644 tests/python/pymovedconfpy/autoapi/index.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5f20d235..f9eb23ba 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,9 +3,21 @@ Changelog Versions follow `Semantic Versioning `_ (``..``). -v1.9.1 (TBC) +v2.0.0 (TBC) ------------ +Breaking Changes +^^^^^^^^^^^^^^^^ + +* Dropped support for Sphinx <4. +* `#352 `: (Python) + Properties are rendered with the ``property`` directive, + fixing support for Sphinx 5.2. + A new ``PythonPythonMapper`` object (``PythonProperty``) has been created + to support this change. This object can be passed to templates, filters, + and hooks. + A new ``property.rst`` template has also been created to support this change. + Trivial/Internal Changes ^^^^^^^^^^^^^^^^^^^^^^^^ * Use https links where possible in documentation. diff --git a/autoapi/documenters.py b/autoapi/documenters.py index c92a9fc2..ed37e7a7 100644 --- a/autoapi/documenters.py +++ b/autoapi/documenters.py @@ -6,6 +6,7 @@ PythonFunction, PythonClass, PythonMethod, + PythonProperty, PythonData, PythonAttribute, PythonException, @@ -192,8 +193,11 @@ def import_object(self): if result: self.parent = self._method_parent - if self.object.method_type != "method": - # document class and static members before ordinary ones + if "staticmethod" in self.object.properties: + # document static members before ordinary ones + self.member_order = self.member_order - 2 + elif "classmethod" in self.object.properties: + # document class members before ordinary ones but after static ones self.member_order = self.member_order - 1 return result @@ -212,22 +216,28 @@ def add_directive_header(self, sig): self.add_line(" :{}:".format(property_type), sourcename) -class AutoapiPropertyDocumenter( - AutoapiMethodDocumenter, AutoapiDocumenter, autodoc.PropertyDocumenter -): +class AutoapiPropertyDocumenter(AutoapiDocumenter, autodoc.PropertyDocumenter): objtype = "apiproperty" - directivetype = "method" - # Always prefer AutoapiDocumenters - priority = autodoc.MethodDocumenter.priority * 100 + 100 + 1 + directivetype = "property" + priority = autodoc.PropertyDocumenter.priority * 100 + 100 @classmethod def can_document_member(cls, member, membername, isattr, parent): - return isinstance(member, PythonMethod) and "property" in member.properties + return isinstance(member, PythonProperty) def add_directive_header(self, sig): - super(AutoapiPropertyDocumenter, self).add_directive_header(sig) + autodoc.ClassLevelDocumenter.add_directive_header(self, sig) + sourcename = self.get_sourcename() - self.add_line(" :property:", sourcename) + if self.options.annotation and self.options.annotation is not autodoc.SUPPRESS: + self.add_line(" :type: %s" % self.options.annotation, sourcename) + + for property_type in ( + "abstractmethod", + "classmethod", + ): + if property_type in self.object.properties: + self.add_line(" :{}:".format(property_type), sourcename) class AutoapiDataDocumenter(AutoapiDocumenter, autodoc.DataDocumenter): diff --git a/autoapi/mappers/python/__init__.py b/autoapi/mappers/python/__init__.py index 2be0cae6..cef10217 100644 --- a/autoapi/mappers/python/__init__.py +++ b/autoapi/mappers/python/__init__.py @@ -5,6 +5,7 @@ PythonModule, PythonMethod, PythonPackage, + PythonProperty, PythonAttribute, PythonData, PythonException, diff --git a/autoapi/mappers/python/mapper.py b/autoapi/mappers/python/mapper.py index 64a15b39..3b87e4ed 100644 --- a/autoapi/mappers/python/mapper.py +++ b/autoapi/mappers/python/mapper.py @@ -19,6 +19,7 @@ PythonModule, PythonMethod, PythonPackage, + PythonProperty, PythonAttribute, PythonData, PythonException, @@ -237,12 +238,12 @@ class PythonSphinxMapper(SphinxMapperBase): PythonModule, PythonMethod, PythonPackage, + PythonProperty, PythonAttribute, PythonData, PythonException, ) } - _OBJ_MAP["property"] = PythonMethod def __init__(self, app, template_dir=None, url_root=None): super(PythonSphinxMapper, self).__init__(app, template_dir, url_root) @@ -421,7 +422,7 @@ def _record_typehints(self, obj): if ( isinstance(obj, (PythonClass, PythonFunction, PythonMethod)) and not obj.overloads - ): + ) or isinstance(obj, PythonProperty): obj_annotations = {} include_return_annotation = True diff --git a/autoapi/mappers/python/objects.py b/autoapi/mappers/python/objects.py index d3324be0..999d0ee4 100644 --- a/autoapi/mappers/python/objects.py +++ b/autoapi/mappers/python/objects.py @@ -169,7 +169,7 @@ class PythonFunction(PythonPythonMapper): type = "function" is_callable = True - member_order = 40 + member_order = 30 def __init__(self, obj, **kwargs): super(PythonFunction, self).__init__(obj, **kwargs) @@ -219,13 +219,6 @@ class PythonMethod(PythonFunction): def __init__(self, obj, **kwargs): super(PythonMethod, self).__init__(obj, **kwargs) - self.method_type = obj.get("method_type") - """The type of method that this object represents. - - This can be one of: method, staticmethod, or classmethod. - - :type: str - """ self.properties = obj["properties"] """The properties that describe what type of method this is. @@ -242,11 +235,34 @@ def _should_skip(self): # type: () -> bool return self._ask_ignore(skip) +class PythonProperty(PythonPythonMapper): + """The representation of a property on a class.""" + + type = "property" + member_order = 60 + + def __init__(self, obj, **kwargs): + super(PythonProperty, self).__init__(obj, **kwargs) + + self.annotation = obj["return_annotation"] + """The type annotation of this property. + + :type: str or None + """ + self.properties = obj["properties"] + """The properties that describe what type of property this is. + + Can be any of: abstractmethod, classmethod + + :type: list(str) + """ + + class PythonData(PythonPythonMapper): """Global, module level data.""" type = "data" - member_order = 10 + member_order = 40 def __init__(self, obj, **kwargs): super(PythonData, self).__init__(obj, **kwargs) @@ -272,7 +288,7 @@ class PythonAttribute(PythonData): """An object/class level attribute.""" type = "attribute" - member_order = 10 + member_order = 60 class TopLevelPythonPythonMapper(PythonPythonMapper): @@ -335,7 +351,7 @@ class PythonClass(PythonPythonMapper): """The representation of a class.""" type = "class" - member_order = 30 + member_order = 20 def __init__(self, obj, **kwargs): super(PythonClass, self).__init__(obj, **kwargs) @@ -409,6 +425,10 @@ def docstring(self, value): def methods(self): return self._children_of_type("method") + @property + def properties(self): + return self._children_of_type("property") + @property def attributes(self): return self._children_of_type("attribute") @@ -446,4 +466,4 @@ class PythonException(PythonClass): """The representation of an exception class.""" type = "exception" - member_order = 20 + member_order = 10 diff --git a/autoapi/mappers/python/parser.py b/autoapi/mappers/python/parser.py index e14b81ed..41d1ba59 100644 --- a/autoapi/mappers/python/parser.py +++ b/autoapi/mappers/python/parser.py @@ -150,18 +150,23 @@ def parse_functiondef(self, node): # pylint: disable=too-many-branches if node.type == "function": type_ = "function" + + if isinstance(node, astroid.AsyncFunctionDef): + properties.append("async") elif astroid_utils.is_decorated_with_property(node): type_ = "property" - properties.append("property") + if node.type == "classmethod": + properties.append(node.type) + if node.is_abstract(pass_is_abstract=False): + properties.append("abstractmethod") else: # "__new__" method is implicit classmethod if node.type in ("staticmethod", "classmethod") and node.name != "__new__": properties.append(node.type) if node.is_abstract(pass_is_abstract=False): properties.append("abstractmethod") - - if isinstance(node, astroid.AsyncFunctionDef): - properties.append("async") + if isinstance(node, astroid.AsyncFunctionDef): + properties.append("async") data = { "type": type_, @@ -177,9 +182,6 @@ def parse_functiondef(self, node): # pylint: disable=too-many-branches "overloads": [], } - if type_ in ("method", "property"): - data["method_type"] = node.type - result = [data] if node.name == "__init__": diff --git a/autoapi/templates/python/class.rst b/autoapi/templates/python/class.rst index a791e3d6..df5edffb 100644 --- a/autoapi/templates/python/class.rst +++ b/autoapi/templates/python/class.rst @@ -32,6 +32,14 @@ {{ klass.render()|indent(3) }} {% endfor %} {% if "inherited-members" in autoapi_options %} + {% set visible_properties = obj.properties|selectattr("display")|list %} + {% else %} + {% set visible_properties = obj.properties|rejectattr("inherited")|selectattr("display")|list %} + {% endif %} + {% for property in visible_properties %} + {{ property.render()|indent(3) }} + {% endfor %} + {% if "inherited-members" in autoapi_options %} {% set visible_attributes = obj.attributes|selectattr("display")|list %} {% else %} {% set visible_attributes = obj.attributes|rejectattr("inherited")|selectattr("display")|list %} diff --git a/autoapi/templates/python/function.rst b/autoapi/templates/python/function.rst index 6db8515c..b00d5c24 100644 --- a/autoapi/templates/python/function.rst +++ b/autoapi/templates/python/function.rst @@ -5,14 +5,11 @@ {{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %} {% endfor %} - {% if sphinx_version >= (2, 1) %} {% for property in obj.properties %} :{{ property }}: {% endfor %} - {% endif %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} - {% else %} {% endif %} {% endif %} diff --git a/autoapi/templates/python/method.rst b/autoapi/templates/python/method.rst index ff1b7767..723cb7bb 100644 --- a/autoapi/templates/python/method.rst +++ b/autoapi/templates/python/method.rst @@ -1,5 +1,4 @@ {%- if obj.display %} -{% if sphinx_version >= (2, 1) %} .. py:method:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %} {% for (args, return_annotation) in obj.overloads %} @@ -14,13 +13,6 @@ {% else %} {% endif %} -{% else %} -.. py:{{ obj.method_type }}:: {{ obj.short_name }}({{ obj.args }}) -{% for (args, return_annotation) in obj.overloads %} - {{ " " * (obj.method_type | length) }} {{ obj.short_name }}({{ args }}) -{% endfor %} - -{% endif %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} {% endif %} diff --git a/autoapi/templates/python/property.rst b/autoapi/templates/python/property.rst new file mode 100644 index 00000000..70af2423 --- /dev/null +++ b/autoapi/templates/python/property.rst @@ -0,0 +1,15 @@ +{%- if obj.display %} +.. py:property:: {{ obj.short_name }} + {% if obj.annotation %} + :type: {{ obj.annotation }} + {% endif %} + {% if obj.properties %} + {% for property in obj.properties %} + :{{ property }}: + {% endfor %} + {% endif %} + + {% if obj.docstring %} + {{ obj.docstring|indent(3) }} + {% endif %} +{% endif %} diff --git a/docs/reference/templates.rst b/docs/reference/templates.rst index 5439bedc..81884f1b 100644 --- a/docs/reference/templates.rst +++ b/docs/reference/templates.rst @@ -73,6 +73,10 @@ Python :members: :show-inheritance: +.. autoapiclass:: autoapi.mappers.python.objects.PythonProperty + :members: + :show-inheritance: + .. autoapiclass:: autoapi.mappers.python.objects.PythonData :members: :show-inheritance: diff --git a/setup.cfg b/setup.cfg index 291c32c6..db508f73 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ install_requires = astroid>=2.7 Jinja2 PyYAML - sphinx>=3.0 + sphinx>=4.0 unidecode [options.extras_require] diff --git a/tests/python/pyexample/autoapi/example/index.rst b/tests/python/pyexample/autoapi/example/index.rst new file mode 100644 index 00000000..2d6946b9 --- /dev/null +++ b/tests/python/pyexample/autoapi/example/index.rst @@ -0,0 +1,185 @@ +:py:mod:`example` +================= + +.. py:module:: example + +.. autoapi-nested-parse:: + + Example module + + This is a description + + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + example.Foo + example.Bar + example.ClassWithNoInit + example.One + example.MultilineOne + example.Two + + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + example.decorator_okay + example.fn_with_long_sig + + + +.. py:class:: Foo(attr) + + Bases: :py:obj:`object` + + This is using custom filters. + .. py:class:: Meta + + Bases: :py:obj:`object` + + This is using custom filters. + .. py:method:: foo() + :classmethod: + + The foo class method + + + + .. py:property:: property_simple + :type: int + + This property should parse okay. + + + .. py:attribute:: class_var + :annotation: = 42 + + + + .. py:attribute:: another_class_var + :annotation: = 42 + + Another class var docstring + + + .. py:attribute:: attr2 + + + This is the docstring of an instance attribute. + + :type: str + + + .. py:method:: method_okay(foo=None, bar=None) + + This method should parse okay + + + .. py:method:: method_multiline(foo=None, bar=None, baz=None) + + This is on multiple lines, but should parse okay too + + pydocstyle gives us lines of source. Test if this means that multiline + definitions are covered in the way we're anticipating here + + + .. py:method:: method_tricky(foo=None, bar=dict(foo=1, bar=2)) + + This will likely fail our argument testing + + We parse naively on commas, so the nested dictionary will throw this off + + + .. py:method:: method_sphinx_docs(foo, bar=0) + + This method is documented with sphinx style docstrings. + + :param foo: The first argument. + :type foo: int + + :param int bar: The second argument. + + :returns: The sum of foo and bar. + :rtype: int + + + .. py:method:: method_google_docs(foo, bar=0) + + This method is documented with google style docstrings. + + Args: + foo (int): The first argument. + bar (int): The second argument. + + Returns: + int: The sum of foo and bar. + + + .. py:method:: method_sphinx_unicode() + + This docstring uses unicodé. + + :returns: A string. + :rtype: str + + + .. py:method:: method_google_unicode() + + This docstring uses unicodé. + + Returns: + str: A string. + + + +.. py:function:: decorator_okay(func) + + This decorator should parse okay. + + +.. py:class:: Bar(attr) + + Bases: :py:obj:`Foo` + + This is using custom filters. + .. py:method:: method_okay(foo=None, bar=None) + + This method should parse okay + + + +.. py:class:: ClassWithNoInit + + This is using custom filters. + +.. py:class:: One + + This is using custom filters. + +.. py:class:: MultilineOne + + Bases: :py:obj:`One` + + This is using custom filters. + +.. py:class:: Two + + Bases: :py:obj:`One` + + This is using custom filters. + +.. py:function:: fn_with_long_sig(this, *, function=None, has=True, quite=True, a, long, signature, many, keyword, arguments) + + A function with a long signature. + + diff --git a/tests/python/pyexample/autoapi/index.rst b/tests/python/pyexample/autoapi/index.rst new file mode 100644 index 00000000..55f9ebe2 --- /dev/null +++ b/tests/python/pyexample/autoapi/index.rst @@ -0,0 +1,11 @@ +API Reference +============= + +This page contains auto-generated API reference documentation [#f1]_. + +.. toctree:: + :titlesonly: + + /autoapi/example/index + +.. [#f1] Created with `sphinx-autoapi `_ \ No newline at end of file diff --git a/tests/python/pyexample/conf.py b/tests/python/pyexample/conf.py index 7a658e10..c85c83d8 100644 --- a/tests/python/pyexample/conf.py +++ b/tests/python/pyexample/conf.py @@ -19,3 +19,4 @@ autoapi_dirs = ["example"] autoapi_file_pattern = "*.py" autoapi_python_class_content = "both" +autoapi_keep_files = True diff --git a/tests/python/pyexample/example/example.py b/tests/python/pyexample/example/example.py index 4b00265e..74cda18e 100644 --- a/tests/python/pyexample/example/example.py +++ b/tests/python/pyexample/example/example.py @@ -34,6 +34,11 @@ def __init__(self, attr): :type: str """ + @property + def property_simple(self) -> int: + """This property should parse okay.""" + return 42 + def method_okay(self, foo=None, bar=None): """This method should parse okay""" return True diff --git a/tests/python/pymovedconfpy/autoapi/example/index.rst b/tests/python/pymovedconfpy/autoapi/example/index.rst new file mode 100644 index 00000000..0896e078 --- /dev/null +++ b/tests/python/pymovedconfpy/autoapi/example/index.rst @@ -0,0 +1,207 @@ +:py:mod:`example` +================= + +.. py:module:: example + +.. autoapi-nested-parse:: + + Example module + + This is a description + + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + example.Foo + example.Bar + example.ClassWithNoInit + example.One + example.MultilineOne + example.Two + + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + example.decorator_okay + example.fn_with_long_sig + + + +.. py:class:: Foo(attr) + + Bases: :py:obj:`object` + + Can we parse arguments from the class docstring? + + :param attr: Set an attribute. + :type attr: str + + Constructor docstring + + .. py:class:: Meta + + Bases: :py:obj:`object` + + A nested class just to test things out + + .. py:method:: foo() + :classmethod: + + The foo class method + + + + .. py:property:: property_simple + :type: int + + This property should parse okay. + + + .. py:attribute:: class_var + :annotation: = 42 + + + + .. py:attribute:: another_class_var + :annotation: = 42 + + Another class var docstring + + + .. py:attribute:: attr2 + + + This is the docstring of an instance attribute. + + :type: str + + + .. py:method:: method_okay(foo=None, bar=None) + + This method should parse okay + + + .. py:method:: method_multiline(foo=None, bar=None, baz=None) + + This is on multiple lines, but should parse okay too + + pydocstyle gives us lines of source. Test if this means that multiline + definitions are covered in the way we're anticipating here + + + .. py:method:: method_tricky(foo=None, bar=dict(foo=1, bar=2)) + + This will likely fail our argument testing + + We parse naively on commas, so the nested dictionary will throw this off + + + .. py:method:: method_sphinx_docs(foo, bar=0) + + This method is documented with sphinx style docstrings. + + :param foo: The first argument. + :type foo: int + + :param int bar: The second argument. + + :returns: The sum of foo and bar. + :rtype: int + + + .. py:method:: method_google_docs(foo, bar=0) + + This method is documented with google style docstrings. + + Args: + foo (int): The first argument. + bar (int): The second argument. + + Returns: + int: The sum of foo and bar. + + + .. py:method:: method_sphinx_unicode() + + This docstring uses unicodé. + + :returns: A string. + :rtype: str + + + .. py:method:: method_google_unicode() + + This docstring uses unicodé. + + Returns: + str: A string. + + + +.. py:function:: decorator_okay(func) + + This decorator should parse okay. + + +.. py:class:: Bar(attr) + + Bases: :py:obj:`Foo` + + Can we parse arguments from the class docstring? + + :param attr: Set an attribute. + :type attr: str + + Constructor docstring + + .. py:method:: method_okay(foo=None, bar=None) + + This method should parse okay + + + +.. py:class:: ClassWithNoInit + + +.. py:class:: One + + One. + + One __init__. + + +.. py:class:: MultilineOne + + Bases: :py:obj:`One` + + This is a naughty summary line + that exists on two lines. + + One __init__. + + +.. py:class:: Two + + Bases: :py:obj:`One` + + Two. + + One __init__. + + +.. py:function:: fn_with_long_sig(this, *, function=None, has=True, quite=True, a, long, signature, many, keyword, arguments) + + A function with a long signature. + + diff --git a/tests/python/pymovedconfpy/autoapi/index.rst b/tests/python/pymovedconfpy/autoapi/index.rst new file mode 100644 index 00000000..55f9ebe2 --- /dev/null +++ b/tests/python/pymovedconfpy/autoapi/index.rst @@ -0,0 +1,11 @@ +API Reference +============= + +This page contains auto-generated API reference documentation [#f1]_. + +.. toctree:: + :titlesonly: + + /autoapi/example/index + +.. [#f1] Created with `sphinx-autoapi `_ \ No newline at end of file diff --git a/tests/python/test_pyintegration.py b/tests/python/test_pyintegration.py index 0fe759e6..0cc8b986 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -91,6 +91,9 @@ def check_integration(self, example_path): # "self" should not be included in constructor arguments assert "Foo(self" not in example_file + assert "property_simple" in example_file + assert "This property should parse okay." in example_file + # Overridden methods without their own docstring # should inherit the parent's docstring assert example_file.count("This method should parse okay") == 2