"""Extension that adds an autosummary:: directive.
The directive can be used to generate function/method/attribute/etc. summary
lists, similar to those output eg. by Epydoc and other API doc generation tools.
An :autolink: role is also provided.
autosummary directive
---------------------
The autosummary directive has the form::
.. autosummary::
:signatures: none
:toctree: generated/
module.function_1
module.function_2
...
and it generates an output table (containing signatures, optionally)
======================== =============================================
module.function_1(args) Summary line from the docstring of function_1
module.function_2(args) Summary line from the docstring
...
======================== =============================================
If the :toctree: option is specified, files matching the function names
are inserted to the toctree with the given prefix:
generated/module.function_1
generated/module.function_2
...
Note: The file names contain the module:: or currentmodule:: prefixes.
.. seealso:: autosummary_generate.py
autolink role
-------------
The autolink role functions as ``:obj:`` when the name referred can be
resolved to a Python object, and otherwise it becomes simple emphasis.
This can be used as the default role to make links 'smart'.
"""
from __future__ import annotations
import functools
import inspect
import operator
import posixpath
import re
import sys
from inspect import Parameter
from types import ModuleType
from typing import TYPE_CHECKING, cast
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst.states import RSTStateMachine, state_classes
from docutils.statemachine import StringList
import sphinx
from sphinx import addnodes
from sphinx.errors import PycodeError
from sphinx.ext.autodoc._directive_options import _AutoDocumenterOptions
from sphinx.ext.autodoc._dynamic._importer import _import_module
from sphinx.ext.autodoc._dynamic._loader import _load_object_by_name
from sphinx.ext.autodoc._dynamic._member_finder import _best_object_type_for_member
from sphinx.ext.autodoc._dynamic._mock import mock
from sphinx.ext.autodoc._sentinels import INSTANCE_ATTR
from sphinx.ext.autodoc._shared import _AutodocAttrGetter, _AutodocConfig
from sphinx.locale import __
from sphinx.pycode import ModuleAnalyzer
from sphinx.util import logging, rst
from sphinx.util.docutils import (
NullReporter,
SphinxDirective,
SphinxRole,
new_document,
switch_source_input,
)
from sphinx.util.inspect import getmro, signature_from_str
from sphinx.util.matching import Matcher
from sphinx.util.parsing import nested_parse_to_nodes
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any, ClassVar
from docutils.nodes import Node, system_message
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
from sphinx.ext.autodoc._property_types import _AutodocObjType
from sphinx.util.typing import ExtensionMetadata, OptionSpec
from sphinx.writers.html5 import HTML5Translator
logger = logging.getLogger(__name__)
periods_re = re.compile(r'\.(?:\s+)')
literal_re = re.compile(r'::\s*$')
WELL_KNOWN_ABBREVIATIONS = ('et al.', 'e.g.', 'i.e.', 'vs.')
# -- autosummary_toc node ------------------------------------------------------
class autosummary_toc(nodes.comment):
pass
def autosummary_toc_visit_html(self: nodes.NodeVisitor, node: autosummary_toc) -> None:
"""Hide autosummary toctree list in HTML output."""
raise nodes.SkipNode
def autosummary_noop(self: nodes.NodeVisitor, node: Node) -> None:
pass
# -- autosummary_table node ----------------------------------------------------
class autosummary_table(nodes.comment):
pass
def autosummary_table_visit_html(
self: HTML5Translator, node: autosummary_table
) -> None:
"""Make the first column of the table non-breaking."""
try:
table = cast('nodes.table', node[0])
tgroup = cast('nodes.tgroup', table[0])
tbody = cast('nodes.tbody', tgroup[-1])
rows = cast('list[nodes.row]', tbody)
for row in rows:
col1_entry = cast('nodes.entry', row[0])
par = cast('nodes.paragraph', col1_entry[0])
for j, subnode in enumerate(list(par)):
if isinstance(subnode, nodes.Text):
new_text = subnode.astext().replace(' ', '\u00a0')
par[j] = nodes.Text(new_text)
except IndexError:
pass
# -- autodoc integration -------------------------------------------------------
def _get_documenter(obj: Any, parent: Any) -> _AutodocObjType:
"""Get the best object type suitable for documenting the given object.
*obj* is the Python object to be documented, and *parent* is another
Python object (e.g. a module or a class) to which *obj* belongs.
"""
if inspect.ismodule(obj):
return 'module'
if parent is None or inspect.ismodule(parent):
parent_obj_type = 'module'
else:
parent_opt = _best_object_type_for_member(
member=parent,
member_name='',
is_attr=False,
parent_obj_type='module',
parent_props=None,
)
parent_obj_type = parent_opt if parent_opt is not None else 'data'
if obj_type := _best_object_type_for_member(
member=obj,
member_name='',
is_attr=False,
parent_obj_type=parent_obj_type,
parent_props=None,
):
return obj_type
return 'data'
# -- .. autosummary:: ----------------------------------------------------------
class Autosummary(SphinxDirective):
"""Pretty table containing short signatures and summaries of functions etc.
autosummary can also optionally generate a hidden toctree:: node.
"""
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
has_content = True
option_spec: ClassVar[OptionSpec] = {
'caption': directives.unchanged_required,
'class': directives.class_option,
'toctree': directives.unchanged,
'nosignatures': directives.flag,
'recursive': directives.flag,
'signatures': directives.unchanged,
'template': directives.unchanged,
}
def run(self) -> list[Node]:
names = [
x.strip().split()[0]
for x in self.content
if x.strip() and re.search(r'^[~a-zA-Z_]', x.strip()[0])
]
items = self.get_items(names)
nodes = self.get_table(items)
if 'toctree' in self.options:
dirname = posixpath.dirname(self.env.current_document.docname)
tree_prefix = self.options['toctree'].strip()
docnames = []
excluded = Matcher(self.config.exclude_patterns)
filename_map = self.config.autosummary_filename_map
for _name, _sig, _summary, real_name in items:
real_name = filename_map.get(real_name, real_name)
docname = posixpath.join(tree_prefix, real_name)
docname = posixpath.normpath(posixpath.join(dirname, docname))
if docname not in self.env.found_docs:
if excluded(str(self.env.doc2path(docname, False))):
msg = __(
'autosummary references excluded document %r. Ignored.'
)
else:
msg = __(
'autosummary: stub file not found %r. '
'Check your autosummary_generate setting.'
)
logger.warning(msg, real_name, location=self.get_location())
continue
docnames.append(docname)
if docnames:
tocnode = addnodes.toctree()
tocnode['includefiles'] = docnames
tocnode['entries'] = [(None, docn) for docn in docnames]
tocnode['maxdepth'] = -1
tocnode['glob'] = None
tocnode['caption'] = self.options.get('caption')
nodes.append(autosummary_toc('', '', tocnode))
if 'toctree' not in self.options and 'caption' in self.options:
logger.warning(
__('A captioned autosummary requires :toctree: option. ignored.'),
location=nodes[-1],
)
return nodes
def import_by_name(
self, name: str, prefixes: list[str | None]
) -> tuple[str, Any, Any, str]:
with mock(self.config.autosummary_mock_imports):
try:
return import_by_name(name, prefixes)
except ImportExceptionGroup as exc:
# check existence of instance attribute
try:
return import_ivar_by_name(name, prefixes)
except ImportError as exc2:
if exc2.__cause__:
errors: list[BaseException] = [*exc.exceptions, exc2.__cause__]
else:
errors = [*exc.exceptions, exc2]
raise ImportExceptionGroup(exc.args[0], errors) from None
def get_items(self, names: list[str]) -> list[tuple[str, str | None, str, str]]:
"""Try to import the given names, and return a list of
``[(name, signature, summary_string, real_name), ...]``.
signature is already formatted and is None if :nosignatures: option was given.
"""
prefixes = get_import_prefixes_from_env(self.env)
items: list[tuple[str, str | None, str, str]] = []
signatures_option = self.options.get('signatures')
if signatures_option is None:
signatures_option = 'none' if 'nosignatures' in self.options else 'long'
if signatures_option not in {'none', 'short', 'long'}:
msg = (
'Invalid value for autosummary :signatures: option: '
f"{signatures_option!r}. Valid values are 'none', 'short', 'long'"
)
raise ValueError(msg)
document_settings = self.state.document.settings
env = self.env
config = _AutodocConfig.from_config(env.config)
current_document = env.current_document
events = env.events
get_attr = _AutodocAttrGetter(env._registry.autodoc_attrgetters)
opts = _AutoDocumenterOptions()
ref_context = env.ref_context
reread_always = env.reread_always
max_item_chars = 50
for name in names:
display_name = name
if name.startswith('~'):
name = name[1:]
display_name = name.split('.')[-1]
try:
real_name, obj, parent, modname = self.import_by_name(
name, prefixes=prefixes
)
except ImportExceptionGroup as exc:
errors = list({f'* {type(e).__name__}: {e}' for e in exc.exceptions})
logger.warning(
__('autosummary: failed to import %s.\nPossible hints:\n%s'),
name,
'\n'.join(errors),
location=self.get_location(),
)
continue
obj_type = _get_documenter(obj, parent)
if isinstance(obj, ModuleType):
full_name = real_name
else:
# give explicitly separated module name, so that members
# of inner classes can be documented
full_name = f'{modname}::{real_name[len(modname) + 1 :]}'
# NB. using full_name here is important, since Documenters
# handle module prefixes slightly differently
props = _load_object_by_name(
name=full_name,
objtype=obj_type,
current_document=current_document,
config=config,
events=events,
get_attr=get_attr,
options=opts,
ref_context=ref_context,
reread_always=reread_always,
)
if props is None:
logger.warning(
__('failed to import object %s'),
real_name,
location=self.get_location(),
)
items.append((display_name, '', '', real_name))
continue
# -- Grab the signature
if signatures_option == 'none':
sig = None
elif not props.signatures:
sig = ''
elif signatures_option == 'short':
sig = '()' if props.signatures == ('()',) else '(…)'
else: # signatures_option == 'long'
max_chars = max(10, max_item_chars - len(display_name))
sig = mangle_signature('\n'.join(props.signatures), max_chars=max_chars)
# -- Grab the summary
# get content from docstrings or attribute documentation
summary = extract_summary(props.docstring_lines, document_settings)
items.append((display_name, sig, summary, real_name))
return items
def get_table(self, items: list[tuple[str, str | None, str, str]]) -> list[Node]:
"""Generate a proper list of table nodes for autosummary:: directive.
*items* is a list produced by :meth:`get_items`.
"""
table_spec = addnodes.tabular_col_spec()
table_spec['spec'] = r'\X{1}{2}\X{1}{2}'
table = autosummary_table('')
real_table = nodes.table(
'', classes=['autosummary', 'longtable', *self.options.get('class', ())]
)
table.append(real_table)
group = nodes.tgroup('', cols=2)
real_table.append(group)
group.append(nodes.colspec('', colwidth=10))
group.append(nodes.colspec('', colwidth=90))
body = nodes.tbody('')
group.append(body)
def append_row(*column_texts: str) -> None:
row = nodes.row('')
source, line = self.state_machine.get_source_and_line()
for text in column_texts:
vl = StringList([text], f'{source}:{line}:')
with switch_source_input(self.state, vl):
col_nodes = nested_parse_to_nodes(
self.state, vl, allow_section_headings=False
)
if col_nodes and isinstance(col_nodes[0], nodes.paragraph):
node = col_nodes[0]
else:
node = nodes.paragraph('')
row.append(nodes.entry('', node))
body.append(row)
for name, sig, summary, real_name in items:
qualifier = 'obj'
if sig is None:
col1 = f':py:{qualifier}:`{name} <{real_name}>`'
else:
col1 = f':py:{qualifier}:`{name} <{real_name}>`\\ {rst.escape(sig)}'
col2 = summary
append_row(col1, col2)
return [table_spec, table]
def strip_arg_typehint(s: str) -> str:
"""Strip a type hint from argument definition."""
return s.partition(':')[0].strip()
def _cleanup_signature(s: str) -> str:
"""Clean up signature using inspect.signautre() for mangle_signature()"""
try:
sig = signature_from_str(s)
parameters = list(sig.parameters.values())
for i, param in enumerate(parameters):
if param.annotation is not Parameter.empty:
# Remove typehints
param = param.replace(annotation=Parameter.empty)
if param.default is not Parameter.empty:
# Replace default value by "None"
param = param.replace(default=None)
parameters[i] = param
sig = sig.replace(parameters=parameters, return_annotation=Parameter.empty)
return str(sig)
except Exception:
# Return the original signature string if failed to clean (ex. parsing error)
return s
def mangle_signature(sig: str, max_chars: int = 30) -> str:
"""Reformat a function signature to a more compact form."""
s = _cleanup_signature(sig)
# Strip return type annotation
s = re.sub(r'\)\s*->\s.*$', ')', s)
# Remove parenthesis
s = re.sub(r'^\((.*)\)$', r'\1', s).strip()
# Strip literals (which can contain things that confuse the code below)
s = re.sub(r'\\\\', '', s) # escaped backslash (maybe inside string)
s = re.sub(r"\\'", '', s) # escaped single quote
s = re.sub(r'\\"', '', s) # escaped double quote
s = re.sub(r"'[^']*'", '', s) # string literal (w/ single quote)
s = re.sub(r'"[^"]*"', '', s) # string literal (w/ double quote)
# Strip complex objects (maybe default value of arguments)
while re.search(
r'\([^)]*\)', s
): # contents of parenthesis (ex. NamedTuple(attr=...))
s = re.sub(r'\([^)]*\)', '', s)
while re.search(r'<[^>]*>', s): # contents of angle brackets (ex.