From cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 19:25:40 +0200 Subject: Adding upstream version 7.2.6. Signed-off-by: Daniel Baumann --- sphinx/util/docfields.py | 408 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 sphinx/util/docfields.py (limited to 'sphinx/util/docfields.py') 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) -- cgit v1.2.3