409 lines
16 KiB
Python
409 lines
16 KiB
Python
"""Utility code for "Doc fields".
|
|
|
|
"Doc fields" are reST field lists in object descriptions that will
|
|
be domain-specifically transformed to a more appealing presentation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from docutils import nodes
|
|
from docutils.nodes import Element, Node
|
|
|
|
from sphinx import addnodes
|
|
from sphinx.locale import __
|
|
from sphinx.util import logging
|
|
from sphinx.util.nodes import get_node_line
|
|
|
|
if TYPE_CHECKING:
|
|
from docutils.parsers.rst.states import Inliner
|
|
|
|
from sphinx.directives import ObjectDescription
|
|
from sphinx.environment import BuildEnvironment
|
|
from sphinx.util.typing import TextlikeNode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _is_single_paragraph(node: nodes.field_body) -> bool:
|
|
"""True if the node only contains one paragraph (and system messages)."""
|
|
if len(node) == 0:
|
|
return False
|
|
elif len(node) > 1:
|
|
for subnode in node[1:]: # type: Node
|
|
if not isinstance(subnode, nodes.system_message):
|
|
return False
|
|
if isinstance(node[0], nodes.paragraph):
|
|
return True
|
|
return False
|
|
|
|
|
|
class Field:
|
|
"""A doc field that is never grouped. It can have an argument or not, the
|
|
argument can be linked using a specified *rolename*. Field should be used
|
|
for doc fields that usually don't occur more than once.
|
|
|
|
The body can be linked using a specified *bodyrolename* if the content is
|
|
just a single inline or text node.
|
|
|
|
Example::
|
|
|
|
:returns: description of the return value
|
|
:rtype: description of the return type
|
|
"""
|
|
is_grouped = False
|
|
is_typed = False
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
names: tuple[str, ...] = (),
|
|
label: str = '',
|
|
has_arg: bool = True,
|
|
rolename: str = '',
|
|
bodyrolename: str = '',
|
|
) -> None:
|
|
self.name = name
|
|
self.names = names
|
|
self.label = label
|
|
self.has_arg = has_arg
|
|
self.rolename = rolename
|
|
self.bodyrolename = bodyrolename
|
|
|
|
def make_xref(self, rolename: str, domain: str, target: str,
|
|
innernode: type[TextlikeNode] = addnodes.literal_emphasis,
|
|
contnode: Node | None = None, env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None, location: Element | None = None) -> Node:
|
|
# note: for backwards compatibility env is last, but not optional
|
|
assert env is not None
|
|
assert (inliner is None) == (location is None), (inliner, location)
|
|
if not rolename:
|
|
return contnode or innernode(target, target)
|
|
# The domain is passed from DocFieldTransformer. So it surely exists.
|
|
# So we don't need to take care the env.get_domain() raises an exception.
|
|
role = env.get_domain(domain).role(rolename)
|
|
if role is None or inliner is None:
|
|
if role is None and inliner is not None:
|
|
msg = __("Problem in %s domain: field is supposed "
|
|
"to use role '%s', but that role is not in the domain.")
|
|
logger.warning(__(msg), domain, rolename, location=location)
|
|
refnode = addnodes.pending_xref('', refdomain=domain, refexplicit=False,
|
|
reftype=rolename, reftarget=target)
|
|
refnode += contnode or innernode(target, target)
|
|
env.get_domain(domain).process_field_xref(refnode)
|
|
return refnode
|
|
lineno = -1
|
|
if location is not None:
|
|
with contextlib.suppress(ValueError):
|
|
lineno = get_node_line(location)
|
|
ns, messages = role(rolename, target, target, lineno, inliner, {}, [])
|
|
return nodes.inline(target, '', *ns)
|
|
|
|
def make_xrefs(self, rolename: str, domain: str, target: str,
|
|
innernode: type[TextlikeNode] = addnodes.literal_emphasis,
|
|
contnode: Node | None = None, env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None, location: Element | None = None,
|
|
) -> list[Node]:
|
|
return [self.make_xref(rolename, domain, target, innernode, contnode,
|
|
env, inliner, location)]
|
|
|
|
def make_entry(self, fieldarg: str, content: list[Node]) -> tuple[str, list[Node]]:
|
|
return (fieldarg, content)
|
|
|
|
def make_field(
|
|
self,
|
|
types: dict[str, list[Node]],
|
|
domain: str,
|
|
item: tuple,
|
|
env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None,
|
|
location: Element | None = None,
|
|
) -> nodes.field:
|
|
fieldarg, content = item
|
|
fieldname = nodes.field_name('', self.label)
|
|
if fieldarg:
|
|
fieldname += nodes.Text(' ')
|
|
fieldname.extend(self.make_xrefs(self.rolename, domain,
|
|
fieldarg, nodes.Text,
|
|
env=env, inliner=inliner, location=location))
|
|
|
|
if len(content) == 1 and (
|
|
isinstance(content[0], nodes.Text) or
|
|
(isinstance(content[0], nodes.inline) and len(content[0]) == 1 and
|
|
isinstance(content[0][0], nodes.Text))):
|
|
content = self.make_xrefs(self.bodyrolename, domain,
|
|
content[0].astext(), contnode=content[0],
|
|
env=env, inliner=inliner, location=location)
|
|
fieldbody = nodes.field_body('', nodes.paragraph('', '', *content))
|
|
return nodes.field('', fieldname, fieldbody)
|
|
|
|
|
|
class GroupedField(Field):
|
|
"""
|
|
A doc field that is grouped; i.e., all fields of that type will be
|
|
transformed into one field with its body being a bulleted list. It always
|
|
has an argument. The argument can be linked using the given *rolename*.
|
|
GroupedField should be used for doc fields that can occur more than once.
|
|
If *can_collapse* is true, this field will revert to a Field if only used
|
|
once.
|
|
|
|
Example::
|
|
|
|
:raises ErrorClass: description when it is raised
|
|
"""
|
|
is_grouped = True
|
|
list_type = nodes.bullet_list
|
|
|
|
def __init__(self, name: str, names: tuple[str, ...] = (), label: str = '',
|
|
rolename: str = '', can_collapse: bool = False) -> None:
|
|
super().__init__(name, names, label, True, rolename)
|
|
self.can_collapse = can_collapse
|
|
|
|
def make_field(
|
|
self,
|
|
types: dict[str, list[Node]],
|
|
domain: str,
|
|
items: tuple,
|
|
env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None,
|
|
location: Element | None = None,
|
|
) -> nodes.field:
|
|
fieldname = nodes.field_name('', self.label)
|
|
listnode = self.list_type()
|
|
for fieldarg, content in items:
|
|
par = nodes.paragraph()
|
|
par.extend(self.make_xrefs(self.rolename, domain, fieldarg,
|
|
addnodes.literal_strong,
|
|
env=env, inliner=inliner, location=location))
|
|
par += nodes.Text(' -- ')
|
|
par += content
|
|
listnode += nodes.list_item('', par)
|
|
|
|
if len(items) == 1 and self.can_collapse:
|
|
list_item = cast(nodes.list_item, listnode[0])
|
|
fieldbody = nodes.field_body('', list_item[0])
|
|
return nodes.field('', fieldname, fieldbody)
|
|
|
|
fieldbody = nodes.field_body('', listnode)
|
|
return nodes.field('', fieldname, fieldbody)
|
|
|
|
|
|
class TypedField(GroupedField):
|
|
"""
|
|
A doc field that is grouped and has type information for the arguments. It
|
|
always has an argument. The argument can be linked using the given
|
|
*rolename*, the type using the given *typerolename*.
|
|
|
|
Two uses are possible: either parameter and type description are given
|
|
separately, using a field from *names* and one from *typenames*,
|
|
respectively, or both are given using a field from *names*, see the example.
|
|
|
|
Example::
|
|
|
|
:param foo: description of parameter foo
|
|
:type foo: SomeClass
|
|
|
|
-- or --
|
|
|
|
:param SomeClass foo: description of parameter foo
|
|
"""
|
|
is_typed = True
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
names: tuple[str, ...] = (),
|
|
typenames: tuple[str, ...] = (),
|
|
label: str = '',
|
|
rolename: str = '',
|
|
typerolename: str = '',
|
|
can_collapse: bool = False,
|
|
) -> None:
|
|
super().__init__(name, names, label, rolename, can_collapse)
|
|
self.typenames = typenames
|
|
self.typerolename = typerolename
|
|
|
|
def make_field(
|
|
self,
|
|
types: dict[str, list[Node]],
|
|
domain: str,
|
|
items: tuple,
|
|
env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None,
|
|
location: Element | None = None,
|
|
) -> nodes.field:
|
|
def handle_item(fieldarg: str, content: str) -> nodes.paragraph:
|
|
par = nodes.paragraph()
|
|
par.extend(self.make_xrefs(self.rolename, domain, fieldarg,
|
|
addnodes.literal_strong, env=env))
|
|
if fieldarg in types:
|
|
par += nodes.Text(' (')
|
|
# NOTE: using .pop() here to prevent a single type node to be
|
|
# inserted twice into the doctree, which leads to
|
|
# inconsistencies later when references are resolved
|
|
fieldtype = types.pop(fieldarg)
|
|
if len(fieldtype) == 1 and isinstance(fieldtype[0], nodes.Text):
|
|
typename = fieldtype[0].astext()
|
|
par.extend(self.make_xrefs(self.typerolename, domain, typename,
|
|
addnodes.literal_emphasis, env=env,
|
|
inliner=inliner, location=location))
|
|
else:
|
|
par += fieldtype
|
|
par += nodes.Text(')')
|
|
par += nodes.Text(' -- ')
|
|
par += content
|
|
return par
|
|
|
|
fieldname = nodes.field_name('', self.label)
|
|
if len(items) == 1 and self.can_collapse:
|
|
fieldarg, content = items[0]
|
|
bodynode: Node = handle_item(fieldarg, content)
|
|
else:
|
|
bodynode = self.list_type()
|
|
for fieldarg, content in items:
|
|
bodynode += nodes.list_item('', handle_item(fieldarg, content))
|
|
fieldbody = nodes.field_body('', bodynode)
|
|
return nodes.field('', fieldname, fieldbody)
|
|
|
|
|
|
class DocFieldTransformer:
|
|
"""
|
|
Transforms field lists in "doc field" syntax into better-looking
|
|
equivalents, using the field type definitions given on a domain.
|
|
"""
|
|
typemap: dict[str, tuple[Field, bool]]
|
|
|
|
def __init__(self, directive: ObjectDescription) -> None:
|
|
self.directive = directive
|
|
|
|
self.typemap = directive.get_field_type_map()
|
|
|
|
def transform_all(self, node: addnodes.desc_content) -> None:
|
|
"""Transform all field list children of a node."""
|
|
# don't traverse, only handle field lists that are immediate children
|
|
for child in node:
|
|
if isinstance(child, nodes.field_list):
|
|
self.transform(child)
|
|
|
|
def transform(self, node: nodes.field_list) -> None:
|
|
"""Transform a single field list *node*."""
|
|
typemap = self.typemap
|
|
|
|
entries: list[nodes.field | tuple[Field, Any, Element]] = []
|
|
groupindices: dict[str, int] = {}
|
|
types: dict[str, dict] = {}
|
|
|
|
# step 1: traverse all fields and collect field types and content
|
|
for field in cast(list[nodes.field], node):
|
|
assert len(field) == 2
|
|
field_name = cast(nodes.field_name, field[0])
|
|
field_body = cast(nodes.field_body, field[1])
|
|
try:
|
|
# split into field type and argument
|
|
fieldtype_name, fieldarg = field_name.astext().split(None, 1)
|
|
except ValueError:
|
|
# maybe an argument-less field type?
|
|
fieldtype_name, fieldarg = field_name.astext(), ''
|
|
typedesc, is_typefield = typemap.get(fieldtype_name, (None, None))
|
|
|
|
# collect the content, trying not to keep unnecessary paragraphs
|
|
if _is_single_paragraph(field_body):
|
|
paragraph = cast(nodes.paragraph, field_body[0])
|
|
content = paragraph.children
|
|
else:
|
|
content = field_body.children
|
|
|
|
# sort out unknown fields
|
|
if typedesc is None or typedesc.has_arg != bool(fieldarg):
|
|
# either the field name is unknown, or the argument doesn't
|
|
# match the spec; capitalize field name and be done with it
|
|
new_fieldname = fieldtype_name[0:1].upper() + fieldtype_name[1:]
|
|
if fieldarg:
|
|
new_fieldname += ' ' + fieldarg
|
|
field_name[0] = nodes.Text(new_fieldname)
|
|
entries.append(field)
|
|
|
|
# but if this has a type then we can at least link it
|
|
if (typedesc and is_typefield and content and
|
|
len(content) == 1 and isinstance(content[0], nodes.Text)):
|
|
typed_field = cast(TypedField, typedesc)
|
|
target = content[0].astext()
|
|
xrefs = typed_field.make_xrefs(
|
|
typed_field.typerolename,
|
|
self.directive.domain or '',
|
|
target,
|
|
contnode=content[0],
|
|
env=self.directive.state.document.settings.env,
|
|
)
|
|
if _is_single_paragraph(field_body):
|
|
paragraph = cast(nodes.paragraph, field_body[0])
|
|
paragraph.clear()
|
|
paragraph.extend(xrefs)
|
|
else:
|
|
field_body.clear()
|
|
field_body += nodes.paragraph('', '', *xrefs)
|
|
|
|
continue
|
|
|
|
typename = typedesc.name
|
|
|
|
# if the field specifies a type, put it in the types collection
|
|
if is_typefield:
|
|
# filter out only inline nodes; others will result in invalid
|
|
# markup being written out
|
|
content = [n for n in content if isinstance(n, (nodes.Inline, nodes.Text))]
|
|
if content:
|
|
types.setdefault(typename, {})[fieldarg] = content
|
|
continue
|
|
|
|
# also support syntax like ``:param type name:``
|
|
if typedesc.is_typed:
|
|
try:
|
|
argtype, argname = fieldarg.rsplit(None, 1)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
types.setdefault(typename, {})[argname] = \
|
|
[nodes.Text(argtype)]
|
|
fieldarg = argname
|
|
|
|
translatable_content = nodes.inline(field_body.rawsource,
|
|
translatable=True)
|
|
translatable_content.document = field_body.parent.document
|
|
translatable_content.source = field_body.parent.source
|
|
translatable_content.line = field_body.parent.line
|
|
translatable_content += content
|
|
|
|
# grouped entries need to be collected in one entry, while others
|
|
# get one entry per field
|
|
if typedesc.is_grouped:
|
|
if typename in groupindices:
|
|
group = cast(tuple[Field, list, Node], entries[groupindices[typename]])
|
|
else:
|
|
groupindices[typename] = len(entries)
|
|
group = (typedesc, [], field)
|
|
entries.append(group)
|
|
new_entry = typedesc.make_entry(fieldarg, [translatable_content])
|
|
group[1].append(new_entry)
|
|
else:
|
|
new_entry = typedesc.make_entry(fieldarg, [translatable_content])
|
|
entries.append((typedesc, new_entry, field))
|
|
|
|
# step 2: all entries are collected, construct the new field list
|
|
new_list = nodes.field_list()
|
|
for entry in entries:
|
|
if isinstance(entry, nodes.field):
|
|
# pass-through old field
|
|
new_list += entry
|
|
else:
|
|
fieldtype, items, location = entry
|
|
fieldtypes = types.get(fieldtype.name, {})
|
|
env = self.directive.state.document.settings.env
|
|
inliner = self.directive.state.inliner
|
|
domain = self.directive.domain or ''
|
|
new_list += fieldtype.make_field(fieldtypes, domain, items,
|
|
env=env, inliner=inliner, location=location)
|
|
|
|
node.replace_self(new_list)
|