| from __future__ import annotations |
| |
| import re |
| from os.path import abspath, relpath |
| from pathlib import Path |
| from typing import TYPE_CHECKING, Any, cast |
| |
| from docutils import nodes |
| from docutils.parsers.rst import directives |
| from docutils.parsers.rst.directives.admonitions import BaseAdmonition |
| from docutils.parsers.rst.directives.misc import Class |
| from docutils.parsers.rst.directives.misc import Include as BaseInclude |
| from docutils.statemachine import StateMachine |
| |
| from sphinx import addnodes |
| from sphinx.domains.changeset import VersionChange # noqa: F401 # for compatibility |
| from sphinx.domains.std import StandardDomain |
| from sphinx.locale import _, __ |
| from sphinx.util import docname_join, logging, url_re |
| from sphinx.util.docutils import SphinxDirective |
| from sphinx.util.matching import Matcher, patfilter |
| from sphinx.util.nodes import explicit_title_re |
| |
| if TYPE_CHECKING: |
| from docutils.nodes import Element, Node |
| |
| from sphinx.application import Sphinx |
| from sphinx.util.typing import OptionSpec |
| |
| |
| glob_re = re.compile(r'.*[*?\[].*') |
| logger = logging.getLogger(__name__) |
| |
| |
| def int_or_nothing(argument: str) -> int: |
| if not argument: |
| return 999 |
| return int(argument) |
| |
| |
| class TocTree(SphinxDirective): |
| """ |
| Directive to notify Sphinx about the hierarchical structure of the docs, |
| and to include a table-of-contents like tree in the current document. |
| """ |
| has_content = True |
| required_arguments = 0 |
| optional_arguments = 0 |
| final_argument_whitespace = False |
| option_spec = { |
| 'maxdepth': int, |
| 'name': directives.unchanged, |
| 'caption': directives.unchanged_required, |
| 'glob': directives.flag, |
| 'hidden': directives.flag, |
| 'includehidden': directives.flag, |
| 'numbered': int_or_nothing, |
| 'titlesonly': directives.flag, |
| 'reversed': directives.flag, |
| } |
| |
| def run(self) -> list[Node]: |
| subnode = addnodes.toctree() |
| subnode['parent'] = self.env.docname |
| |
| # (title, ref) pairs, where ref may be a document, or an external link, |
| # and title may be None if the document's title is to be used |
| subnode['entries'] = [] |
| subnode['includefiles'] = [] |
| subnode['maxdepth'] = self.options.get('maxdepth', -1) |
| subnode['caption'] = self.options.get('caption') |
| subnode['glob'] = 'glob' in self.options |
| subnode['hidden'] = 'hidden' in self.options |
| subnode['includehidden'] = 'includehidden' in self.options |
| subnode['numbered'] = self.options.get('numbered', 0) |
| subnode['titlesonly'] = 'titlesonly' in self.options |
| self.set_source_info(subnode) |
| wrappernode = nodes.compound(classes=['toctree-wrapper']) |
| wrappernode.append(subnode) |
| self.add_name(wrappernode) |
| |
| ret = self.parse_content(subnode) |
| ret.append(wrappernode) |
| return ret |
| |
| def parse_content(self, toctree: addnodes.toctree) -> list[Node]: |
| generated_docnames = frozenset(StandardDomain._virtual_doc_names) |
| suffixes = self.config.source_suffix |
| current_docname = self.env.docname |
| glob = toctree['glob'] |
| |
| # glob target documents |
| all_docnames = self.env.found_docs.copy() | generated_docnames |
| all_docnames.remove(current_docname) # remove current document |
| frozen_all_docnames = frozenset(all_docnames) |
| |
| ret: list[Node] = [] |
| excluded = Matcher(self.config.exclude_patterns) |
| for entry in self.content: |
| if not entry: |
| continue |
| |
| # look for explicit titles ("Some Title <document>") |
| explicit = explicit_title_re.match(entry) |
| url_match = url_re.match(entry) is not None |
| if glob and glob_re.match(entry) and not explicit and not url_match: |
| pat_name = docname_join(current_docname, entry) |
| doc_names = sorted(patfilter(all_docnames, pat_name)) |
| for docname in doc_names: |
| if docname in generated_docnames: |
| # don't include generated documents in globs |
| continue |
| all_docnames.remove(docname) # don't include it again |
| toctree['entries'].append((None, docname)) |
| toctree['includefiles'].append(docname) |
| if not doc_names: |
| logger.warning(__("toctree glob pattern %r didn't match any documents"), |
| entry, location=toctree) |
| continue |
| |
| if explicit: |
| ref = explicit.group(2) |
| title = explicit.group(1) |
| docname = ref |
| else: |
| ref = docname = entry |
| title = None |
| |
| # remove suffixes (backwards compatibility) |
| for suffix in suffixes: |
| if docname.endswith(suffix): |
| docname = docname.removesuffix(suffix) |
| break |
| |
| # absolutise filenames |
| docname = docname_join(current_docname, docname) |
| if url_match or ref == 'self': |
| toctree['entries'].append((title, ref)) |
| continue |
| |
| if docname not in frozen_all_docnames: |
| if excluded(self.env.doc2path(docname, False)): |
| message = __('toctree contains reference to excluded document %r') |
| subtype = 'excluded' |
| else: |
| message = __('toctree contains reference to nonexisting document %r') |
| subtype = 'not_readable' |
| |
| logger.warning(message, docname, type='toc', subtype=subtype, |
| location=toctree) |
| self.env.note_reread() |
| continue |
| |
| if docname in all_docnames: |
| all_docnames.remove(docname) |
| else: |
| logger.warning(__('duplicated entry found in toctree: %s'), docname, |
| location=toctree) |
| |
| toctree['entries'].append((title, docname)) |
| toctree['includefiles'].append(docname) |
| |
| # entries contains all entries (self references, external links etc.) |
| if 'reversed' in self.options: |
| toctree['entries'] = list(reversed(toctree['entries'])) |
| toctree['includefiles'] = list(reversed(toctree['includefiles'])) |
| |
| return ret |
| |
| |
| class Author(SphinxDirective): |
| """ |
| Directive to give the name of the author of the current document |
| or section. Shown in the output only if the show_authors option is on. |
| """ |
| has_content = False |
| required_arguments = 1 |
| optional_arguments = 0 |
| final_argument_whitespace = True |
| option_spec: OptionSpec = {} |
| |
| def run(self) -> list[Node]: |
| if not self.config.show_authors: |
| return [] |
| para: Element = nodes.paragraph(translatable=False) |
| emph = nodes.emphasis() |
| para += emph |
| if self.name == 'sectionauthor': |
| text = _('Section author: ') |
| elif self.name == 'moduleauthor': |
| text = _('Module author: ') |
| elif self.name == 'codeauthor': |
| text = _('Code author: ') |
| else: |
| text = _('Author: ') |
| emph += nodes.Text(text) |
| inodes, messages = self.state.inline_text(self.arguments[0], self.lineno) |
| emph.extend(inodes) |
| |
| ret: list[Node] = [para] |
| ret += messages |
| return ret |
| |
| |
| class SeeAlso(BaseAdmonition): |
| """ |
| An admonition mentioning things to look at as reference. |
| """ |
| node_class = addnodes.seealso |
| |
| |
| class TabularColumns(SphinxDirective): |
| """ |
| Directive to give an explicit tabulary column definition to LaTeX. |
| """ |
| has_content = False |
| required_arguments = 1 |
| optional_arguments = 0 |
| final_argument_whitespace = True |
| option_spec: OptionSpec = {} |
| |
| def run(self) -> list[Node]: |
| node = addnodes.tabular_col_spec() |
| node['spec'] = self.arguments[0] |
| self.set_source_info(node) |
| return [node] |
| |
| |
| class Centered(SphinxDirective): |
| """ |
| Directive to create a centered line of bold text. |
| """ |
| has_content = False |
| required_arguments = 1 |
| optional_arguments = 0 |
| final_argument_whitespace = True |
| option_spec: OptionSpec = {} |
| |
| def run(self) -> list[Node]: |
| if not self.arguments: |
| return [] |
| subnode: Element = addnodes.centered() |
| inodes, messages = self.state.inline_text(self.arguments[0], self.lineno) |
| subnode.extend(inodes) |
| |
| ret: list[Node] = [subnode] |
| ret += messages |
| return ret |
| |
| |
| class Acks(SphinxDirective): |
| """ |
| Directive for a list of names. |
| """ |
| has_content = True |
| required_arguments = 0 |
| optional_arguments = 0 |
| final_argument_whitespace = False |
| option_spec: OptionSpec = {} |
| |
| def run(self) -> list[Node]: |
| node = addnodes.acks() |
| node.document = self.state.document |
| self.state.nested_parse(self.content, self.content_offset, node) |
| if len(node.children) != 1 or not isinstance(node.children[0], |
| nodes.bullet_list): |
| logger.warning(__('.. acks content is not a list'), |
| location=(self.env.docname, self.lineno)) |
| return [] |
| return [node] |
| |
| |
| class HList(SphinxDirective): |
| """ |
| Directive for a list that gets compacted horizontally. |
| """ |
| has_content = True |
| required_arguments = 0 |
| optional_arguments = 0 |
| final_argument_whitespace = False |
| option_spec: OptionSpec = { |
| 'columns': int, |
| } |
| |
| def run(self) -> list[Node]: |
| ncolumns = self.options.get('columns', 2) |
| node = nodes.paragraph() |
| node.document = self.state.document |
| self.state.nested_parse(self.content, self.content_offset, node) |
| if len(node.children) != 1 or not isinstance(node.children[0], |
| nodes.bullet_list): |
| logger.warning(__('.. hlist content is not a list'), |
| location=(self.env.docname, self.lineno)) |
| return [] |
| fulllist = node.children[0] |
| # create a hlist node where the items are distributed |
| npercol, nmore = divmod(len(fulllist), ncolumns) |
| index = 0 |
| newnode = addnodes.hlist() |
| newnode['ncolumns'] = str(ncolumns) |
| for column in range(ncolumns): |
| endindex = index + ((npercol + 1) if column < nmore else npercol) |
| bullet_list = nodes.bullet_list() |
| bullet_list += fulllist.children[index:endindex] |
| newnode += addnodes.hlistcol('', bullet_list) |
| index = endindex |
| return [newnode] |
| |
| |
| class Only(SphinxDirective): |
| """ |
| Directive to only include text if the given tag(s) are enabled. |
| """ |
| has_content = True |
| required_arguments = 1 |
| optional_arguments = 0 |
| final_argument_whitespace = True |
| option_spec: OptionSpec = {} |
| |
| def run(self) -> list[Node]: |
| node = addnodes.only() |
| node.document = self.state.document |
| self.set_source_info(node) |
| node['expr'] = self.arguments[0] |
| |
| # Same as util.nested_parse_with_titles but try to handle nested |
| # sections which should be raised higher up the doctree. |
| memo: Any = self.state.memo |
| surrounding_title_styles = memo.title_styles |
| surrounding_section_level = memo.section_level |
| memo.title_styles = [] |
| memo.section_level = 0 |
| try: |
| self.state.nested_parse(self.content, self.content_offset, |
| node, match_titles=True) |
| title_styles = memo.title_styles |
| if (not surrounding_title_styles or |
| not title_styles or |
| title_styles[0] not in surrounding_title_styles or |
| not self.state.parent): |
| # No nested sections so no special handling needed. |
| return [node] |
| # Calculate the depths of the current and nested sections. |
| current_depth = 0 |
| parent = self.state.parent |
| while parent: |
| current_depth += 1 |
| parent = parent.parent |
| current_depth -= 2 |
| title_style = title_styles[0] |
| nested_depth = len(surrounding_title_styles) |
| if title_style in surrounding_title_styles: |
| nested_depth = surrounding_title_styles.index(title_style) |
| # Use these depths to determine where the nested sections should |
| # be placed in the doctree. |
| n_sects_to_raise = current_depth - nested_depth + 1 |
| parent = cast(nodes.Element, self.state.parent) |
| for _i in range(n_sects_to_raise): |
| if parent.parent: |
| parent = parent.parent |
| parent.append(node) |
| return [] |
| finally: |
| memo.title_styles = surrounding_title_styles |
| memo.section_level = surrounding_section_level |
| |
| |
| class Include(BaseInclude, SphinxDirective): |
| """ |
| Like the standard "Include" directive, but interprets absolute paths |
| "correctly", i.e. relative to source directory. |
| """ |
| |
| def run(self) -> list[Node]: |
| |
| # To properly emit "include-read" events from included RST text, |
| # we must patch the ``StateMachine.insert_input()`` method. |
| # In the future, docutils will hopefully offer a way for Sphinx |
| # to provide the RST parser to use |
| # when parsing RST text that comes in via Include directive. |
| def _insert_input(include_lines, source): |
| # First, we need to combine the lines back into text so that |
| # we can send it with the include-read event. |
| # In docutils 0.18 and later, there are two lines at the end |
| # that act as markers. |
| # We must preserve them and leave them out of the include-read event: |
| text = "\n".join(include_lines[:-2]) |
| |
| path = Path(relpath(abspath(source), start=self.env.srcdir)) |
| docname = self.env.docname |
| |
| # Emit the "include-read" event |
| arg = [text] |
| self.env.app.events.emit('include-read', path, docname, arg) |
| text = arg[0] |
| |
| # Split back into lines and reattach the two marker lines |
| include_lines = text.splitlines() + include_lines[-2:] |
| |
| # Call the parent implementation. |
| # Note that this snake does not eat its tail because we patch |
| # the *Instance* method and this call is to the *Class* method. |
| return StateMachine.insert_input(self.state_machine, include_lines, source) |
| |
| # Only enable this patch if there are listeners for 'include-read'. |
| if self.env.app.events.listeners.get('include-read'): |
| # See https://github.com/python/mypy/issues/2427 for details on the mypy issue |
| self.state_machine.insert_input = _insert_input # type: ignore[method-assign] |
| |
| if self.arguments[0].startswith('<') and \ |
| self.arguments[0].endswith('>'): |
| # docutils "standard" includes, do not do path processing |
| return super().run() |
| rel_filename, filename = self.env.relfn2path(self.arguments[0]) |
| self.arguments[0] = filename |
| self.env.note_included(filename) |
| return super().run() |
| |
| |
| def setup(app: Sphinx) -> dict[str, Any]: |
| directives.register_directive('toctree', TocTree) |
| directives.register_directive('sectionauthor', Author) |
| directives.register_directive('moduleauthor', Author) |
| directives.register_directive('codeauthor', Author) |
| directives.register_directive('seealso', SeeAlso) |
| directives.register_directive('tabularcolumns', TabularColumns) |
| directives.register_directive('centered', Centered) |
| directives.register_directive('acks', Acks) |
| directives.register_directive('hlist', HList) |
| directives.register_directive('only', Only) |
| directives.register_directive('include', Include) |
| |
| # register the standard rst class directive under a different name |
| # only for backwards compatibility now |
| directives.register_directive('cssclass', Class) |
| # new standard name when default-domain with "class" is in effect |
| directives.register_directive('rst-class', Class) |
| |
| return { |
| 'version': 'builtin', |
| 'parallel_read_safe': True, |
| 'parallel_write_safe': True, |
| } |