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 ``:meta public:`` in info-field-list
* #7487: autodoc: Allow to generate docs for singledispatch functions by * #7487: autodoc: Allow to generate docs for singledispatch functions by
py:autofunction py:autofunction
* #7143: autodoc: Support final classes and methods
* #7466: autosummary: headings in generated documents are not translated * #7466: autosummary: headings in generated documents are not translated
* #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a * #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a
caption to the toctree caption to the toctree
@ -62,6 +63,8 @@ Features added
* #7543: html theme: Add top and bottom margins to tables * #7543: html theme: Add top and bottom margins to tables
* C and C++: allow semicolon in the end of declarations. * C and C++: allow semicolon in the end of declarations.
* C++, parse parameterized noexcept specifiers. * 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 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 Describes an exception class. The signature can, but need not include
parentheses with constructor arguments. 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 .. rst:directive:: .. py:class:: name
.. py:class:: name(parameters) .. 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. 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 .. rst:directive:: .. py:attribute:: name
Describes an object data attribute. The description should include 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 .. versionadded:: 2.1
.. rst:directive:option:: final
:type: no value
Indicate the class is a final method.
.. versionadded:: 3.1
.. rst:directive:option:: property .. rst:directive:option:: property
:type: no value :type: no value

View File

@ -641,10 +641,18 @@ class PyClasslike(PyObject):
Description of a class-like object (classes, interfaces, exceptions). Description of a class-like object (classes, interfaces, exceptions).
""" """
option_spec = PyObject.option_spec.copy()
option_spec.update({
'final': directives.flag,
})
allow_nesting = True allow_nesting = True
def get_signature_prefix(self, sig: str) -> str: 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: def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str:
if self.objtype == 'class': if self.objtype == 'class':
@ -749,6 +757,7 @@ class PyMethod(PyObject):
'abstractmethod': directives.flag, 'abstractmethod': directives.flag,
'async': directives.flag, 'async': directives.flag,
'classmethod': directives.flag, 'classmethod': directives.flag,
'final': directives.flag,
'property': directives.flag, 'property': directives.flag,
'staticmethod': directives.flag, 'staticmethod': directives.flag,
}) })
@ -761,6 +770,8 @@ class PyMethod(PyObject):
def get_signature_prefix(self, sig: str) -> str: def get_signature_prefix(self, sig: str) -> str:
prefix = [] prefix = []
if 'final' in self.options:
prefix.append('final')
if 'abstractmethod' in self.options: if 'abstractmethod' in self.options:
prefix.append('abstract') prefix.append('abstract')
if 'async' in self.options: if 'async' in self.options:

View File

@ -1215,10 +1215,15 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
return super().format_signature(**kwargs) return super().format_signature(**kwargs)
def add_directive_header(self, sig: str) -> None: def add_directive_header(self, sig: str) -> None:
sourcename = self.get_sourcename()
if self.doc_as_attr: if self.doc_as_attr:
self.directivetype = 'attribute' self.directivetype = 'attribute'
super().add_directive_header(sig) 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 # add inheritance info, if wanted
if not self.doc_as_attr and self.options.show_inheritance: if not self.doc_as_attr and self.options.show_inheritance:
sourcename = self.get_sourcename() sourcename = self.get_sourcename()
@ -1488,6 +1493,8 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type:
self.add_line(' :classmethod:', sourcename) self.add_line(' :classmethod:', sourcename)
if inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name): if inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name):
self.add_line(' :staticmethod:', sourcename) 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: def document_members(self, all_members: bool = False) -> None:
pass pass

View File

@ -144,6 +144,7 @@ class ModuleAnalyzer:
# will be filled by parse() # will be filled by parse()
self.annotations = None # type: Dict[Tuple[str, str], str] self.annotations = None # type: Dict[Tuple[str, str], str]
self.attr_docs = None # type: Dict[Tuple[str, str], List[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.tagorder = None # type: Dict[str, int]
self.tags = None # type: Dict[str, Tuple[str, int, int]] self.tags = None # type: Dict[str, Tuple[str, int, int]]
@ -161,6 +162,7 @@ class ModuleAnalyzer:
self.attr_docs[scope] = [''] self.attr_docs[scope] = ['']
self.annotations = parser.annotations self.annotations = parser.annotations
self.finals = parser.finals
self.tags = parser.definitions self.tags = parser.definitions
self.tagorder = parser.deforders self.tagorder = parser.deforders
except Exception as exc: except Exception as exc:

View File

