"""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 return isinstance(node[0], nodes.paragraph) 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) # type: ignore[call-arg] # 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) # type: ignore[call-arg] 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: list[Node]) -> 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(')') has_content = any(c.astext().strip() for c in content) if has_content: 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)