diff options
-rw-r--r-- | CHANGES | 11 | ||||
-rw-r--r-- | doc/usage/restructuredtext/domains.rst | 25 | ||||
-rw-r--r-- | sphinx/domains/python.py | 13 | ||||
-rw-r--r-- | sphinx/ext/autodoc/__init__.py | 24 | ||||
-rw-r--r-- | sphinx/pycode/__init__.py | 2 | ||||
-rw-r--r-- | sphinx/pycode/parser.py | 38 | ||||
-rw-r--r-- | sphinx/themes/basic/static/basic.css_t | 3 | ||||
-rw-r--r-- | sphinx/util/inspect.py | 13 | ||||
-rw-r--r-- | tests/roots/test-ext-autodoc/target/final.py | 14 | ||||
-rw-r--r-- | tests/test_autodoc.py | 33 | ||||
-rw-r--r-- | tests/test_domain_py.py | 44 | ||||
-rw-r--r-- | tests/test_pycode_parser.py | 78 | ||||
-rw-r--r-- | tests/test_util_inspect.py | 15 |
13 files changed, 301 insertions, 12 deletions
@@ -72,6 +72,7 @@ Features added ``:meta public:`` in info-field-list * #7487: autodoc: Allow to generate docs for singledispatch functions by py:autofunction +* #7143: autodoc: Support final classes and methods * #7466: autosummary: headings in generated documents are not translated * #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a caption to the toctree @@ -79,8 +80,6 @@ Features added to generate stub files recursively * #4030: autosummary: Add :confval:`autosummary_context` to add template variables for custom templates -* #7535: sphinx-autogen: crashes when custom template uses inheritance -* #7536: sphinx-autogen: crashes when template uses i18n feature * #7481: html theme: Add right margin to footnote/citation labels * #7482: html theme: CSS spacing for code blocks with captions and line numbers * #7443: html theme: Add new options :confval:`globaltoc_collapse` and @@ -95,12 +94,20 @@ Features added * #7543: html theme: Add top and bottom margins to tables * C and C++: allow semicolon in the end of declarations. * C++, parse parameterized noexcept specifiers. +* #7143: py domain: Add ``:final:`` option to :rst:dir:`py:class:`, + :rst:dir:`py:exception:` and :rst:dir:`py:method:` directives Bugs fixed ---------- * #6703: autodoc: incremental build does not work for imported objects * #7564: autodoc: annotations not to be shown for descriptors +* #6588: autodoc: Decorated inherited method has no documentation +* #7469: autodoc: The change of autodoc-process-docstring for variables is + cached unexpectedly +* #7535: sphinx-autogen: crashes when custom template uses inheritance +* #7536: sphinx-autogen: crashes when template uses i18n feature +* #2785: html: Bad alignment of equation links Testing -------- diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 7a89e94e2..9559acf22 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -212,6 +212,15 @@ The following directives are provided for module and class contents: Describes an exception class. The signature can, but need not include parentheses with constructor arguments. + .. rubric:: options + + .. rst:directive:option:: final + :type: no value + + Indicate the class is a final class. + + .. versionadded:: 3.1 + .. rst:directive:: .. py:class:: name .. py:class:: name(parameters) @@ -235,6 +244,15 @@ The following directives are provided for module and class contents: The first way is the preferred one. + .. rubric:: options + + .. rst:directive:option:: final + :type: no value + + Indicate the class is a final class. + + .. versionadded:: 3.1 + .. rst:directive:: .. py:attribute:: name Describes an object data attribute. The description should include @@ -283,6 +301,13 @@ The following directives are provided for module and class contents: .. versionadded:: 2.1 + .. rst:directive:option:: final + :type: no value + + Indicate the class is a final method. + + .. versionadded:: 3.1 + .. rst:directive:option:: property :type: no value diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 91ba489c7..712b2c83e 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -604,10 +604,18 @@ class PyClasslike(PyObject): Description of a class-like object (classes, interfaces, exceptions). """ + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'final': directives.flag, + }) + allow_nesting = True def get_signature_prefix(self, sig: str) -> str: - return self.objtype + ' ' + if 'final' in self.options: + return 'final %s ' % self.objtype + else: + return '%s ' % self.objtype def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: if self.objtype == 'class': @@ -628,6 +636,7 @@ class PyMethod(PyObject): 'abstractmethod': directives.flag, 'async': directives.flag, 'classmethod': directives.flag, + 'final': directives.flag, 'property': directives.flag, 'staticmethod': directives.flag, }) @@ -640,6 +649,8 @@ class PyMethod(PyObject): def get_signature_prefix(self, sig: str) -> str: prefix = [] + if 'final' in self.options: + prefix.append('final') if 'abstractmethod' in self.options: prefix.append('abstract') if 'async' in self.options: diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index af6399310..e2c47dc88 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -431,7 +431,8 @@ class Documenter: def get_doc(self, ignore: int = 1) -> List[List[str]]: """Decode and return lines of the docstring(s) for the object.""" docstring = getdoc(self.object, self.get_attr, - self.env.config.autodoc_inherit_docstrings) + self.env.config.autodoc_inherit_docstrings, + self.parent, self.object_name) if docstring: tab_width = self.directive.state.document.settings.tab_width return [prepare_docstring(docstring, ignore, tab_width)] @@ -462,7 +463,10 @@ class Documenter: key = ('.'.join(self.objpath[:-1]), self.objpath[-1]) if key in attr_docs: no_docstring = True - docstrings = [attr_docs[key]] + # make a copy of docstring for attributes to avoid cache + # the change of autodoc-process-docstring event. + docstrings = [list(attr_docs[key])] + for i, line in enumerate(self.process_doc(docstrings)): self.add_line(line, sourcename, i) @@ -552,7 +556,8 @@ class Documenter: else: isattr = False - doc = getdoc(member, self.get_attr, self.env.config.autodoc_inherit_docstrings) + doc = getdoc(member, self.get_attr, self.env.config.autodoc_inherit_docstrings, + self.parent, self.object_name) if not isinstance(doc, str): # Ignore non-string __doc__ doc = None @@ -1200,10 +1205,15 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: return super().format_signature(**kwargs) def add_directive_header(self, sig: str) -> None: + sourcename = self.get_sourcename() + if self.doc_as_attr: self.directivetype = 'attribute' super().add_directive_header(sig) + if self.analyzer and '.'.join(self.objpath) in self.analyzer.finals: + self.add_line(' :final:', sourcename) + # add inheritance info, if wanted if not self.doc_as_attr and self.options.show_inheritance: sourcename = self.get_sourcename() @@ -1233,7 +1243,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if content in ('both', 'init'): __init__ = self.get_attr(self.object, '__init__', None) initdocstring = getdoc(__init__, self.get_attr, - self.env.config.autodoc_inherit_docstrings) + self.env.config.autodoc_inherit_docstrings, + self.parent, self.object_name) # for new-style classes, no __init__ means default __init__ if (initdocstring is not None and (initdocstring == object.__init__.__doc__ or # for pypy @@ -1243,7 +1254,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: # try __new__ __new__ = self.get_attr(self.object, '__new__', None) initdocstring = getdoc(__new__, self.get_attr, - self.env.config.autodoc_inherit_docstrings) + self.env.config.autodoc_inherit_docstrings, + self.parent, self.object_name) # for new-style classes, no __new__ means default __new__ if (initdocstring is not None and (initdocstring == object.__new__.__doc__ or # for pypy @@ -1467,6 +1479,8 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: self.add_line(' :classmethod:', sourcename) if inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name): self.add_line(' :staticmethod:', sourcename) + if self.analyzer and '.'.join(self.objpath) in self.analyzer.finals: + self.add_line(' :final:', sourcename) def document_members(self, all_members: bool = False) -> None: pass diff --git a/sphinx/pycode/__init__.py b/sphinx/pycode/__init__.py index 3f6ecaf2b..6505f8dbb 100644 --- a/sphinx/pycode/__init__.py +++ b/sphinx/pycode/__init__.py @@ -133,6 +133,7 @@ class ModuleAnalyzer: # will be filled by parse() self.annotations = None # type: Dict[Tuple[str, str], str] self.attr_docs = None # type: Dict[Tuple[str, str], List[str]] + self.finals = None # type: List[str] self.tagorder = None # type: Dict[str, int] self.tags = None # type: Dict[str, Tuple[str, int, int]] @@ -150,6 +151,7 @@ class ModuleAnalyzer: self.attr_docs[scope] = [''] self.annotations = parser.annotations + self.finals = parser.finals self.tags = parser.definitions self.tagorder = parser.deforders except Exception as exc: diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index c6ff67bec..a77654939 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -231,6 +231,9 @@ class VariableCommentPicker(ast.NodeVisitor): self.annotations = {} # type: Dict[Tuple[str, str], str] self.previous = None # type: ast.AST self.deforders = {} # type: Dict[str, int] + self.finals = [] # type: List[str] + self.typing = None # type: str + self.typing_final = None # type: str super().__init__() def get_qualname_for(self, name: str) -> Optional[List[str]]: @@ -249,6 +252,11 @@ class VariableCommentPicker(ast.NodeVisitor): if qualname: self.deforders[".".join(qualname)] = next(self.counter) + def add_final_entry(self, name: str) -> None: + qualname = self.get_qualname_for(name) + if qualname: + self.finals.append(".".join(qualname)) + def add_variable_comment(self, name: str, comment: str) -> None: qualname = self.get_qualname_for(name) if qualname: @@ -261,6 +269,22 @@ class VariableCommentPicker(ast.NodeVisitor): basename = ".".join(qualname[:-1]) self.annotations[(basename, name)] = unparse(annotation) + def is_final(self, decorators: List[ast.expr]) -> bool: + final = [] + if self.typing: + final.append('%s.final' % self.typing) + if self.typing_final: + final.append(self.typing_final) + + for decorator in decorators: + try: + if unparse(decorator) in final: + return True + except NotImplementedError: + pass + + return False + def get_self(self) -> ast.arg: """Returns the name of first argument if in function.""" if self.current_function and self.current_function.args.args: @@ -282,11 +306,19 @@ class VariableCommentPicker(ast.NodeVisitor): for name in node.names: self.add_entry(name.asname or name.name) + if name.name == 'typing': + self.typing = name.asname or name.name + elif name.name == 'typing.final': + self.typing_final = name.asname or name.name + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: """Handles Import node and record it to definition orders.""" for name in node.names: self.add_entry(name.asname or name.name) + if node.module == 'typing' and name.name == 'final': + self.typing_final = name.asname or name.name + def visit_Assign(self, node: ast.Assign) -> None: """Handles Assign node and pick up a variable comment.""" try: @@ -370,6 +402,8 @@ class VariableCommentPicker(ast.NodeVisitor): """Handles ClassDef node and set context.""" self.current_classes.append(node.name) self.add_entry(node.name) + if self.is_final(node.decorator_list): + self.add_final_entry(node.name) self.context.append(node.name) self.previous = node for child in node.body: @@ -381,6 +415,8 @@ class VariableCommentPicker(ast.NodeVisitor): """Handles FunctionDef node and set context.""" if self.current_function is None: self.add_entry(node.name) # should be called before setting self.current_function + if self.is_final(node.decorator_list): + self.add_final_entry(node.name) self.context.append(node.name) self.current_function = node for child in node.body: @@ -481,6 +517,7 @@ class Parser: self.comments = {} # type: Dict[Tuple[str, str], str] self.deforders = {} # type: Dict[str, int] self.definitions = {} # type: Dict[str, Tuple[str, int, int]] + self.finals = [] # type: List[str] def parse(self) -> None: """Parse the source code.""" @@ -495,6 +532,7 @@ class Parser: self.annotations = picker.annotations self.comments = picker.comments self.deforders = picker.deforders + self.finals = picker.finals def parse_definition(self) -> None: """Parse the location of definitions from the code.""" diff --git a/sphinx/themes/basic/static/basic.css_t b/sphinx/themes/basic/static/basic.css_t index 1c2282b23..45908ece1 100644 --- a/sphinx/themes/basic/static/basic.css_t +++ b/sphinx/themes/basic/static/basic.css_t @@ -779,8 +779,7 @@ span.eqno { } span.eqno a.headerlink { - position: relative; - left: 0px; + position: absolute; z-index: 1; } diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 17b19ae76..88f8dffd3 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -526,13 +526,14 @@ def signature_from_str(signature: str) -> inspect.Signature: def getdoc(obj: Any, attrgetter: Callable = safe_getattr, - allow_inherited: bool = False) -> str: + allow_inherited: bool = False, cls: Any = None, name: str = None) -> str: """Get the docstring for the object. This tries to obtain the docstring for some kind of objects additionally: * partial functions * inherited docstring + * inherited decorated methods """ doc = attrgetter(obj, '__doc__', None) if ispartial(obj) and doc == obj.__class__.__doc__: @@ -540,4 +541,14 @@ def getdoc(obj: Any, attrgetter: Callable = safe_getattr, elif doc is None and allow_inherited: doc = inspect.getdoc(obj) + if doc is None and cls: + # inspect.getdoc() does not support some kind of inherited and decorated methods. + # This tries to obtain the docstring from super classes. + for basecls in getattr(cls, '__mro__', []): + meth = safe_getattr(basecls, name, None) + if meth: + doc = inspect.getdoc(meth) + if doc: + break + return doc diff --git a/tests/roots/test-ext-autodoc/target/final.py b/tests/roots/test-ext-autodoc/target/final.py new file mode 100644 index 000000000..ff78442e7 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/final.py @@ -0,0 +1,14 @@ +import typing +from typing import final + + +@typing.final +class Class: + """docstring""" + + @final + def meth1(self): + """docstring""" + + def meth2(self): + """docstring""" diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index 51ae3ddc3..cbbdbb787 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -1691,3 +1691,36 @@ def test_cython(): ' Docstring.', '', ] + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='typing.final is available since python3.8') +@pytest.mark.usefixtures('setup_test') +def test_final(): + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.final', options) + assert list(actual) == [ + '', + '.. py:module:: target.final', + '', + '', + '.. py:class:: Class', + ' :module: target.final', + ' :final:', + '', + ' docstring', + '', + '', + ' .. py:method:: Class.meth1()', + ' :module: target.final', + ' :final:', + '', + ' docstring', + '', + '', + ' .. py:method:: Class.meth2()', + ' :module: target.final', + '', + ' docstring', + '', + ] diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index d9d61db14..5a1d73cfe 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -499,6 +499,34 @@ def test_pyfunction(app): assert domain.objects['example.func2'] == ('index', 'example.func2', 'function') +def test_pyclass_options(app): + text = (".. py:class:: Class1\n" + ".. py:class:: Class2\n" + " :final:\n") + domain = app.env.get_domain('py') + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_annotation, "class "], + [desc_name, "Class1"])], + [desc_content, ()])], + addnodes.index, + [desc, ([desc_signature, ([desc_annotation, "final class "], + [desc_name, "Class2"])], + [desc_content, ()])])) + + # class + assert_node(doctree[0], addnodes.index, + entries=[('single', 'Class1 (built-in class)', 'Class1', '', None)]) + assert 'Class1' in domain.objects + assert domain.objects['Class1'] == ('index', 'Class1', 'class') + + # :final: + assert_node(doctree[2], addnodes.index, + entries=[('single', 'Class2 (built-in class)', 'Class2', '', None)]) + assert 'Class2' in domain.objects + assert domain.objects['Class2'] == ('index', 'Class2', 'class') + + def test_pymethod_options(app): text = (".. py:class:: Class\n" "\n" @@ -512,7 +540,9 @@ def test_pymethod_options(app): " .. py:method:: meth5\n" " :property:\n" " .. py:method:: meth6\n" - " :abstractmethod:\n") + " :abstractmethod:\n" + " .. py:method:: meth7\n" + " :final:\n") domain = app.env.get_domain('py') doctree = restructuredtext.parse(app, text) assert_node(doctree, (addnodes.index, @@ -529,6 +559,8 @@ def test_pymethod_options(app): addnodes.index, desc, addnodes.index, + desc, + addnodes.index, desc)])])) # method @@ -589,6 +621,16 @@ def test_pymethod_options(app): assert 'Class.meth6' in domain.objects assert domain.objects['Class.meth6'] == ('index', 'Class.meth6', 'method') + # :final: + assert_node(doctree[1][1][12], addnodes.index, + entries=[('single', 'meth7() (Class method)', 'Class.meth7', '', None)]) + assert_node(doctree[1][1][13], ([desc_signature, ([desc_annotation, "final "], + [desc_name, "meth7"], + [desc_parameterlist, ()])], + [desc_content, ()])) + assert 'Class.meth7' in domain.objects + assert domain.objects['Class.meth7'] == ('index', 'Class.meth7', 'method') + def test_pyclassmethod(app): text = (".. py:class:: Class\n" diff --git a/tests/test_pycode_parser.py b/tests/test_pycode_parser.py index 0bf505a33..398c9f8a4 100644 --- a/tests/test_pycode_parser.py +++ b/tests/test_pycode_parser.py @@ -374,3 +374,81 @@ def test_formfeed_char(): parser = Parser(source) parser.parse() assert parser.comments == {('Foo', 'attr'): 'comment'} + + +def test_typing_final(): + source = ('import typing\n' + '\n' + '@typing.final\n' + 'def func(): pass\n' + '\n' + '@typing.final\n' + 'class Foo:\n' + ' @typing.final\n' + ' def meth(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.finals == ['func', 'Foo', 'Foo.meth'] + + +def test_typing_final_from_import(): + source = ('from typing import final\n' + '\n' + '@final\n' + 'def func(): pass\n' + '\n' + '@final\n' + 'class Foo:\n' + ' @final\n' + ' def meth(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.finals == ['func', 'Foo', 'Foo.meth'] + + +def test_typing_final_import_as(): + source = ('import typing as foo\n' + '\n' + '@foo.final\n' + 'def func(): pass\n' + '\n' + '@foo.final\n' + 'class Foo:\n' + ' @typing.final\n' + ' def meth(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.finals == ['func', 'Foo'] + + +def test_typing_final_from_import_as(): + source = ('from typing import final as bar\n' + '\n' + '@bar\n' + 'def func(): pass\n' + '\n' + '@bar\n' + 'class Foo:\n' + ' @final\n' + ' def meth(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.finals == ['func', 'Foo'] + + +def test_typing_final_not_imported(): + source = ('@typing.final\n' + 'def func(): pass\n' + '\n' + '@typing.final\n' + 'class Foo:\n' + ' @final\n' + ' def meth(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.finals == [] diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index ff1074702..65070d6d1 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -564,3 +564,18 @@ def test_unpartial(): assert inspect.unpartial(func2) is func1 assert inspect.unpartial(func3) is func1 + + +def test_getdoc_inherited_decorated_method(): + class Foo: + def meth(self): + """docstring.""" + + class Bar(Foo): + @functools.lru_cache() + def meth(self): + # inherited and decorated method + pass + + assert inspect.getdoc(Bar.meth, getattr, False, Bar, "meth") is None + assert inspect.getdoc(Bar.meth, getattr, True, Bar, "meth") == "docstring." |