1364 lines
48 KiB
Python
1364 lines
48 KiB
Python
"""Classes for docstring parsing and formatting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import collections
|
|
import contextlib
|
|
import inspect
|
|
import re
|
|
from functools import partial
|
|
from typing import TYPE_CHECKING, Any, Callable
|
|
|
|
from sphinx.locale import _, __
|
|
from sphinx.util import logging
|
|
from sphinx.util.typing import get_type_hints, stringify_annotation
|
|
|
|
if TYPE_CHECKING:
|
|
from sphinx.application import Sphinx
|
|
from sphinx.config import Config as SphinxConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_directive_regex = re.compile(r'\.\. \S+::')
|
|
_google_section_regex = re.compile(r'^(\s|\w)+:\s*$')
|
|
_google_typed_arg_regex = re.compile(r'(.+?)\(\s*(.*[^\s]+)\s*\)')
|
|
_numpy_section_regex = re.compile(r'^[=\-`:\'"~^_*+#<>]{2,}\s*$')
|
|
_single_colon_regex = re.compile(r'(?<!:):(?!:)')
|
|
_xref_or_code_regex = re.compile(
|
|
r'((?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:`.+?`)|'
|
|
r'(?:``.+?``)|'
|
|
r'(?::meta .+:.*)|'
|
|
r'(?:`.+?\s*(?<!\x00)<.*?>`))')
|
|
_xref_regex = re.compile(
|
|
r'(?:(?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:)?`.+?`)',
|
|
)
|
|
_bullet_list_regex = re.compile(r'^(\*|\+|\-)(\s+\S|\s*$)')
|
|
_enumerated_list_regex = re.compile(
|
|
r'^(?P<paren>\()?'
|
|
r'(\d+|#|[ivxlcdm]+|[IVXLCDM]+|[a-zA-Z])'
|
|
r'(?(paren)\)|\.)(\s+\S|\s*$)')
|
|
_token_regex = re.compile(
|
|
r"(,\sor\s|\sor\s|\sof\s|:\s|\sto\s|,\sand\s|\sand\s|,\s"
|
|
r"|[{]|[}]"
|
|
r'|"(?:\\"|[^"])*"'
|
|
r"|'(?:\\'|[^'])*')",
|
|
)
|
|
_default_regex = re.compile(
|
|
r"^default[^_0-9A-Za-z].*$",
|
|
)
|
|
_SINGLETONS = ("None", "True", "False", "Ellipsis")
|
|
|
|
|
|
class Deque(collections.deque):
|
|
"""
|
|
A subclass of deque that mimics ``pockets.iterators.modify_iter``.
|
|
|
|
The `.Deque.get` and `.Deque.next` methods are added.
|
|
"""
|
|
|
|
sentinel = object()
|
|
|
|
def get(self, n: int) -> Any:
|
|
"""
|
|
Return the nth element of the stack, or ``self.sentinel`` if n is
|
|
greater than the stack size.
|
|
"""
|
|
return self[n] if n < len(self) else self.sentinel
|
|
|
|
def next(self) -> Any:
|
|
if self:
|
|
return super().popleft()
|
|
else:
|
|
raise StopIteration
|
|
|
|
|
|
def _convert_type_spec(_type: str, translations: dict[str, str] | None = None) -> str:
|
|
"""Convert type specification to reference in reST."""
|
|
if translations is not None and _type in translations:
|
|
return translations[_type]
|
|
if _type == 'None':
|
|
return ':py:obj:`None`'
|
|
return f':py:class:`{_type}`'
|
|
|
|
|
|
class GoogleDocstring:
|
|
"""Convert Google style docstrings to reStructuredText.
|
|
|
|
Parameters
|
|
----------
|
|
docstring : :obj:`str` or :obj:`list` of :obj:`str`
|
|
The docstring to parse, given either as a string or split into
|
|
individual lines.
|
|
config: :obj:`sphinx.ext.napoleon.Config` or :obj:`sphinx.config.Config`
|
|
The configuration settings to use. If not given, defaults to the
|
|
config object on `app`; or if `app` is not given defaults to the
|
|
a new :class:`sphinx.ext.napoleon.Config` object.
|
|
|
|
|
|
Other Parameters
|
|
----------------
|
|
app : :class:`sphinx.application.Sphinx`, optional
|
|
Application object representing the Sphinx process.
|
|
what : :obj:`str`, optional
|
|
A string specifying the type of the object to which the docstring
|
|
belongs. Valid values: "module", "class", "exception", "function",
|
|
"method", "attribute".
|
|
name : :obj:`str`, optional
|
|
The fully qualified name of the object.
|
|
obj : module, class, exception, function, method, or attribute
|
|
The object to which the docstring belongs.
|
|
options : :class:`sphinx.ext.autodoc.Options`, optional
|
|
The options given to the directive: an object with attributes
|
|
inherited_members, undoc_members, show_inheritance and no_index that
|
|
are True if the flag option of same name was given to the auto
|
|
directive.
|
|
|
|
|
|
Example
|
|
-------
|
|
>>> from sphinx.ext.napoleon import Config
|
|
>>> config = Config(napoleon_use_param=True, napoleon_use_rtype=True)
|
|
>>> docstring = '''One line summary.
|
|
...
|
|
... Extended description.
|
|
...
|
|
... Args:
|
|
... arg1(int): Description of `arg1`
|
|
... arg2(str): Description of `arg2`
|
|
... Returns:
|
|
... str: Description of return value.
|
|
... '''
|
|
>>> print(GoogleDocstring(docstring, config))
|
|
One line summary.
|
|
<BLANKLINE>
|
|
Extended description.
|
|
<BLANKLINE>
|
|
:param arg1: Description of `arg1`
|
|
:type arg1: int
|
|
:param arg2: Description of `arg2`
|
|
:type arg2: str
|
|
<BLANKLINE>
|
|
:returns: Description of return value.
|
|
:rtype: str
|
|
<BLANKLINE>
|
|
|
|
"""
|
|
|
|
_name_rgx = re.compile(r"^\s*((?::(?P<role>\S+):)?`(?P<name>~?[a-zA-Z0-9_.-]+)`|"
|
|
r" (?P<name2>~?[a-zA-Z0-9_.-]+))\s*", re.X)
|
|
|
|
def __init__(
|
|
self,
|
|
docstring: str | list[str],
|
|
config: SphinxConfig | None = None,
|
|
app: Sphinx | None = None,
|
|
what: str = '',
|
|
name: str = '',
|
|
obj: Any = None,
|
|
options: Any = None,
|
|
) -> None:
|
|
self._app = app
|
|
if config:
|
|
self._config = config
|
|
elif app:
|
|
self._config = app.config
|
|
else:
|
|
from sphinx.ext.napoleon import Config
|
|
|
|
self._config = Config() # type: ignore[assignment]
|
|
|
|
if not what:
|
|
if inspect.isclass(obj):
|
|
what = 'class'
|
|
elif inspect.ismodule(obj):
|
|
what = 'module'
|
|
elif callable(obj):
|
|
what = 'function'
|
|
else:
|
|
what = 'object'
|
|
|
|
self._what = what
|
|
self._name = name
|
|
self._obj = obj
|
|
self._opt = options
|
|
if isinstance(docstring, str):
|
|
lines = docstring.splitlines()
|
|
else:
|
|
lines = docstring
|
|
self._lines = Deque(map(str.rstrip, lines))
|
|
self._parsed_lines: list[str] = []
|
|
self._is_in_section = False
|
|
self._section_indent = 0
|
|
if not hasattr(self, '_directive_sections'):
|
|
self._directive_sections: list[str] = []
|
|
if not hasattr(self, '_sections'):
|
|
self._sections: dict[str, Callable] = {
|
|
'args': self._parse_parameters_section,
|
|
'arguments': self._parse_parameters_section,
|
|
'attention': partial(self._parse_admonition, 'attention'),
|
|
'attributes': self._parse_attributes_section,
|
|
'caution': partial(self._parse_admonition, 'caution'),
|
|
'danger': partial(self._parse_admonition, 'danger'),
|
|
'error': partial(self._parse_admonition, 'error'),
|
|
'example': self._parse_examples_section,
|
|
'examples': self._parse_examples_section,
|
|
'hint': partial(self._parse_admonition, 'hint'),
|
|
'important': partial(self._parse_admonition, 'important'),
|
|
'keyword args': self._parse_keyword_arguments_section,
|
|
'keyword arguments': self._parse_keyword_arguments_section,
|
|
'methods': self._parse_methods_section,
|
|
'note': partial(self._parse_admonition, 'note'),
|
|
'notes': self._parse_notes_section,
|
|
'other parameters': self._parse_other_parameters_section,
|
|
'parameters': self._parse_parameters_section,
|
|
'receive': self._parse_receives_section,
|
|
'receives': self._parse_receives_section,
|
|
'return': self._parse_returns_section,
|
|
'returns': self._parse_returns_section,
|
|
'raise': self._parse_raises_section,
|
|
'raises': self._parse_raises_section,
|
|
'references': self._parse_references_section,
|
|
'see also': self._parse_see_also_section,
|
|
'tip': partial(self._parse_admonition, 'tip'),
|
|
'todo': partial(self._parse_admonition, 'todo'),
|
|
'warning': partial(self._parse_admonition, 'warning'),
|
|
'warnings': partial(self._parse_admonition, 'warning'),
|
|
'warn': self._parse_warns_section,
|
|
'warns': self._parse_warns_section,
|
|
'yield': self._parse_yields_section,
|
|
'yields': self._parse_yields_section,
|
|
}
|
|
|
|
self._load_custom_sections()
|
|
|
|
self._parse()
|
|
|
|
def __str__(self) -> str:
|
|
"""Return the parsed docstring in reStructuredText format.
|
|
|
|
Returns
|
|
-------
|
|
unicode
|
|
Unicode version of the docstring.
|
|
|
|
"""
|
|
return '\n'.join(self.lines())
|
|
|
|
def lines(self) -> list[str]:
|
|
"""Return the parsed lines of the docstring in reStructuredText format.
|
|
|
|
Returns
|
|
-------
|
|
list(str)
|
|
The lines of the docstring in a list.
|
|
|
|
"""
|
|
return self._parsed_lines
|
|
|
|
def _consume_indented_block(self, indent: int = 1) -> list[str]:
|
|
lines = []
|
|
line = self._lines.get(0)
|
|
while (
|
|
not self._is_section_break() and
|
|
(not line or self._is_indented(line, indent))
|
|
):
|
|
lines.append(self._lines.next())
|
|
line = self._lines.get(0)
|
|
return lines
|
|
|
|
def _consume_contiguous(self) -> list[str]:
|
|
lines = []
|
|
while (self._lines and
|
|
self._lines.get(0) and
|
|
not self._is_section_header()):
|
|
lines.append(self._lines.next())
|
|
return lines
|
|
|
|
def _consume_empty(self) -> list[str]:
|
|
lines = []
|
|
line = self._lines.get(0)
|
|
while self._lines and not line:
|
|
lines.append(self._lines.next())
|
|
line = self._lines.get(0)
|
|
return lines
|
|
|
|
def _consume_field(self, parse_type: bool = True, prefer_type: bool = False,
|
|
) -> tuple[str, str, list[str]]:
|
|
line = self._lines.next()
|
|
|
|
before, colon, after = self._partition_field_on_colon(line)
|
|
_name, _type, _desc = before, '', after
|
|
|
|
if parse_type:
|
|
match = _google_typed_arg_regex.match(before)
|
|
if match:
|
|
_name = match.group(1).strip()
|
|
_type = match.group(2)
|
|
|
|
_name = self._escape_args_and_kwargs(_name)
|
|
|
|
if prefer_type and not _type:
|
|
_type, _name = _name, _type
|
|
|
|
if _type and self._config.napoleon_preprocess_types:
|
|
_type = _convert_type_spec(_type, self._config.napoleon_type_aliases or {})
|
|
|
|
indent = self._get_indent(line) + 1
|
|
_descs = [_desc] + self._dedent(self._consume_indented_block(indent))
|
|
_descs = self.__class__(_descs, self._config).lines()
|
|
return _name, _type, _descs
|
|
|
|
def _consume_fields(self, parse_type: bool = True, prefer_type: bool = False,
|
|
multiple: bool = False) -> list[tuple[str, str, list[str]]]:
|
|
self._consume_empty()
|
|
fields = []
|
|
while not self._is_section_break():
|
|
_name, _type, _desc = self._consume_field(parse_type, prefer_type)
|
|
if multiple and _name:
|
|
for name in _name.split(","):
|
|
fields.append((name.strip(), _type, _desc))
|
|
elif _name or _type or _desc:
|
|
fields.append((_name, _type, _desc))
|
|
return fields
|
|
|
|
def _consume_inline_attribute(self) -> tuple[str, list[str]]:
|
|
line = self._lines.next()
|
|
_type, colon, _desc = self._partition_field_on_colon(line)
|
|
if not colon or not _desc:
|
|
_type, _desc = _desc, _type
|
|
_desc += colon
|
|
_descs = [_desc] + self._dedent(self._consume_to_end())
|
|
_descs = self.__class__(_descs, self._config).lines()
|
|
return _type, _descs
|
|
|
|
def _consume_returns_section(self, preprocess_types: bool = False,
|
|
) -> list[tuple[str, str, list[str]]]:
|
|
lines = self._dedent(self._consume_to_next_section())
|
|
if lines:
|
|
before, colon, after = self._partition_field_on_colon(lines[0])
|
|
_name, _type, _desc = '', '', lines
|
|
|
|
if colon:
|
|
if after:
|
|
_desc = [after] + lines[1:]
|
|
else:
|
|
_desc = lines[1:]
|
|
|
|
_type = before
|
|
|
|
if (_type and preprocess_types and
|
|
self._config.napoleon_preprocess_types):
|
|
_type = _convert_type_spec(_type, self._config.napoleon_type_aliases or {})
|
|
|
|
_desc = self.__class__(_desc, self._config).lines()
|
|
return [(_name, _type, _desc)]
|
|
else:
|
|
return []
|
|
|
|
def _consume_usage_section(self) -> list[str]:
|
|
lines = self._dedent(self._consume_to_next_section())
|
|
return lines
|
|
|
|
def _consume_section_header(self) -> str:
|
|
section = self._lines.next()
|
|
stripped_section = section.strip(':')
|
|
if stripped_section.lower() in self._sections:
|
|
section = stripped_section
|
|
return section
|
|
|
|
def _consume_to_end(self) -> list[str]:
|
|
lines = []
|
|
while self._lines:
|
|
lines.append(self._lines.next())
|
|
return lines
|
|
|
|
def _consume_to_next_section(self) -> list[str]:
|
|
self._consume_empty()
|
|
lines = []
|
|
while not self._is_section_break():
|
|
lines.append(self._lines.next())
|
|
return lines + self._consume_empty()
|
|
|
|
def _dedent(self, lines: list[str], full: bool = False) -> list[str]:
|
|
if full:
|
|
return [line.lstrip() for line in lines]
|
|
else:
|
|
min_indent = self._get_min_indent(lines)
|
|
return [line[min_indent:] for line in lines]
|
|
|
|
def _escape_args_and_kwargs(self, name: str) -> str:
|
|
if name.endswith('_') and getattr(self._config, 'strip_signature_backslash', False):
|
|
name = name[:-1] + r'\_'
|
|
|
|
if name[:2] == '**':
|
|
return r'\*\*' + name[2:]
|
|
elif name[:1] == '*':
|
|
return r'\*' + name[1:]
|
|
else:
|
|
return name
|
|
|
|
def _fix_field_desc(self, desc: list[str]) -> list[str]:
|
|
if self._is_list(desc):
|
|
desc = [''] + desc
|
|
elif desc[0].endswith('::'):
|
|
desc_block = desc[1:]
|
|
indent = self._get_indent(desc[0])
|
|
block_indent = self._get_initial_indent(desc_block)
|
|
if block_indent > indent:
|
|
desc = [''] + desc
|
|
else:
|
|
desc = ['', desc[0]] + self._indent(desc_block, 4)
|
|
return desc
|
|
|
|
def _format_admonition(self, admonition: str, lines: list[str]) -> list[str]:
|
|
lines = self._strip_empty(lines)
|
|
if len(lines) == 1:
|
|
return [f'.. {admonition}:: {lines[0].strip()}', '']
|
|
elif lines:
|
|
lines = self._indent(self._dedent(lines), 3)
|
|
return ['.. %s::' % admonition, ''] + lines + ['']
|
|
else:
|
|
return ['.. %s::' % admonition, '']
|
|
|
|
def _format_block(
|
|
self, prefix: str, lines: list[str], padding: str | None = None,
|
|
) -> list[str]:
|
|
if lines:
|
|
if padding is None:
|
|
padding = ' ' * len(prefix)
|
|
result_lines = []
|
|
for i, line in enumerate(lines):
|
|
if i == 0:
|
|
result_lines.append((prefix + line).rstrip())
|
|
elif line:
|
|
result_lines.append(padding + line)
|
|
else:
|
|
result_lines.append('')
|
|
return result_lines
|
|
else:
|
|
return [prefix]
|
|
|
|
def _format_docutils_params(self, fields: list[tuple[str, str, list[str]]],
|
|
field_role: str = 'param', type_role: str = 'type',
|
|
) -> list[str]:
|
|
lines = []
|
|
for _name, _type, _desc in fields:
|
|
_desc = self._strip_empty(_desc)
|
|
if any(_desc):
|
|
_desc = self._fix_field_desc(_desc)
|
|
field = f':{field_role} {_name}: '
|
|
lines.extend(self._format_block(field, _desc))
|
|
else:
|
|
lines.append(f':{field_role} {_name}:')
|
|
|
|
if _type:
|
|
lines.append(f':{type_role} {_name}: {_type}')
|
|
return lines + ['']
|
|
|
|
def _format_field(self, _name: str, _type: str, _desc: list[str]) -> list[str]:
|
|
_desc = self._strip_empty(_desc)
|
|
has_desc = any(_desc)
|
|
separator = ' -- ' if has_desc else ''
|
|
if _name:
|
|
if _type:
|
|
if '`' in _type:
|
|
field = f'**{_name}** ({_type}){separator}'
|
|
else:
|
|
field = f'**{_name}** (*{_type}*){separator}'
|
|
else:
|
|
field = f'**{_name}**{separator}'
|
|
elif _type:
|
|
if '`' in _type:
|
|
field = f'{_type}{separator}'
|
|
else:
|
|
field = f'*{_type}*{separator}'
|
|
else:
|
|
field = ''
|
|
|
|
if has_desc:
|
|
_desc = self._fix_field_desc(_desc)
|
|
if _desc[0]:
|
|
return [field + _desc[0]] + _desc[1:]
|
|
else:
|
|
return [field] + _desc
|
|
else:
|
|
return [field]
|
|
|
|
def _format_fields(self, field_type: str, fields: list[tuple[str, str, list[str]]],
|
|
) -> list[str]:
|
|
field_type = ':%s:' % field_type.strip()
|
|
padding = ' ' * len(field_type)
|
|
multi = len(fields) > 1
|
|
lines: list[str] = []
|
|
for _name, _type, _desc in fields:
|
|
field = self._format_field(_name, _type, _desc)
|
|
if multi:
|
|
if lines:
|
|
lines.extend(self._format_block(padding + ' * ', field))
|
|
else:
|
|
lines.extend(self._format_block(field_type + ' * ', field))
|
|
else:
|
|
lines.extend(self._format_block(field_type + ' ', field))
|
|
if lines and lines[-1]:
|
|
lines.append('')
|
|
return lines
|
|
|
|
def _get_current_indent(self, peek_ahead: int = 0) -> int:
|
|
line = self._lines.get(peek_ahead)
|
|
while line is not self._lines.sentinel:
|
|
if line:
|
|
return self._get_indent(line)
|
|
peek_ahead += 1
|
|
line = self._lines.get(peek_ahead)
|
|
return 0
|
|
|
|
def _get_indent(self, line: str) -> int:
|
|
for i, s in enumerate(line):
|
|
if not s.isspace():
|
|
return i
|
|
return len(line)
|
|
|
|
def _get_initial_indent(self, lines: list[str]) -> int:
|
|
for line in lines:
|
|
if line:
|
|
return self._get_indent(line)
|
|
return 0
|
|
|
|
def _get_min_indent(self, lines: list[str]) -> int:
|
|
min_indent = None
|
|
for line in lines:
|
|
if line:
|
|
indent = self._get_indent(line)
|
|
if min_indent is None or indent < min_indent:
|
|
min_indent = indent
|
|
return min_indent or 0
|
|
|
|
def _indent(self, lines: list[str], n: int = 4) -> list[str]:
|
|
return [(' ' * n) + line for line in lines]
|
|
|
|
def _is_indented(self, line: str, indent: int = 1) -> bool:
|
|
for i, s in enumerate(line): # noqa: SIM110
|
|
if i >= indent:
|
|
return True
|
|
elif not s.isspace():
|
|
return False
|
|
return False
|
|
|
|
def _is_list(self, lines: list[str]) -> bool:
|
|
if not lines:
|
|
return False
|
|
if _bullet_list_regex.match(lines[0]):
|
|
return True
|
|
if _enumerated_list_regex.match(lines[0]):
|
|
return True
|
|
if len(lines) < 2 or lines[0].endswith('::'):
|
|
return False
|
|
indent = self._get_indent(lines[0])
|
|
next_indent = indent
|
|
for line in lines[1:]:
|
|
if line:
|
|
next_indent = self._get_indent(line)
|
|
break
|
|
return next_indent > indent
|
|
|
|
def _is_section_header(self) -> bool:
|
|
section = self._lines.get(0).lower()
|
|
match = _google_section_regex.match(section)
|
|
if match and section.strip(':') in self._sections:
|
|
header_indent = self._get_indent(section)
|
|
section_indent = self._get_current_indent(peek_ahead=1)
|
|
return section_indent > header_indent
|
|
elif self._directive_sections:
|
|
if _directive_regex.match(section):
|
|
for directive_section in self._directive_sections:
|
|
if section.startswith(directive_section):
|
|
return True
|
|
return False
|
|
|
|
def _is_section_break(self) -> bool:
|
|
line = self._lines.get(0)
|
|
return (not self._lines or
|
|
self._is_section_header() or
|
|
(self._is_in_section and
|
|
line and
|
|
not self._is_indented(line, self._section_indent)))
|
|
|
|
def _load_custom_sections(self) -> None:
|
|
if self._config.napoleon_custom_sections is not None:
|
|
for entry in self._config.napoleon_custom_sections:
|
|
if isinstance(entry, str):
|
|
# if entry is just a label, add to sections list,
|
|
# using generic section logic.
|
|
self._sections[entry.lower()] = self._parse_custom_generic_section
|
|
else:
|
|
# otherwise, assume entry is container;
|
|
if entry[1] == "params_style":
|
|
self._sections[entry[0].lower()] = \
|
|
self._parse_custom_params_style_section
|
|
elif entry[1] == "returns_style":
|
|
self._sections[entry[0].lower()] = \
|
|
self._parse_custom_returns_style_section
|
|
else:
|
|
# [0] is new section, [1] is the section to alias.
|
|
# in the case of key mismatch, just handle as generic section.
|
|
self._sections[entry[0].lower()] = \
|
|
self._sections.get(entry[1].lower(),
|
|
self._parse_custom_generic_section)
|
|
|
|
def _parse(self) -> None:
|
|
self._parsed_lines = self._consume_empty()
|
|
|
|
if self._name and self._what in ('attribute', 'data', 'property'):
|
|
res: list[str] = []
|
|
with contextlib.suppress(StopIteration):
|
|
res = self._parse_attribute_docstring()
|
|
|
|
self._parsed_lines.extend(res)
|
|
return
|
|
|
|
while self._lines:
|
|
if self._is_section_header():
|
|
try:
|
|
section = self._consume_section_header()
|
|
self._is_in_section = True
|
|
self._section_indent = self._get_current_indent()
|
|
if _directive_regex.match(section):
|
|
lines = [section] + self._consume_to_next_section()
|
|
else:
|
|
lines = self._sections[section.lower()](section)
|
|
finally:
|
|
self._is_in_section = False
|
|
self._section_indent = 0
|
|
else:
|
|
if not self._parsed_lines:
|
|
lines = self._consume_contiguous() + self._consume_empty()
|
|
else:
|
|
lines = self._consume_to_next_section()
|
|
self._parsed_lines.extend(lines)
|
|
|
|
def _parse_admonition(self, admonition: str, section: str) -> list[str]:
|
|
# type (str, str) -> List[str]
|
|
lines = self._consume_to_next_section()
|
|
return self._format_admonition(admonition, lines)
|
|
|
|
def _parse_attribute_docstring(self) -> list[str]:
|
|
_type, _desc = self._consume_inline_attribute()
|
|
lines = self._format_field('', '', _desc)
|
|
if _type:
|
|
lines.extend(['', ':type: %s' % _type])
|
|
return lines
|
|
|
|
def _parse_attributes_section(self, section: str) -> list[str]:
|
|
lines = []
|
|
for _name, _type, _desc in self._consume_fields():
|
|
if not _type:
|
|
_type = self._lookup_annotation(_name)
|
|
if self._config.napoleon_use_ivar:
|
|
field = ':ivar %s: ' % _name
|
|
lines.extend(self._format_block(field, _desc))
|
|
if _type:
|
|
lines.append(f':vartype {_name}: {_type}')
|
|
else:
|
|
lines.append('.. attribute:: ' + _name)
|
|
if self._opt:
|
|
if 'no-index' in self._opt or 'noindex' in self._opt:
|
|
lines.append(' :no-index:')
|
|
lines.append('')
|
|
|
|
fields = self._format_field('', '', _desc)
|
|
lines.extend(self._indent(fields, 3))
|
|
if _type:
|
|
lines.append('')
|
|
lines.extend(self._indent([':type: %s' % _type], 3))
|
|
lines.append('')
|
|
if self._config.napoleon_use_ivar:
|
|
lines.append('')
|
|
return lines
|
|
|
|
def _parse_examples_section(self, section: str) -> list[str]:
|
|
labels = {
|
|
'example': _('Example'),
|
|
'examples': _('Examples'),
|
|
}
|
|
use_admonition = self._config.napoleon_use_admonition_for_examples
|
|
label = labels.get(section.lower(), section)
|
|
return self._parse_generic_section(label, use_admonition)
|
|
|
|
def _parse_custom_generic_section(self, section: str) -> list[str]:
|
|
# for now, no admonition for simple custom sections
|
|
return self._parse_generic_section(section, False)
|
|
|
|
def _parse_custom_params_style_section(self, section: str) -> list[str]:
|
|
return self._format_fields(section, self._consume_fields())
|
|
|
|
def _parse_custom_returns_style_section(self, section: str) -> list[str]:
|
|
fields = self._consume_returns_section(preprocess_types=True)
|
|
return self._format_fields(section, fields)
|
|
|
|
def _parse_usage_section(self, section: str) -> list[str]:
|
|
header = ['.. rubric:: Usage:', '']
|
|
block = ['.. code-block:: python', '']
|
|
lines = self._consume_usage_section()
|
|
lines = self._indent(lines, 3)
|
|
return header + block + lines + ['']
|
|
|
|
def _parse_generic_section(self, section: str, use_admonition: bool) -> list[str]:
|
|
lines = self._strip_empty(self._consume_to_next_section())
|
|
lines = self._dedent(lines)
|
|
if use_admonition:
|
|
header = '.. admonition:: %s' % section
|
|
lines = self._indent(lines, 3)
|
|
else:
|
|
header = '.. rubric:: %s' % section
|
|
if lines:
|
|
return [header, ''] + lines + ['']
|
|
else:
|
|
return [header, '']
|
|
|
|
def _parse_keyword_arguments_section(self, section: str) -> list[str]:
|
|
fields = self._consume_fields()
|
|
if self._config.napoleon_use_keyword:
|
|
return self._format_docutils_params(
|
|
fields,
|
|
field_role="keyword",
|
|
type_role="kwtype")
|
|
else:
|
|
return self._format_fields(_('Keyword Arguments'), fields)
|
|
|
|
def _parse_methods_section(self, section: str) -> list[str]:
|
|
lines: list[str] = []
|
|
for _name, _type, _desc in self._consume_fields(parse_type=False):
|
|
lines.append('.. method:: %s' % _name)
|
|
if self._opt:
|
|
if 'no-index' in self._opt or 'noindex' in self._opt:
|
|
lines.append(' :no-index:')
|
|
if _desc:
|
|
lines.extend([''] + self._indent(_desc, 3))
|
|
lines.append('')
|
|
return lines
|
|
|
|
def _parse_notes_section(self, section: str) -> list[str]:
|
|
use_admonition = self._config.napoleon_use_admonition_for_notes
|
|
return self._parse_generic_section(_('Notes'), use_admonition)
|
|
|
|
def _parse_other_parameters_section(self, section: str) -> list[str]:
|
|
if self._config.napoleon_use_param:
|
|
# Allow to declare multiple parameters at once (ex: x, y: int)
|
|
fields = self._consume_fields(multiple=True)
|
|
return self._format_docutils_params(fields)
|
|
else:
|
|
fields = self._consume_fields()
|
|
return self._format_fields(_('Other Parameters'), fields)
|
|
|
|
def _parse_parameters_section(self, section: str) -> list[str]:
|
|
if self._config.napoleon_use_param:
|
|
# Allow to declare multiple parameters at once (ex: x, y: int)
|
|
fields = self._consume_fields(multiple=True)
|
|
return self._format_docutils_params(fields)
|
|
else:
|
|
fields = self._consume_fields()
|
|
return self._format_fields(_('Parameters'), fields)
|
|
|
|
def _parse_raises_section(self, section: str) -> list[str]:
|
|
fields = self._consume_fields(parse_type=False, prefer_type=True)
|
|
lines: list[str] = []
|
|
for _name, _type, _desc in fields:
|
|
m = self._name_rgx.match(_type)
|
|
if m and m.group('name'):
|
|
_type = m.group('name')
|
|
elif _xref_regex.match(_type):
|
|
pos = _type.find('`')
|
|
_type = _type[pos + 1:-1]
|
|
_type = ' ' + _type if _type else ''
|
|
_desc = self._strip_empty(_desc)
|
|
_descs = ' ' + '\n '.join(_desc) if any(_desc) else ''
|
|
lines.append(f':raises{_type}:{_descs}')
|
|
if lines:
|
|
lines.append('')
|
|
return lines
|
|
|
|
def _parse_receives_section(self, section: str) -> list[str]:
|
|
if self._config.napoleon_use_param:
|
|
# Allow to declare multiple parameters at once (ex: x, y: int)
|
|
fields = self._consume_fields(multiple=True)
|
|
return self._format_docutils_params(fields)
|
|
else:
|
|
fields = self._consume_fields()
|
|
return self._format_fields(_('Receives'), fields)
|
|
|
|
def _parse_references_section(self, section: str) -> list[str]:
|
|
use_admonition = self._config.napoleon_use_admonition_for_references
|
|
return self._parse_generic_section(_('References'), use_admonition)
|
|
|
|
def _parse_returns_section(self, section: str) -> list[str]:
|
|
fields = self._consume_returns_section()
|
|
multi = len(fields) > 1
|
|
use_rtype = False if multi else self._config.napoleon_use_rtype
|
|
lines: list[str] = []
|
|
|
|
for _name, _type, _desc in fields:
|
|
if use_rtype:
|
|
field = self._format_field(_name, '', _desc)
|
|
else:
|
|
field = self._format_field(_name, _type, _desc)
|
|
|
|
if multi:
|
|
if lines:
|
|
lines.extend(self._format_block(' * ', field))
|
|
else:
|
|
lines.extend(self._format_block(':returns: * ', field))
|
|
else:
|
|
if any(field): # only add :returns: if there's something to say
|
|
lines.extend(self._format_block(':returns: ', field))
|
|
if _type and use_rtype:
|
|
lines.extend([':rtype: %s' % _type, ''])
|
|
if lines and lines[-1]:
|
|
lines.append('')
|
|
return lines
|
|
|
|
def _parse_see_also_section(self, section: str) -> list[str]:
|
|
return self._parse_admonition('seealso', section)
|
|
|
|
def _parse_warns_section(self, section: str) -> list[str]:
|
|
return self._format_fields(_('Warns'), self._consume_fields())
|
|
|
|
def _parse_yields_section(self, section: str) -> list[str]:
|
|
fields = self._consume_returns_section(preprocess_types=True)
|
|
return self._format_fields(_('Yields'), fields)
|
|
|
|
def _partition_field_on_colon(self, line: str) -> tuple[str, str, str]:
|
|
before_colon = []
|
|
after_colon = []
|
|
colon = ''
|
|
found_colon = False
|
|
for i, source in enumerate(_xref_or_code_regex.split(line)):
|
|
if found_colon:
|
|
after_colon.append(source)
|
|
else:
|
|
m = _single_colon_regex.search(source)
|
|
if (i % 2) == 0 and m:
|
|
found_colon = True
|
|
colon = source[m.start(): m.end()]
|
|
before_colon.append(source[:m.start()])
|
|
after_colon.append(source[m.end():])
|
|
else:
|
|
before_colon.append(source)
|
|
|
|
return ("".join(before_colon).strip(),
|
|
colon,
|
|
"".join(after_colon).strip())
|
|
|
|
def _strip_empty(self, lines: list[str]) -> list[str]:
|
|
if lines:
|
|
start = -1
|
|
for i, line in enumerate(lines):
|
|
if line:
|
|
start = i
|
|
break
|
|
if start == -1:
|
|
lines = []
|
|
end = -1
|
|
for i in reversed(range(len(lines))):
|
|
line = lines[i]
|
|
if line:
|
|
end = i
|
|
break
|
|
if start > 0 or end + 1 < len(lines):
|
|
lines = lines[start:end + 1]
|
|
return lines
|
|
|
|
def _lookup_annotation(self, _name: str) -> str:
|
|
if self._config.napoleon_attr_annotations:
|
|
if self._what in ("module", "class", "exception") and self._obj:
|
|
# cache the class annotations
|
|
if not hasattr(self, "_annotations"):
|
|
localns = getattr(self._config, "autodoc_type_aliases", {})
|
|
localns.update(getattr(
|
|
self._config, "napoleon_type_aliases", {},
|
|
) or {})
|
|
self._annotations = get_type_hints(self._obj, None, localns)
|
|
if _name in self._annotations:
|
|
return stringify_annotation(self._annotations[_name],
|
|
'fully-qualified-except-typing')
|
|
# No annotation found
|
|
return ""
|
|
|
|
|
|
def _recombine_set_tokens(tokens: list[str]) -> list[str]:
|
|
token_queue = collections.deque(tokens)
|
|
keywords = ("optional", "default")
|
|
|
|
def takewhile_set(tokens):
|
|
open_braces = 0
|
|
previous_token = None
|
|
while True:
|
|
try:
|
|
token = tokens.popleft()
|
|
except IndexError:
|
|
break
|
|
|
|
if token == ", ":
|
|
previous_token = token
|
|
continue
|
|
|
|
if not token.strip():
|
|
continue
|
|
|
|
if token in keywords:
|
|
tokens.appendleft(token)
|
|
if previous_token is not None:
|
|
tokens.appendleft(previous_token)
|
|
break
|
|
|
|
if previous_token is not None:
|
|
yield previous_token
|
|
previous_token = None
|
|
|
|
if token == "{":
|
|
open_braces += 1
|
|
elif token == "}":
|
|
open_braces -= 1
|
|
|
|
yield token
|
|
|
|
if open_braces == 0:
|
|
break
|
|
|
|
def combine_set(tokens):
|
|
while True:
|
|
try:
|
|
token = tokens.popleft()
|
|
except IndexError:
|
|
break
|
|
|
|
if token == "{":
|
|
tokens.appendleft("{")
|
|
yield "".join(takewhile_set(tokens))
|
|
else:
|
|
yield token
|
|
|
|
return list(combine_set(token_queue))
|
|
|
|
|
|
def _tokenize_type_spec(spec: str) -> list[str]:
|
|
def postprocess(item):
|
|
if _default_regex.match(item):
|
|
default = item[:7]
|
|
# can't be separated by anything other than a single space
|
|
# for now
|
|
other = item[8:]
|
|
|
|
return [default, " ", other]
|
|
else:
|
|
return [item]
|
|
|
|
tokens = [
|
|
item
|
|
for raw_token in _token_regex.split(spec)
|
|
for item in postprocess(raw_token)
|
|
if item
|
|
]
|
|
return tokens
|
|
|
|
|
|
def _token_type(token: str, location: str | None = None) -> str:
|
|
def is_numeric(token):
|
|
try:
|
|
# use complex to make sure every numeric value is detected as literal
|
|
complex(token)
|
|
except ValueError:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
if token.startswith(" ") or token.endswith(" "):
|
|
type_ = "delimiter"
|
|
elif (
|
|
is_numeric(token) or
|
|
(token.startswith("{") and token.endswith("}")) or
|
|
(token.startswith('"') and token.endswith('"')) or
|
|
(token.startswith("'") and token.endswith("'"))
|
|
):
|
|
type_ = "literal"
|
|
elif token.startswith("{"):
|
|
logger.warning(
|
|
__("invalid value set (missing closing brace): %s"),
|
|
token,
|
|
location=location,
|
|
)
|
|
type_ = "literal"
|
|
elif token.endswith("}"):
|
|
logger.warning(
|
|
__("invalid value set (missing opening brace): %s"),
|
|
token,
|
|
location=location,
|
|
)
|
|
type_ = "literal"
|
|
elif token.startswith(("'", '"')):
|
|
logger.warning(
|
|
__("malformed string literal (missing closing quote): %s"),
|
|
token,
|
|
location=location,
|
|
)
|
|
type_ = "literal"
|
|
elif token.endswith(("'", '"')):
|
|
logger.warning(
|
|
__("malformed string literal (missing opening quote): %s"),
|
|
token,
|
|
location=location,
|
|
)
|
|
type_ = "literal"
|
|
elif token in ("optional", "default"):
|
|
# default is not a official keyword (yet) but supported by the
|
|
# reference implementation (numpydoc) and widely used
|
|
type_ = "control"
|
|
elif _xref_regex.match(token):
|
|
type_ = "reference"
|
|
else:
|
|
type_ = "obj"
|
|
|
|
return type_
|
|
|
|
|
|
def _convert_numpy_type_spec(
|
|
_type: str, location: str | None = None, translations: dict | None = None,
|
|
) -> str:
|
|
if translations is None:
|
|
translations = {}
|
|
|
|
def convert_obj(obj, translations, default_translation):
|
|
translation = translations.get(obj, obj)
|
|
|
|
# use :class: (the default) only if obj is not a standard singleton
|
|
if translation in _SINGLETONS and default_translation == ":class:`%s`":
|
|
default_translation = ":obj:`%s`"
|
|
elif translation == "..." and default_translation == ":class:`%s`":
|
|
# allow referencing the builtin ...
|
|
default_translation = ":obj:`%s <Ellipsis>`"
|
|
|
|
if _xref_regex.match(translation) is None:
|
|
translation = default_translation % translation
|
|
|
|
return translation
|
|
|
|
tokens = _tokenize_type_spec(_type)
|
|
combined_tokens = _recombine_set_tokens(tokens)
|
|
types = [
|
|
(token, _token_type(token, location))
|
|
for token in combined_tokens
|
|
]
|
|
|
|
converters = {
|
|
"literal": lambda x: "``%s``" % x,
|
|
"obj": lambda x: convert_obj(x, translations, ":class:`%s`"),
|
|
"control": lambda x: "*%s*" % x,
|
|
"delimiter": lambda x: x,
|
|
"reference": lambda x: x,
|
|
}
|
|
|
|
converted = "".join(converters.get(type_)(token) # type: ignore[misc]
|
|
for token, type_ in types)
|
|
|
|
return converted
|
|
|
|
|
|
class NumpyDocstring(GoogleDocstring):
|
|
"""Convert NumPy style docstrings to reStructuredText.
|
|
|
|
Parameters
|
|
----------
|
|
docstring : :obj:`str` or :obj:`list` of :obj:`str`
|
|
The docstring to parse, given either as a string or split into
|
|
individual lines.
|
|
config: :obj:`sphinx.ext.napoleon.Config` or :obj:`sphinx.config.Config`
|
|
The configuration settings to use. If not given, defaults to the
|
|
config object on `app`; or if `app` is not given defaults to the
|
|
a new :class:`sphinx.ext.napoleon.Config` object.
|
|
|
|
|
|
Other Parameters
|
|
----------------
|
|
app : :class:`sphinx.application.Sphinx`, optional
|
|
Application object representing the Sphinx process.
|
|
what : :obj:`str`, optional
|
|
A string specifying the type of the object to which the docstring
|
|
belongs. Valid values: "module", "class", "exception", "function",
|
|
"method", "attribute".
|
|
name : :obj:`str`, optional
|
|
The fully qualified name of the object.
|
|
obj : module, class, exception, function, method, or attribute
|
|
The object to which the docstring belongs.
|
|
options : :class:`sphinx.ext.autodoc.Options`, optional
|
|
The options given to the directive: an object with attributes
|
|
inherited_members, undoc_members, show_inheritance and no_index that
|
|
are True if the flag option of same name was given to the auto
|
|
directive.
|
|
|
|
|
|
Example
|
|
-------
|
|
>>> from sphinx.ext.napoleon import Config
|
|
>>> config = Config(napoleon_use_param=True, napoleon_use_rtype=True)
|
|
>>> docstring = '''One line summary.
|
|
...
|
|
... Extended description.
|
|
...
|
|
... Parameters
|
|
... ----------
|
|
... arg1 : int
|
|
... Description of `arg1`
|
|
... arg2 : str
|
|
... Description of `arg2`
|
|
... Returns
|
|
... -------
|
|
... str
|
|
... Description of return value.
|
|
... '''
|
|
>>> print(NumpyDocstring(docstring, config))
|
|
One line summary.
|
|
<BLANKLINE>
|
|
Extended description.
|
|
<BLANKLINE>
|
|
:param arg1: Description of `arg1`
|
|
:type arg1: int
|
|
:param arg2: Description of `arg2`
|
|
:type arg2: str
|
|
<BLANKLINE>
|
|
:returns: Description of return value.
|
|
:rtype: str
|
|
<BLANKLINE>
|
|
|
|
Methods
|
|
-------
|
|
__str__()
|
|
Return the parsed docstring in reStructuredText format.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
UTF-8 encoded version of the docstring.
|
|
|
|
__unicode__()
|
|
Return the parsed docstring in reStructuredText format.
|
|
|
|
Returns
|
|
-------
|
|
unicode
|
|
Unicode version of the docstring.
|
|
|
|
lines()
|
|
Return the parsed lines of the docstring in reStructuredText format.
|
|
|
|
Returns
|
|
-------
|
|
list(str)
|
|
The lines of the docstring in a list.
|
|
|
|
"""
|
|
def __init__(
|
|
self,
|
|
docstring: str | list[str],
|
|
config: SphinxConfig | None = None,
|
|
app: Sphinx | None = None,
|
|
what: str = '',
|
|
name: str = '',
|
|
obj: Any = None,
|
|
options: Any = None,
|
|
) -> None:
|
|
self._directive_sections = ['.. index::']
|
|
super().__init__(docstring, config, app, what, name, obj, options)
|
|
|
|
def _get_location(self) -> str | None:
|
|
try:
|
|
filepath = inspect.getfile(self._obj) if self._obj is not None else None
|
|
except TypeError:
|
|
filepath = None
|
|
name = self._name
|
|
|
|
if filepath is None and name is None:
|
|
return None
|
|
elif filepath is None:
|
|
filepath = ""
|
|
|
|
return ":".join([filepath, "docstring of %s" % name])
|
|
|
|
def _escape_args_and_kwargs(self, name: str) -> str:
|
|
func = super()._escape_args_and_kwargs
|
|
|
|
if ", " in name:
|
|
return ", ".join(func(param) for param in name.split(", "))
|
|
else:
|
|
return func(name)
|
|
|
|
def _consume_field(self, parse_type: bool = True, prefer_type: bool = False,
|
|
) -> tuple[str, str, list[str]]:
|
|
line = self._lines.next()
|
|
if parse_type:
|
|
_name, _, _type = self._partition_field_on_colon(line)
|
|
else:
|
|
_name, _type = line, ''
|
|
_name, _type = _name.strip(), _type.strip()
|
|
_name = self._escape_args_and_kwargs(_name)
|
|
|
|
if parse_type and not _type:
|
|
_type = self._lookup_annotation(_name)
|
|
|
|
if prefer_type and not _type:
|
|
_type, _name = _name, _type
|
|
|
|
if self._config.napoleon_preprocess_types:
|
|
_type = _convert_numpy_type_spec(
|
|
_type,
|
|
location=self._get_location(),
|
|
translations=self._config.napoleon_type_aliases or {},
|
|
)
|
|
|
|
indent = self._get_indent(line) + 1
|
|
_desc = self._dedent(self._consume_indented_block(indent))
|
|
_desc = self.__class__(_desc, self._config).lines()
|
|
return _name, _type, _desc
|
|
|
|
def _consume_returns_section(self, preprocess_types: bool = False,
|
|
) -> list[tuple[str, str, list[str]]]:
|
|
return self._consume_fields(prefer_type=True)
|
|
|
|
def _consume_section_header(self) -> str:
|
|
section = self._lines.next()
|
|
if not _directive_regex.match(section):
|
|
# Consume the header underline
|
|
self._lines.next()
|
|
return section
|
|
|
|
def _is_section_break(self) -> bool:
|
|
line1, line2 = self._lines.get(0), self._lines.get(1)
|
|
return (not self._lines or
|
|
self._is_section_header() or
|
|
['', ''] == [line1, line2] or
|
|
(self._is_in_section and
|
|
line1 and
|
|
not self._is_indented(line1, self._section_indent)))
|
|
|
|
def _is_section_header(self) -> bool:
|
|
section, underline = self._lines.get(0), self._lines.get(1)
|
|
section = section.lower()
|
|
if section in self._sections and isinstance(underline, str):
|
|
return bool(_numpy_section_regex.match(underline))
|
|
elif self._directive_sections:
|
|
if _directive_regex.match(section):
|
|
for directive_section in self._directive_sections:
|
|
if section.startswith(directive_section):
|
|
return True
|
|
return False
|
|
|
|
def _parse_see_also_section(self, section: str) -> list[str]:
|
|
lines = self._consume_to_next_section()
|
|
try:
|
|
return self._parse_numpydoc_see_also_section(lines)
|
|
except ValueError:
|
|
return self._format_admonition('seealso', lines)
|
|
|
|
def _parse_numpydoc_see_also_section(self, content: list[str]) -> list[str]:
|
|
"""
|
|
Derived from the NumpyDoc implementation of _parse_see_also.
|
|
|
|
See Also
|
|
--------
|
|
func_name : Descriptive text
|
|
continued text
|
|
another_func_name : Descriptive text
|
|
func_name1, func_name2, :meth:`func_name`, func_name3
|
|
|
|
"""
|
|
items = []
|
|
|
|
def parse_item_name(text: str) -> tuple[str, str | None]:
|
|
"""Match ':role:`name`' or 'name'"""
|
|
m = self._name_rgx.match(text)
|
|
if m:
|
|
g = m.groups()
|
|
if g[1] is None:
|
|
return g[3], None
|
|
else:
|
|
return g[2], g[1]
|
|
raise ValueError("%s is not a item name" % text)
|
|
|
|
def push_item(name: str | None, rest: list[str]) -> None:
|
|
if not name:
|
|
return
|
|
name, role = parse_item_name(name)
|
|
items.append((name, list(rest), role))
|
|
del rest[:]
|
|
|
|
def translate(func, description, role):
|
|
translations = self._config.napoleon_type_aliases
|
|
if role is not None or not translations:
|
|
return func, description, role
|
|
|
|
translated = translations.get(func, func)
|
|
match = self._name_rgx.match(translated)
|
|
if not match:
|
|
return translated, description, role
|
|
|
|
groups = match.groupdict()
|
|
role = groups["role"]
|
|
new_func = groups["name"] or groups["name2"]
|
|
|
|
return new_func, description, role
|
|
|
|
current_func = None
|
|
rest: list[str] = []
|
|
|
|
for line in content:
|
|
if not line.strip():
|
|
continue
|
|
|
|
m = self._name_rgx.match(line)
|
|
if m and line[m.end():].strip().startswith(':'):
|
|
push_item(current_func, rest)
|
|
current_func, line = line[:m.end()], line[m.end():]
|
|
rest = [line.split(':', 1)[1].strip()]
|
|
if not rest[0]:
|
|
rest = []
|
|
elif not line.startswith(' '):
|
|
push_item(current_func, rest)
|
|
current_func = None
|
|
if ',' in line:
|
|
for func in line.split(','):
|
|
if func.strip():
|
|
push_item(func, [])
|
|
elif line.strip():
|
|
current_func = line
|
|
elif current_func is not None:
|
|
rest.append(line.strip())
|
|
push_item(current_func, rest)
|
|
|
|
if not items:
|
|
return []
|
|
|
|
# apply type aliases
|
|
items = [
|
|
translate(func, description, role)
|
|
for func, description, role in items
|
|
]
|
|
|
|
lines: list[str] = []
|
|
last_had_desc = True
|
|
for name, desc, role in items:
|
|
if role:
|
|
link = f':{role}:`{name}`'
|
|
else:
|
|
link = ':obj:`%s`' % name
|
|
if desc or last_had_desc:
|
|
lines += ['']
|
|
lines += [link]
|
|
else:
|
|
lines[-1] += ", %s" % link
|
|
if desc:
|
|
lines += self._indent([' '.join(desc)])
|
|
last_had_desc = True
|
|
else:
|
|
last_had_desc = False
|
|
lines += ['']
|
|
|
|
return self._format_admonition('seealso', lines)
|