@ -231,6 +231,9 @@ class VariableCommentPicker(ast.NodeVisitor):
self.annotations = {} # type: Dict[Tuple[str, str], str] self.annotations = {} # type: Dict[Tuple[str, str], str]
self.previous = None # type: ast.AST self.previous = None # type: ast.AST
self.deforders = {} # type: Dict[str, int] self.deforders = {} # type: Dict[str, int]
self.finals = [] # type: List[str]
self.typing = None # type: str
self.typing_final = None # type: str
super().__init__() super().__init__()
def get_qualname_for(self, name: str) -> Optional[List[str]]: def get_qualname_for(self, name: str) -> Optional[List[str]]:
@ -249,6 +252,11 @@ class VariableCommentPicker(ast.NodeVisitor):
if qualname: if qualname:
self.deforders[".".join(qualname)] = next(self.counter) 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: def add_variable_comment(self, name: str, comment: str) -> None:
qualname = self.get_qualname_for(name) qualname = self.get_qualname_for(name)
if qualname: if qualname:
@ -261,6 +269,22 @@ class VariableCommentPicker(ast.NodeVisitor):
basename = ".".join(qualname[:-1]) basename = ".".join(qualname[:-1])
self.annotations[(basename, name)] = unparse(annotation) 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: def get_self(self) -> ast.arg:
"""Returns the name of first argument if in function.""" """Returns the name of first argument if in function."""
if self.current_function and self.current_function.args.args: if self.current_function and self.current_function.args.args:
@ -282,11 +306,19 @@ class VariableCommentPicker(ast.NodeVisitor):
for name in node.names: for name in node.names:
self.add_entry(name.asname or name.name) 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: def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Handles Import node and record it to definition orders.""" """Handles Import node and record it to definition orders."""
for name in node.names: for name in node.names:
self.add_entry(name.asname or name.name) 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: def visit_Assign(self, node: ast.Assign) -> None:
"""Handles Assign node and pick up a variable comment.""" """Handles Assign node and pick up a variable comment."""
try: try:
@ -370,6 +402,8 @@ class VariableCommentPicker(ast.NodeVisitor):
"""Handles ClassDef node and set context.""" """Handles ClassDef node and set context."""
self.current_classes.append(node.name) self.current_classes.append(node.name)
self.add_entry(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.context.append(node.name)
self.previous = node self.previous = node
for child in node.body: for child in node.body:
@ -381,6 +415,8 @@ class VariableCommentPicker(ast.NodeVisitor):
"""Handles FunctionDef node and set context.""" """Handles FunctionDef node and set context."""
if self.current_function is None: if self.current_function is None:
self.add_entry(node.name) # should be called before setting self.current_function 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.context.append(node.name)
self.current_function = node self.current_function = node
for child in node.body: for child in node.body:
@ -481,6 +517,7 @@ class Parser:
self.comments = {} # type: Dict[Tuple[str, str], str] self.comments = {} # type: Dict[Tuple[str, str], str]
self.deforders = {} # type: Dict[str, int] self.deforders = {} # type: Dict[str, int]
self.definitions = {} # type: Dict[str, Tuple[str, int, int]] self.definitions = {} # type: Dict[str, Tuple[str, int, int]]
self.finals = [] # type: List[str]
def parse(self) -> None: def parse(self) -> None:
"""Parse the source code.""" """Parse the source code."""
@ -495,6 +532,7 @@ class Parser:
self.annotations = picker.annotations self.annotations = picker.annotations
self.comments = picker.comments self.comments = picker.comments
self.deforders = picker.deforders self.deforders = picker.deforders
self.finals = picker.finals
def parse_definition(self) -> None: def parse_definition(self) -> None:
"""Parse the location of definitions from the code.""" """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.', ' 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') 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): def test_pymethod_options(app):
text = (".. py:class:: Class\n" text = (".. py:class:: Class\n"
"\n" "\n"
@ -512,7 +540,9 @@ def test_pymethod_options(app):
" .. py:method:: meth5\n" " .. py:method:: meth5\n"
" :property:\n" " :property:\n"
" .. py:method:: meth6\n" " .. py:method:: meth6\n"
" :abstractmethod:\n") " :abstractmethod:\n"
" .. py:method:: meth7\n"
" :final:\n")
domain = app.env.get_domain('py') domain = app.env.get_domain('py')
doctree = restructuredtext.parse(app, text) doctree = restructuredtext.parse(app, text)
assert_node(doctree, (addnodes.index, assert_node(doctree, (addnodes.index,
@ -529,6 +559,8 @@ def test_pymethod_options(app):
addnodes.index, addnodes.index,
desc, desc,
addnodes.index, addnodes.index,
desc,
addnodes.index,
desc)])])) desc)])]))
# method # method
@ -589,6 +621,16 @@ def test_pymethod_options(app):
assert 'Class.meth6' in domain.objects assert 'Class.meth6' in domain.objects
assert domain.objects['Class.meth6'] == ('index', 'Class.meth6', 'method') 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): def test_pyclassmethod(app):
text = (".. py:class:: Class\n" text = (".. py:class:: Class\n"

View File

@ -374,3 +374,81 @@ def test_formfeed_char():
parser = Parser(source) parser = Parser(source)
parser.parse() parser.parse()
assert parser.comments == {('Foo', 'attr'): 'comment'} 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 == []