"""Handlers for additional ReST roles.""" from __future__ import annotations import re from typing import TYPE_CHECKING, Any import docutils.parsers.rst.directives import docutils.parsers.rst.roles import docutils.parsers.rst.states from docutils import nodes, utils from sphinx import addnodes from sphinx.locale import _, __ from sphinx.util import ws_re from sphinx.util.docutils import ReferenceRole, SphinxRole if TYPE_CHECKING: from collections.abc import Sequence from docutils.nodes import Element, Node, TextElement, system_message from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx.util.typing import RoleFunction generic_docroles = { 'command': addnodes.literal_strong, 'dfn': nodes.emphasis, 'kbd': nodes.literal, 'mailheader': addnodes.literal_emphasis, 'makevar': addnodes.literal_strong, 'manpage': addnodes.manpage, 'mimetype': addnodes.literal_emphasis, 'newsgroup': addnodes.literal_emphasis, 'program': addnodes.literal_strong, # XXX should be an x-ref 'regexp': nodes.literal, } # -- generic cross-reference role ---------------------------------------------- class XRefRole(ReferenceRole): """ A generic cross-referencing role. To create a callable that can be used as a role function, create an instance of this class. The general features of this role are: * Automatic creation of a reference and a content node. * Optional separation of title and target with `title `. * The implementation is a class rather than a function to make customization easier. Customization can be done in two ways: * Supplying constructor parameters: * `fix_parens` to normalize parentheses (strip from target, and add to title if configured) * `lowercase` to lowercase the target * `nodeclass` and `innernodeclass` select the node classes for the reference and the content node * Subclassing and overwriting `process_link()` and/or `result_nodes()`. """ nodeclass: type[Element] = addnodes.pending_xref innernodeclass: type[TextElement] = nodes.literal def __init__(self, fix_parens: bool = False, lowercase: bool = False, nodeclass: type[Element] | None = None, innernodeclass: type[TextElement] | None = None, warn_dangling: bool = False) -> None: self.fix_parens = fix_parens self.lowercase = lowercase self.warn_dangling = warn_dangling if nodeclass is not None: self.nodeclass = nodeclass if innernodeclass is not None: self.innernodeclass = innernodeclass super().__init__() def update_title_and_target(self, title: str, target: str) -> tuple[str, str]: if not self.has_explicit_title: if title.endswith('()'): # remove parentheses title = title[:-2] if self.config.add_function_parentheses: # add them back to all occurrences if configured title += '()' # remove parentheses from the target too if target.endswith('()'): target = target[:-2] return title, target def run(self) -> tuple[list[Node], list[system_message]]: if ':' not in self.name: self.refdomain, self.reftype = '', self.name self.classes = ['xref', self.reftype] else: self.refdomain, self.reftype = self.name.split(':', 1) self.classes = ['xref', self.refdomain, f'{self.refdomain}-{self.reftype}'] if self.disabled: return self.create_non_xref_node() else: return self.create_xref_node() def create_non_xref_node(self) -> tuple[list[Node], list[system_message]]: text = utils.unescape(self.text[1:]) if self.fix_parens: self.has_explicit_title = False # treat as implicit text, target = self.update_title_and_target(text, "") node = self.innernodeclass(self.rawtext, text, classes=self.classes) return self.result_nodes(self.inliner.document, self.env, node, is_ref=False) def create_xref_node(self) -> tuple[list[Node], list[system_message]]: target = self.target title = self.title if self.lowercase: target = target.lower() if self.fix_parens: title, target = self.update_title_and_target(title, target) # create the reference node options = {'refdoc': self.env.docname, 'refdomain': self.refdomain, 'reftype': self.reftype, 'refexplicit': self.has_explicit_title, 'refwarn': self.warn_dangling} refnode = self.nodeclass(self.rawtext, **options) self.set_source_info(refnode) # determine the target and title for the class title, target = self.process_link(self.env, refnode, self.has_explicit_title, title, target) refnode['reftarget'] = target refnode += self.innernodeclass(self.rawtext, title, classes=self.classes) return self.result_nodes(self.inliner.document, self.env, refnode, is_ref=True) # methods that can be overwritten def process_link(self, env: BuildEnvironment, refnode: Element, has_explicit_title: bool, title: str, target: str) -> tuple[str, str]: """Called after parsing title and target text, and creating the reference node (given in *refnode*). This method can alter the reference node and must return a new (or the same) ``(title, target)`` tuple. """ return title, ws_re.sub(' ', target) def result_nodes(self, document: nodes.document, env: BuildEnvironment, node: Element, is_ref: bool) -> tuple[list[Node], list[system_message]]: """Called before returning the finished nodes. *node* is the reference node if one was created (*is_ref* is then true), else the content node. This method can add other nodes and must return a ``(nodes, messages)`` tuple (the usual return value of a role function). """ return [node], [] class AnyXRefRole(XRefRole): def process_link(self, env: BuildEnvironment, refnode: Element, has_explicit_title: bool, title: str, target: str) -> tuple[str, str]: result = super().process_link(env, refnode, has_explicit_title, title, target) # add all possible context info (i.e. std:program, py:module etc.) refnode.attributes.update(env.ref_context) return result class PEP(ReferenceRole): def run(self) -> tuple[list[Node], list[system_message]]: target_id = 'index-%s' % self.env.new_serialno('index') entries = [('single', _('Python Enhancement Proposals; PEP %s') % self.target, target_id, '', None)] index = addnodes.index(entries=entries) target = nodes.target('', '', ids=[target_id]) self.inliner.document.note_explicit_target(target) try: refuri = self.build_uri() reference = nodes.reference('', '', internal=False, refuri=refuri, classes=['pep']) if self.has_explicit_title: reference += nodes.strong(self.title, self.title) else: title = "PEP " + self.title reference += nodes.strong(title, title) except ValueError: msg = self.inliner.reporter.error(__('invalid PEP number %s') % self.target, line=self.lineno) prb = self.inliner.problematic(self.rawtext, self.rawtext, msg) return [prb], [msg] return [index, target, reference], [] def build_uri(self) -> str: base_url = self.inliner.document.settings.pep_base_url ret = self.target.split('#', 1) if len(ret) == 2: return base_url + 'pep-%04d/#%s' % (int(ret[0]), ret[1]) else: return base_url + 'pep-%04d/' % int(ret[0]) class RFC(ReferenceRole): def run(self) -> tuple[list[Node], list[system_message]]: target_id = 'index-%s' % self.env.new_serialno('index') entries = [('single', 'RFC; RFC %s' % self.target, target_id, '', None)] index = addnodes.index(entries=entries) target = nodes.target('', '', ids=[target_id]) self.inliner.document.note_explicit_target(target) try: refuri = self.build_uri() reference = nodes.reference('', '', internal=False, refuri=refuri, classes=['rfc']) if self.has_explicit_title: reference += nodes.strong(self.title, self.title) else: title = "RFC " + self.title reference += nodes.strong(title, title) except ValueError: msg = self.inliner.reporter.error(__('invalid RFC number %s') % self.target, line=self.lineno) prb = self.inliner.problematic(self.rawtext, self.rawtext, msg) return [prb], [msg] return [index, target, reference], [] def build_uri(self) -> str: base_url = self.inliner.document.settings.rfc_base_url ret = self.target.split('#', 1) if len(ret) == 2: return base_url + self.inliner.rfc_url % int(ret[0]) + '#' + ret[1] else: return base_url + self.inliner.rfc_url % int(ret[0]) _amp_re = re.compile(r'(? tuple[list[Node], list[system_message]]: node = nodes.inline(rawtext=self.rawtext, classes=[self.name]) spans = self.amp_re.split(self.text) node += nodes.Text(spans.pop(0)) for span in spans: span = span.replace('&&', '&') letter = nodes.Text(span[0]) accelerator = nodes.inline('', '', letter, classes=['accelerator']) node += accelerator node += nodes.Text(span[1:]) return [node], [] class MenuSelection(GUILabel): BULLET_CHARACTER = '\N{TRIANGULAR BULLET}' def run(self) -> tuple[list[Node], list[system_message]]: self.text = self.text.replace('-->', self.BULLET_CHARACTER) return super().run() _litvar_re = re.compile('{([^}]+)}') parens_re = re.compile(r'(\\*{|\\*})') class EmphasizedLiteral(SphinxRole): parens_re = re.compile(r'(\\\\|\\{|\\}|{|})') def run(self) -> tuple[list[Node], list[system_message]]: children = self.parse(self.text) node = nodes.literal(self.rawtext, '', *children, role=self.name.lower(), classes=[self.name]) return [node], [] def parse(self, text: str) -> list[Node]: result: list[Node] = [] stack = [''] for part in self.parens_re.split(text): if part == '\\\\': # escaped backslash stack[-1] += '\\' elif part == '{': if len(stack) >= 2 and stack[-2] == "{": # nested stack[-1] += "{" else: # start emphasis stack.append('{') stack.append('') elif part == '}': if len(stack) == 3 and stack[1] == "{" and len(stack[2]) > 0: # emphasized word found if stack[0]: result.append(nodes.Text(stack[0])) result.append(nodes.emphasis(stack[2], stack[2])) stack = [''] else: # emphasized word not found; the rparen is not a special symbol stack.append('}') stack = [''.join(stack)] elif part == '\\{': # escaped left-brace stack[-1] += '{' elif part == '\\}': # escaped right-brace stack[-1] += '}' else: # others (containing escaped braces) stack[-1] += part if ''.join(stack): # remaining is treated as Text text = ''.join(stack) result.append(nodes.Text(text)) return result _abbr_re = re.compile(r'\((.*)\)$', re.S) class Abbreviation(SphinxRole): abbr_re = re.compile(r'\((.*)\)$', re.S) def run(self) -> tuple[list[Node], list[system_message]]: options = self.options.copy() matched = self.abbr_re.search(self.text) if matched: text = self.text[:matched.start()].strip() options['explanation'] = matched.group(1) else: text = self.text return [nodes.abbreviation(self.rawtext, text, **options)], [] # Sphinx provides the `code-block` directive for highlighting code blocks. # Docutils provides the `code` role which in theory can be used similarly by # defining a custom role for a given programming language: # # .. .. role:: python(code) # :language: python # :class: highlight # # In practice this does not produce correct highlighting because it uses a # separate highlighting mechanism that results in the "long" pygments class # names rather than "short" pygments class names produced by the Sphinx # `code-block` directive and for which this extension contains CSS rules. # # In addition, even if that issue is fixed, because the highlighting # implementation in docutils, despite being based on pygments, differs from that # used by Sphinx, the output does not exactly match that produced by the Sphinx # `code-block` directive. # # This issue is noted here: //github.com/sphinx-doc/sphinx/issues/5157 # # This overrides the docutils `code` role to perform highlighting in the same # way as the Sphinx `code-block` directive. # # TODO: Change to use `SphinxRole` once SphinxRole is fixed to support options. def code_role(name: str, rawtext: str, text: str, lineno: int, inliner: docutils.parsers.rst.states.Inliner, options: dict | None = None, content: Sequence[str] = (), ) -> tuple[list[Node], list[system_message]]: if options is None: options = {} options = options.copy() docutils.parsers.rst.roles.set_classes(options) language = options.get('language', '') classes = ['code'] if language: classes.append('highlight') if 'classes' in options: classes.extend(options['classes']) if language and language not in classes: classes.append(language) node = nodes.literal(rawtext, text, classes=classes, language=language) return [node], [] code_role.options = { # type: ignore[attr-defined] 'class': docutils.parsers.rst.directives.class_option, 'language': docutils.parsers.rst.directives.unchanged, } specific_docroles: dict[str, RoleFunction] = { # links to download references 'download': XRefRole(nodeclass=addnodes.download_reference), # links to anything 'any': AnyXRefRole(warn_dangling=True), 'pep': PEP(), 'rfc': RFC(), 'guilabel': GUILabel(), 'menuselection': MenuSelection(), 'file': EmphasizedLiteral(), 'samp': EmphasizedLiteral(), 'abbr': Abbreviation(), } def setup(app: Sphinx) -> dict[str, Any]: from docutils.parsers.rst import roles for rolename, nodeclass in generic_docroles.items(): generic = roles.GenericRole(rolename, nodeclass) role = roles.CustomRole(rolename, generic, {'classes': [rolename]}) roles.register_local_role(rolename, role) for rolename, func in specific_docroles.items(): roles.register_local_role(rolename, func) # Since docutils registers it as a canonical role, override it as a # canonical role as well. roles.register_canonical_role('code', code_role) return { 'version': 'builtin', 'parallel_read_safe': True, 'parallel_write_safe': True, }