summaryrefslogtreecommitdiffstats
path: root/sphinx/util/docfields.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/util/docfields.py')
-rw-r--r--sphinx/util/docfields.py408
1 files changed, 408 insertions, 0 deletions
diff --git a/sphinx/util/docfields.py b/sphinx/util/docfields.py
new file mode 100644
index 0000000..c48c3be
--- /dev/null
+++ b/sphinx/util/docfields.py
@@ -0,0 +1,408 @@
+"""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)