Merge pull request #7084 from tk0miya/autodoc_data_type_annotation

autodoc: Support type annotations for variables
This commit is contained in:
Takeshi KOMIYA 2020-02-03 01:40:20 +09:00 committed by GitHub
commit 64b440ccaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 136 additions and 38 deletions

View File

@ -47,6 +47,7 @@ Features added
images (imagesize-1.2.0 or above is required)
* #6994: imgconverter: Support illustrator file (.ai) to .png conversion
* autodoc: Support Positional-Only Argument separator (PEP-570 compliant)
* autodoc: Support type annotations for variables
* #2755: autodoc: Add new event: :event:`autodoc-before-process-signature`
* #2755: autodoc: Support type_comment style (ex. ``# type: (str) -> str``)
annotation (python3.8+ or `typed_ast <https://github.com/python/typed_ast>`_

View File

@ -10,6 +10,7 @@
:license: BSD, see LICENSE for details.
"""
import importlib
import re
import warnings
from types import ModuleType
@ -33,6 +34,7 @@ from sphinx.util import logging
from sphinx.util import rpartition
from sphinx.util.docstrings import prepare_docstring
from sphinx.util.inspect import getdoc, object_description, safe_getattr, stringify_signature
from sphinx.util.typing import stringify as stringify_typehint
if False:
# For type annotation
@ -1232,12 +1234,22 @@ class DataDocumenter(ModuleLevelDocumenter):
super().add_directive_header(sig)
sourcename = self.get_sourcename()
if not self.options.annotation:
# obtain annotation for this data
annotations = getattr(self.parent, '__annotations__', {})
if self.objpath[-1] in annotations:
objrepr = stringify_typehint(annotations.get(self.objpath[-1]))
self.add_line(' :type: ' + objrepr, sourcename)
else:
key = ('.'.join(self.objpath[:-1]), self.objpath[-1])
if self.analyzer and key in self.analyzer.annotations:
self.add_line(' :type: ' + self.analyzer.annotations[key],
sourcename)
try:
objrepr = object_description(self.object)
self.add_line(' :value: ' + objrepr, sourcename)
except ValueError:
pass
else:
self.add_line(' :annotation: = ' + objrepr, sourcename)
elif self.options.annotation is SUPPRESS:
pass
else:
@ -1276,6 +1288,12 @@ class DataDeclarationDocumenter(DataDocumenter):
"""Never import anything."""
# disguise as a data
self.objtype = 'data'
try:
# import module to obtain type annotation
self.parent = importlib.import_module(self.modname)
except ImportError:
pass
return True
def add_content(self, more_content: Any, no_docstring: bool = False) -> None:
@ -1404,12 +1422,22 @@ class AttributeDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter):
sourcename = self.get_sourcename()
if not self.options.annotation:
if not self._datadescriptor:
# obtain annotation for this attribute
annotations = getattr(self.parent, '__annotations__', {})
if self.objpath[-1] in annotations:
objrepr = stringify_typehint(annotations.get(self.objpath[-1]))
self.add_line(' :type: ' + objrepr, sourcename)
else:
key = ('.'.join(self.objpath[:-1]), self.objpath[-1])
if self.analyzer and key in self.analyzer.annotations:
self.add_line(' :type: ' + self.analyzer.annotations[key],
sourcename)
try:
objrepr = object_description(self.object)
self.add_line(' :value: ' + objrepr, sourcename)
except ValueError:
pass
else:
self.add_line(' :annotation: = ' + objrepr, sourcename)
elif self.options.annotation is SUPPRESS:
pass
else:

View File

@ -142,9 +142,10 @@ class ModuleAnalyzer:
self.code = source.read()
# will be filled by parse()
self.attr_docs = None # type: Dict[Tuple[str, str], List[str]]
self.tagorder = None # type: Dict[str, int]
self.tags = None # type: Dict[str, Tuple[str, int, int]]
self.annotations = None # type: Dict[Tuple[str, str], str]
self.attr_docs = None # type: Dict[Tuple[str, str], List[str]]
self.tagorder = None # type: Dict[str, int]
self.tags = None # type: Dict[str, Tuple[str, int, int]]
def parse(self) -> None:
"""Parse the source code."""
@ -159,6 +160,7 @@ class ModuleAnalyzer:
else:
self.attr_docs[scope] = ['']
self.annotations = parser.annotations
self.tags = parser.definitions
self.tagorder = parser.deforders
except Exception as exc:

View File

@ -38,6 +38,8 @@ def unparse(node: ast.AST) -> str:
"""Unparse an AST to string."""
if node is None:
return None
elif isinstance(node, str):
return node
elif isinstance(node, ast.Attribute):
return "%s.%s" % (unparse(node.value), node.attr)
elif isinstance(node, ast.Bytes):

View File

@ -7,7 +7,6 @@
:copyright: Copyright 2007-2020 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import ast
import inspect
import itertools
import re
@ -17,6 +16,9 @@ from token import NAME, NEWLINE, INDENT, DEDENT, NUMBER, OP, STRING
from tokenize import COMMENT, NL
from typing import Any, Dict, List, Tuple
from sphinx.pycode.ast import ast # for py37 or older
from sphinx.pycode.ast import parse, unparse
comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$')
indent_re = re.compile('^\\s*$')
@ -226,6 +228,7 @@ class VariableCommentPicker(ast.NodeVisitor):
self.current_classes = [] # type: List[str]
self.current_function = None # type: ast.FunctionDef
self.comments = {} # type: Dict[Tuple[str, str], str]
self.annotations = {} # type: Dict[Tuple[str, str], str]
self.previous = None # type: ast.AST
self.deforders = {} # type: Dict[str, int]
super().__init__()
@ -254,6 +257,18 @@ class VariableCommentPicker(ast.NodeVisitor):
self.comments[(context, name)] = comment
def add_variable_annotation(self, name: str, annotation: ast.AST) -> None:
if self.current_function:
if self.current_classes and self.context[-1] == "__init__":
# store variable comments inside __init__ method of classes
context = ".".join(self.context[:-1])
else:
return
else:
context = ".".join(self.context)
self.annotations[(context, name)] = unparse(annotation)
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:
@ -295,6 +310,14 @@ class VariableCommentPicker(ast.NodeVisitor):
except TypeError:
return # this assignment is not new definition!
# record annotation
if hasattr(node, 'annotation') and node.annotation: # type: ignore
for varname in varnames:
self.add_variable_annotation(varname, node.annotation) # type: ignore
elif hasattr(node, 'type_comment') and node.type_comment:
for varname in varnames:
self.add_variable_annotation(varname, node.type_comment) # type: ignore
# check comments after assignment
parser = AfterCommentParser([current_line[node.col_offset:]] +
self.buffers[node.lineno:])
@ -468,6 +491,7 @@ class Parser:
def __init__(self, code: str, encoding: str = 'utf-8') -> None:
self.code = filter_whitespace(code)
self.encoding = encoding
self.annotations = {} # type: Dict[Tuple[str, str], str]
self.comments = {} # type: Dict[Tuple[str, str], str]
self.deforders = {} # type: Dict[str, int]
self.definitions = {} # type: Dict[str, Tuple[str, int, int]]
@ -479,9 +503,10 @@ class Parser:
def parse_comments(self) -> None:
"""Parse the code and pick up comments."""
tree = ast.parse(self.code)
tree = parse(self.code)
picker = VariableCommentPicker(self.code.splitlines(True), self.encoding)
picker.visit(tree)
self.annotations = picker.annotations
self.comments = picker.comments
self.deforders = picker.deforders

View File

@ -2,12 +2,17 @@
attr1: str = ''
#: attr2
attr2: str
#: attr3
attr3 = '' # type: str
class Class:
attr1: int = 0
attr2: int
attr3 = 0 # type: int
def __init__(self):
self.attr3: int = 0 #: attr3
self.attr4: int #: attr4
self.attr4: int = 0 #: attr4
self.attr5: int #: attr5
self.attr6 = 0 # type: int
"""attr6"""

View File

@ -906,7 +906,7 @@ def test_autodoc_module_scope(app):
'',
'.. py:attribute:: Class.mdocattr',
' :module: target',
' :annotation: = <_io.StringIO object>',
' :value: <_io.StringIO object>',
'',
' should be documented as well - süß',
' '
@ -922,7 +922,7 @@ def test_autodoc_class_scope(app):
'',
'.. py:attribute:: Class.mdocattr',
' :module: target',
' :annotation: = <_io.StringIO object>',
' :value: <_io.StringIO object>',
'',
' should be documented as well - süß',
' '
@ -942,12 +942,12 @@ def test_class_attributes(app):
' ',
' .. py:attribute:: AttCls.a1',
' :module: target',
' :annotation: = hello world',
' :value: hello world',
' ',
' ',
' .. py:attribute:: AttCls.a2',
' :module: target',
' :annotation: = None',
' :value: None',
' '
]
@ -966,7 +966,7 @@ def test_instance_attributes(app):
' ',
' .. py:attribute:: InstAttCls.ca1',
' :module: target',
" :annotation: = 'a'",
" :value: 'a'",
' ',
' Doc comment for class attribute InstAttCls.ca1.',
' It can have multiple lines.',
@ -974,28 +974,28 @@ def test_instance_attributes(app):
' ',
' .. py:attribute:: InstAttCls.ca2',
' :module: target',
" :annotation: = 'b'",
" :value: 'b'",
' ',
' Doc comment for InstAttCls.ca2. One line only.',
' ',
' ',
' .. py:attribute:: InstAttCls.ca3',
' :module: target',
" :annotation: = 'c'",
" :value: 'c'",
' ',
' Docstring for class attribute InstAttCls.ca3.',
' ',
' ',
' .. py:attribute:: InstAttCls.ia1',
' :module: target',
' :annotation: = None',
' :value: None',
' ',
' Doc comment for instance attribute InstAttCls.ia1',
' ',
' ',
' .. py:attribute:: InstAttCls.ia2',
' :module: target',
' :annotation: = None',
' :value: None',
' ',
' Docstring for instance attribute InstAttCls.ia2.',
' '
@ -1014,7 +1014,7 @@ def test_instance_attributes(app):
' ',
' .. py:attribute:: InstAttCls.ca1',
' :module: target',
" :annotation: = 'a'",
" :value: 'a'",
' ',
' Doc comment for class attribute InstAttCls.ca1.',
' It can have multiple lines.',
@ -1022,7 +1022,7 @@ def test_instance_attributes(app):
' ',
' .. py:attribute:: InstAttCls.ia1',
' :module: target',
' :annotation: = None',
' :value: None',
' ',
' Doc comment for instance attribute InstAttCls.ia1',
' '
@ -1090,28 +1090,28 @@ def test_enum_class(app):
' ',
' .. py:attribute:: EnumCls.val1',
' :module: target.enum',
' :annotation: = 12',
' :value: 12',
' ',
' doc for val1',
' ',
' ',
' .. py:attribute:: EnumCls.val2',
' :module: target.enum',
' :annotation: = 23',
' :value: 23',
' ',
' doc for val2',
' ',
' ',
' .. py:attribute:: EnumCls.val3',
' :module: target.enum',
' :annotation: = 34',
' :value: 34',
' ',
' doc for val3',
' ',
' ',
' .. py:attribute:: EnumCls.val4',
' :module: target.enum',
' :annotation: = 34',
' :value: 34',
' '
]
@ -1121,7 +1121,7 @@ def test_enum_class(app):
'',
'.. py:attribute:: EnumCls.val1',
' :module: target.enum',
' :annotation: = 12',
' :value: 12',
'',
' doc for val1',
' '
@ -1405,40 +1405,68 @@ def test_autodoc_typed_instance_variables(app):
' ',
' .. py:attribute:: Class.attr1',
' :module: target.typed_vars',
' :annotation: = 0',
' :type: int',
' :value: 0',
' ',
' ',
' .. py:attribute:: Class.attr2',
' :module: target.typed_vars',
' :annotation: = None',
' :type: int',
' :value: None',
' ',
' ',
' .. py:attribute:: Class.attr3',
' :module: target.typed_vars',
' :annotation: = None',
' :type: int',
' :value: 0',
' ',
' attr3',
' ',
' ',
' .. py:attribute:: Class.attr4',
' :module: target.typed_vars',
' :annotation: = None',
' :type: int',
' :value: None',
' ',
' attr4',
' ',
' ',
' .. py:attribute:: Class.attr5',
' :module: target.typed_vars',
' :type: int',
' :value: None',
' ',
' attr5',
' ',
' ',
' .. py:attribute:: Class.attr6',
' :module: target.typed_vars',
' :type: int',
' :value: None',
' ',
' attr6',
' ',
'',
'.. py:data:: attr1',
' :module: target.typed_vars',
" :annotation: = ''",
' :type: str',
" :value: ''",
'',
' attr1',
' ',
'',
'.. py:data:: attr2',
' :module: target.typed_vars',
" :annotation: = None",
' :type: str',
' :value: None',
'',
' attr2',
' ',
'',
'.. py:data:: attr3',
' :module: target.typed_vars',
' :type: str',
" :value: ''",
'',
' attr3',
' '
]
@ -1455,7 +1483,7 @@ def test_autodoc_for_egged_code(app):
'',
'.. py:data:: CONSTANT',
' :module: sample',
' :annotation: = 1',
' :value: 1',
'',
' constant on sample.py',
' ',

View File

@ -99,12 +99,19 @@ def test_annotated_assignment_py36():
source = ('a: str = "Sphinx" #: comment\n'
'b: int = 1\n'
'"""string on next line"""\n'
'c: int #: comment')
'c: int #: comment\n'
'd = 1 # type: int\n'
'"""string on next line"""\n')
parser = Parser(source)
parser.parse()
assert parser.comments == {('', 'a'): 'comment',
('', 'b'): 'string on next line',
('', 'c'): 'comment'}
('', 'c'): 'comment',
('', 'd'): 'string on next line'}
assert parser.annotations == {('', 'a'): 'str',
('', 'b'): 'int',
('', 'c'): 'int',
('', 'd'): 'int'}
assert parser.definitions == {}