Merge branch '3.x'

This commit is contained in:
Takeshi KOMIYA 2020-04-30 21:49:28 +09:00
commit 71eddf3095
13 changed files with 301 additions and 12 deletions

11
CHANGES
View File

@ -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
--------

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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."""

View File

@ -779,8 +779,7 @@ span.eqno {
}
span.eqno a.headerlink {
position: relative;
left: 0px;
position: absolute;
z-index: 1;
}

View File

@ -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

View File

@ -0,0 +1,14 @@
import typing
from typing import final
@typing.final
class Class:
"""docstring"""
@final
def meth1(self):
"""docstring"""
def meth2(self):
"""docstring"""

View File

@ -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',
'',
]

View File

@ -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"

View File

@ -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 == []

View File

@ -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."