"""Attribute docstrings parsing.
.. seealso:: https://peps.python.org/pep-0257/#what-is-a-docstring
"""
import ast
import inspect
import textwrap
import typing as T
from types import ModuleType
from .common import Docstring, DocstringParam
ast_constant_attr = {
ast.Constant: "value",
# python <= 3.7:
ast.NameConstant: "value",
ast.Num: "n",
ast.Str: "s",
}
def ast_get_constant_value(node: ast.AST) -> T.Any:
"""Return the constant's value if the given node is a constant."""
return getattr(node, ast_constant_attr[node.__class__])
def ast_unparse(node: ast.AST) -> T.Optional[str]:
"""Convert the AST node to source code as a string."""
if hasattr(ast, "unparse"):
return ast.unparse(node)
# Support simple cases in Python < 3.9
if isinstance(node, (ast.Str, ast.Num, ast.NameConstant, ast.Constant)):
return str(ast_get_constant_value(node))
if isinstance(node, ast.Name):
return node.id
return None
def ast_is_literal_str(node: ast.AST) -> bool:
"""Return True if the given node is a literal string."""
return (
isinstance(node, ast.Expr)
and isinstance(node.value, (ast.Constant, ast.Str))
and isinstance(ast_get_constant_value(node.value), str)
)
def ast_get_attribute(
node: ast.AST,
) -> T.Optional[T.Tuple[str, T.Optional[str], T.Optional[str]]]:
"""Return name, type and default if the given node is an attribute."""
if isinstance(node, (ast.Assign, ast.AnnAssign)):
target = (
node.targets[0] if isinstance(node, ast.Assign) else node.target
)
if isinstance(target, ast.Name):
type_str = None
if isinstance(node, ast.AnnAssign):
type_str = ast_unparse(node.annotation)
default = None
if node.value:
default = ast_unparse(node.value)
return target.id, type_str, default
return None
class AttributeDocstrings(ast.NodeVisitor):
"""An ast.NodeVisitor that collects attribute docstrings."""
attr_docs = None
prev_attr = None
def visit(self, node):
if self.prev_attr and ast_is_literal_str(node):
attr_name, attr_type, attr_default = self.prev_attr
self.attr_docs[attr_name] = (
ast_get_constant_value(node.value),
attr_type,
attr_default,
)
self.prev_attr = ast_get_attribute(node)
if isinstance(node, (ast.ClassDef, ast.Module)):
self.generic_visit(node)
def get_attr_docs(
self, component: T.Any
) -> T.Dict[str, T.Tuple[str, T.Optional[str], T.Optional[str]]]:
"""Get attribute docstrings from the given component.
:param component: component to process (class or module)
:returns: for each attribute docstring, a tuple with (description,
type, default)
"""
self.attr_docs = {}
self.prev_attr = None
try:
source = textwrap.dedent(inspect.getsource(component))
except OSError:
pass
else:
tree = ast.parse(source)
if inspect.ismodule(component):
self.visit(tree)
elif isinstance(tree, ast.Module) and isinstance(
tree.body[0], ast.ClassDef
):
self.visit(tree.body[0])
return self.attr_docs
def add_attribute_docstrings(
obj: T.Union[type, ModuleType], docstring: Docstring
) -> None:
"""Add attribute docstrings found in the object's source code.
:param obj: object from which to parse attribute docstrings
:param docstring: Docstring object where found attributes are added
:returns: list with names of added attributes
"""
params = set(p.arg_name for p in docstring.params)
for arg_name, (description, type_name, default) in (
AttributeDocstrings().get_attr_docs(obj).items()
):
if arg_name not in params:
param = DocstringParam(
args=["attribute", arg_name],
description=description,
arg_name=arg_name,
type_name=type_name,
is_optional=default is not None,
default=default,
)
docstring.meta.append(param)