Merge pull request #7561 from tk0miya/7143_typing.final

Support @final decorator
This commit is contained in:
Takeshi KOMIYA 2020-04-28 22:57:56 +09:00 committed by GitHub
commit a8a2d59403
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 255 additions and 2 deletions

View File

@ -41,6 +41,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
@ -62,6 +63,8 @@ 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
----------

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

@ -641,10 +641,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':
@ -749,6 +757,7 @@ class PyMethod(PyObject):
'abstractmethod': directives.flag,
'async': directives.flag,
'classmethod': directives.flag,
'final': directives.flag,
'property': directives.flag,
'staticmethod': directives.flag,
})
@ -761,6 +770,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

@ -1215,10 +1215,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()
@ -1488,6 +1493,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

@ -144,6 +144,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]]
@ -161,6 +162,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

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