diff options
Diffstat (limited to 'sphinx/directives')
-rw-r--r-- | sphinx/directives/__init__.py | 373 | ||||
-rw-r--r-- | sphinx/directives/code.py | 482 | ||||
-rw-r--r-- | sphinx/directives/other.py | 443 | ||||
-rw-r--r-- | sphinx/directives/patches.py | 189 |
4 files changed, 1487 insertions, 0 deletions
diff --git a/sphinx/directives/__init__.py b/sphinx/directives/__init__.py new file mode 100644 index 0000000..d4cf28e --- /dev/null +++ b/sphinx/directives/__init__.py @@ -0,0 +1,373 @@ +"""Handlers for additional ReST directives.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast + +from docutils import nodes +from docutils.parsers.rst import directives, roles + +from sphinx import addnodes +from sphinx.addnodes import desc_signature # NoQA: TCH001 +from sphinx.util import docutils +from sphinx.util.docfields import DocFieldTransformer, Field, TypedField +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles +from sphinx.util.typing import OptionSpec # NoQA: TCH001 + +if TYPE_CHECKING: + from docutils.nodes import Node + + from sphinx.application import Sphinx + + +# RE to strip backslash escapes +nl_escape_re = re.compile(r'\\\n') +strip_backslash_re = re.compile(r'\\(.)') + + +def optional_int(argument: str) -> int | None: + """ + Check for an integer argument or None value; raise ``ValueError`` if not. + """ + if argument is None: + return None + else: + value = int(argument) + if value < 0: + msg = 'negative value; must be positive or zero' + raise ValueError(msg) + return value + + +ObjDescT = TypeVar('ObjDescT') + + +class ObjectDescription(SphinxDirective, Generic[ObjDescT]): + """ + Directive to describe a class, function or similar object. Not used + directly, but subclassed (in domain-specific directives) to add custom + behavior. + """ + + has_content = True + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + option_spec: OptionSpec = { + 'no-index': directives.flag, + 'no-index-entry': directives.flag, + 'no-contents-entry': directives.flag, + 'no-typesetting': directives.flag, + 'noindex': directives.flag, + 'noindexentry': directives.flag, + 'nocontentsentry': directives.flag, + } + + # types of doc fields that this directive handles, see sphinx.util.docfields + doc_field_types: list[Field] = [] + domain: str | None = None + objtype: str # set when `run` method is called + indexnode: addnodes.index + + # Warning: this might be removed in future version. Don't touch this from extensions. + _doc_field_type_map: dict[str, tuple[Field, bool]] = {} + + def get_field_type_map(self) -> dict[str, tuple[Field, bool]]: + if self._doc_field_type_map == {}: + self._doc_field_type_map = {} + for field in self.doc_field_types: + for name in field.names: + self._doc_field_type_map[name] = (field, False) + + if field.is_typed: + typed_field = cast(TypedField, field) + for name in typed_field.typenames: + self._doc_field_type_map[name] = (field, True) + + return self._doc_field_type_map + + def get_signatures(self) -> list[str]: + """ + Retrieve the signatures to document from the directive arguments. By + default, signatures are given as arguments, one per line. + """ + lines = nl_escape_re.sub('', self.arguments[0]).split('\n') + if self.config.strip_signature_backslash: + # remove backslashes to support (dummy) escapes; helps Vim highlighting + return [strip_backslash_re.sub(r'\1', line.strip()) for line in lines] + else: + return [line.strip() for line in lines] + + def handle_signature(self, sig: str, signode: desc_signature) -> ObjDescT: + """ + Parse the signature *sig* into individual nodes and append them to + *signode*. If ValueError is raised, parsing is aborted and the whole + *sig* is put into a single desc_name node. + + The return value should be a value that identifies the object. It is + passed to :meth:`add_target_and_index()` unchanged, and otherwise only + used to skip duplicates. + """ + raise ValueError + + def add_target_and_index(self, name: ObjDescT, sig: str, signode: desc_signature) -> None: + """ + Add cross-reference IDs and entries to self.indexnode, if applicable. + + *name* is whatever :meth:`handle_signature()` returned. + """ + return # do nothing by default + + def before_content(self) -> None: + """ + Called before parsing content. Used to set information about the current + directive context on the build environment. + """ + pass + + def transform_content(self, contentnode: addnodes.desc_content) -> None: + """ + Called after creating the content through nested parsing, + but before the ``object-description-transform`` event is emitted, + and before the info-fields are transformed. + Can be used to manipulate the content. + """ + pass + + def after_content(self) -> None: + """ + Called after parsing content. Used to reset information about the + current directive context on the build environment. + """ + pass + + def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]: + """ + Returns a tuple of strings, one entry for each part of the object's + hierarchy (e.g. ``('module', 'submodule', 'Class', 'method')``). The + returned tuple is used to properly nest children within parents in the + table of contents, and can also be used within the + :py:meth:`_toc_entry_name` method. + + This method must not be used outwith table of contents generation. + """ + return () + + def _toc_entry_name(self, sig_node: desc_signature) -> str: + """ + Returns the text of the table of contents entry for the object. + + This function is called once, in :py:meth:`run`, to set the name for the + table of contents entry (a special attribute ``_toc_name`` is set on the + object node, later used in + ``environment.collectors.toctree.TocTreeCollector.process_doc().build_toc()`` + when the table of contents entries are collected). + + To support table of contents entries for their objects, domains must + override this method, also respecting the configuration setting + ``toc_object_entries_show_parents``. Domains must also override + :py:meth:`_object_hierarchy_parts`, with one (string) entry for each part of the + object's hierarchy. The result of this method is set on the signature + node, and can be accessed as ``sig_node['_toc_parts']`` for use within + this method. The resulting tuple is also used to properly nest children + within parents in the table of contents. + + An example implementations of this method is within the python domain + (:meth:`!PyObject._toc_entry_name`). The python domain sets the + ``_toc_parts`` attribute within the :py:meth:`handle_signature()` + method. + """ + return '' + + def run(self) -> list[Node]: + """ + Main directive entry function, called by docutils upon encountering the + directive. + + This directive is meant to be quite easily subclassable, so it delegates + to several additional methods. What it does: + + * find out if called as a domain-specific directive, set self.domain + * create a `desc` node to fit all description inside + * parse standard options, currently `no-index` + * create an index node if needed as self.indexnode + * parse all given signatures (as returned by self.get_signatures()) + using self.handle_signature(), which should either return a name + or raise ValueError + * add index entries using self.add_target_and_index() + * parse the content and handle doc fields in it + """ + if ':' in self.name: + self.domain, self.objtype = self.name.split(':', 1) + else: + self.domain, self.objtype = '', self.name + self.indexnode = addnodes.index(entries=[]) + + node = addnodes.desc() + node.document = self.state.document + source, line = self.get_source_info() + # If any options were specified to the directive, + # self.state.document.current_line will at this point be set to + # None. To ensure nodes created as part of the signature have a line + # number set, set the document's line number correctly. + # + # Note that we need to subtract one from the line number since + # note_source uses 0-based line numbers. + if line is not None: + line -= 1 + self.state.document.note_source(source, line) + node['domain'] = self.domain + # 'desctype' is a backwards compatible attribute + node['objtype'] = node['desctype'] = self.objtype + node['no-index'] = node['noindex'] = no_index = ( + 'no-index' in self.options + # xref RemovedInSphinx90Warning + # deprecate noindex in Sphinx 9.0 + or 'noindex' in self.options) + node['no-index-entry'] = node['noindexentry'] = ( + 'no-index-entry' in self.options + # xref RemovedInSphinx90Warning + # deprecate noindexentry in Sphinx 9.0 + or 'noindexentry' in self.options) + node['no-contents-entry'] = node['nocontentsentry'] = ( + 'no-contents-entry' in self.options + # xref RemovedInSphinx90Warning + # deprecate nocontentsentry in Sphinx 9.0 + or 'nocontentsentry' in self.options) + node['no-typesetting'] = ('no-typesetting' in self.options) + if self.domain: + node['classes'].append(self.domain) + node['classes'].append(node['objtype']) + + self.names: list[ObjDescT] = [] + signatures = self.get_signatures() + for sig in signatures: + # add a signature node for each signature in the current unit + # and add a reference target for it + signode = addnodes.desc_signature(sig, '') + self.set_source_info(signode) + node.append(signode) + try: + # name can also be a tuple, e.g. (classname, objname); + # this is strictly domain-specific (i.e. no assumptions may + # be made in this base class) + name = self.handle_signature(sig, signode) + except ValueError: + # signature parsing failed + signode.clear() + signode += addnodes.desc_name(sig, sig) + continue # we don't want an index entry here + finally: + # Private attributes for ToC generation. Will be modified or removed + # without notice. + if self.env.app.config.toc_object_entries: + signode['_toc_parts'] = self._object_hierarchy_parts(signode) + signode['_toc_name'] = self._toc_entry_name(signode) + else: + signode['_toc_parts'] = () + signode['_toc_name'] = '' + if name not in self.names: + self.names.append(name) + if not no_index: + # only add target and index entry if this is the first + # description of the object with this name in this desc block + self.add_target_and_index(name, sig, signode) + + contentnode = addnodes.desc_content() + node.append(contentnode) + + if self.names: + # needed for association of version{added,changed} directives + self.env.temp_data['object'] = self.names[0] + self.before_content() + nested_parse_with_titles(self.state, self.content, contentnode, self.content_offset) + self.transform_content(contentnode) + self.env.app.emit('object-description-transform', + self.domain, self.objtype, contentnode) + DocFieldTransformer(self).transform_all(contentnode) + self.env.temp_data['object'] = None + self.after_content() + + if node['no-typesetting']: + # Attempt to return the index node, and a new target node + # containing all the ids of this node and its children. + # If ``:no-index:`` is set, or there are no ids on the node + # or any of its children, then just return the index node, + # as Docutils expects a target node to have at least one id. + if node_ids := [node_id for el in node.findall(nodes.Element) + for node_id in el.get('ids', ())]: + target_node = nodes.target(ids=node_ids) + self.set_source_info(target_node) + return [self.indexnode, target_node] + return [self.indexnode] + return [self.indexnode, node] + + +class DefaultRole(SphinxDirective): + """ + Set the default interpreted text role. Overridden from docutils. + """ + + optional_arguments = 1 + final_argument_whitespace = False + + def run(self) -> list[Node]: + if not self.arguments: + docutils.unregister_role('') + return [] + role_name = self.arguments[0] + role, messages = roles.role(role_name, self.state_machine.language, + self.lineno, self.state.reporter) + if role: # type: ignore[truthy-function] + docutils.register_role('', role) # type: ignore[arg-type] + self.env.temp_data['default_role'] = role_name + else: + literal_block = nodes.literal_block(self.block_text, self.block_text) + reporter = self.state.reporter + error = reporter.error('Unknown interpreted text role "%s".' % role_name, + literal_block, line=self.lineno) + messages += [error] + + return cast(list[nodes.Node], messages) + + +class DefaultDomain(SphinxDirective): + """ + Directive to (re-)set the default domain for this source file. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = False + option_spec: OptionSpec = {} + + def run(self) -> list[Node]: + domain_name = self.arguments[0].lower() + # if domain_name not in env.domains: + # # try searching by label + # for domain in env.domains.values(): + # if domain.label.lower() == domain_name: + # domain_name = domain.name + # break + self.env.temp_data['default_domain'] = self.env.domains.get(domain_name) + return [] + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_config_value("strip_signature_backslash", False, 'env') + directives.register_directive('default-role', DefaultRole) + directives.register_directive('default-domain', DefaultDomain) + directives.register_directive('describe', ObjectDescription) + # new, more consistent, name + directives.register_directive('object', ObjectDescription) + + app.add_event('object-description-transform') + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/directives/code.py b/sphinx/directives/code.py new file mode 100644 index 0000000..8de1661 --- /dev/null +++ b/sphinx/directives/code.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import sys +import textwrap +from difflib import unified_diff +from typing import TYPE_CHECKING, Any + +from docutils import nodes +from docutils.parsers.rst import directives +from docutils.statemachine import StringList + +from sphinx import addnodes +from sphinx.directives import optional_int +from sphinx.locale import __ +from sphinx.util import logging, parselinenos +from sphinx.util.docutils import SphinxDirective + +if TYPE_CHECKING: + from docutils.nodes import Element, Node + + from sphinx.application import Sphinx + from sphinx.config import Config + from sphinx.util.typing import OptionSpec + +logger = logging.getLogger(__name__) + + +class Highlight(SphinxDirective): + """ + Directive to set the highlighting language for code blocks, as well + as the threshold for line numbers. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = False + option_spec: OptionSpec = { + 'force': directives.flag, + 'linenothreshold': directives.positive_int, + } + + def run(self) -> list[Node]: + language = self.arguments[0].strip() + linenothreshold = self.options.get('linenothreshold', sys.maxsize) + force = 'force' in self.options + + self.env.temp_data['highlight_language'] = language + return [addnodes.highlightlang(lang=language, + force=force, + linenothreshold=linenothreshold)] + + +def dedent_lines( + lines: list[str], dedent: int | None, location: tuple[str, int] | None = None, +) -> list[str]: + if dedent is None: + return textwrap.dedent(''.join(lines)).splitlines(True) + + if any(s[:dedent].strip() for s in lines): + logger.warning(__('non-whitespace stripped by dedent'), location=location) + + new_lines = [] + for line in lines: + new_line = line[dedent:] + if line.endswith('\n') and not new_line: + new_line = '\n' # keep CRLF + new_lines.append(new_line) + + return new_lines + + +def container_wrapper( + directive: SphinxDirective, literal_node: Node, caption: str, +) -> nodes.container: + container_node = nodes.container('', literal_block=True, + classes=['literal-block-wrapper']) + parsed = nodes.Element() + directive.state.nested_parse(StringList([caption], source=''), + directive.content_offset, parsed) + if isinstance(parsed[0], nodes.system_message): + msg = __('Invalid caption: %s' % parsed[0].astext()) + raise ValueError(msg) + if isinstance(parsed[0], nodes.Element): + caption_node = nodes.caption(parsed[0].rawsource, '', + *parsed[0].children) + caption_node.source = literal_node.source + caption_node.line = literal_node.line + container_node += caption_node + container_node += literal_node + return container_node + raise RuntimeError # never reached + + +class CodeBlock(SphinxDirective): + """ + Directive for a code block with special highlighting or line numbering + settings. + """ + + has_content = True + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = False + option_spec: OptionSpec = { + 'force': directives.flag, + 'linenos': directives.flag, + 'dedent': optional_int, + 'lineno-start': int, + 'emphasize-lines': directives.unchanged_required, + 'caption': directives.unchanged_required, + 'class': directives.class_option, + 'name': directives.unchanged, + } + + def run(self) -> list[Node]: + document = self.state.document + code = '\n'.join(self.content) + location = self.state_machine.get_source_and_line(self.lineno) + + linespec = self.options.get('emphasize-lines') + if linespec: + try: + nlines = len(self.content) + hl_lines = parselinenos(linespec, nlines) + if any(i >= nlines for i in hl_lines): + logger.warning(__('line number spec is out of range(1-%d): %r') % + (nlines, self.options['emphasize-lines']), + location=location) + + hl_lines = [x + 1 for x in hl_lines if x < nlines] + except ValueError as err: + return [document.reporter.warning(err, line=self.lineno)] + else: + hl_lines = None + + if 'dedent' in self.options: + location = self.state_machine.get_source_and_line(self.lineno) + lines = code.splitlines(True) + lines = dedent_lines(lines, self.options['dedent'], location=location) + code = ''.join(lines) + + literal: Element = nodes.literal_block(code, code) + if 'linenos' in self.options or 'lineno-start' in self.options: + literal['linenos'] = True + literal['classes'] += self.options.get('class', []) + literal['force'] = 'force' in self.options + if self.arguments: + # highlight language specified + literal['language'] = self.arguments[0] + else: + # no highlight language specified. Then this directive refers the current + # highlight setting via ``highlight`` directive or ``highlight_language`` + # configuration. + literal['language'] = self.env.temp_data.get('highlight_language', + self.config.highlight_language) + extra_args = literal['highlight_args'] = {} + if hl_lines is not None: + extra_args['hl_lines'] = hl_lines + if 'lineno-start' in self.options: + extra_args['linenostart'] = self.options['lineno-start'] + self.set_source_info(literal) + + caption = self.options.get('caption') + if caption: + try: + literal = container_wrapper(self, literal, caption) + except ValueError as exc: + return [document.reporter.warning(exc, line=self.lineno)] + + # literal will be note_implicit_target that is linked from caption and numref. + # when options['name'] is provided, it should be primary ID. + self.add_name(literal) + + return [literal] + + +class LiteralIncludeReader: + INVALID_OPTIONS_PAIR = [ + ('lineno-match', 'lineno-start'), + ('lineno-match', 'append'), + ('lineno-match', 'prepend'), + ('start-after', 'start-at'), + ('end-before', 'end-at'), + ('diff', 'pyobject'), + ('diff', 'lineno-start'), + ('diff', 'lineno-match'), + ('diff', 'lines'), + ('diff', 'start-after'), + ('diff', 'end-before'), + ('diff', 'start-at'), + ('diff', 'end-at'), + ] + + def __init__(self, filename: str, options: dict[str, Any], config: Config) -> None: + self.filename = filename + self.options = options + self.encoding = options.get('encoding', config.source_encoding) + self.lineno_start = self.options.get('lineno-start', 1) + + self.parse_options() + + def parse_options(self) -> None: + for option1, option2 in self.INVALID_OPTIONS_PAIR: + if option1 in self.options and option2 in self.options: + raise ValueError(__('Cannot use both "%s" and "%s" options') % + (option1, option2)) + + def read_file( + self, filename: str, location: tuple[str, int] | None = None, + ) -> list[str]: + try: + with open(filename, encoding=self.encoding, errors='strict') as f: + text = f.read() + if 'tab-width' in self.options: + text = text.expandtabs(self.options['tab-width']) + + return text.splitlines(True) + except OSError as exc: + raise OSError(__('Include file %r not found or reading it failed') % + filename) from exc + except UnicodeError as exc: + raise UnicodeError(__('Encoding %r used for reading included file %r seems to ' + 'be wrong, try giving an :encoding: option') % + (self.encoding, filename)) from exc + + def read(self, location: tuple[str, int] | None = None) -> tuple[str, int]: + if 'diff' in self.options: + lines = self.show_diff() + else: + filters = [self.pyobject_filter, + self.start_filter, + self.end_filter, + self.lines_filter, + self.dedent_filter, + self.prepend_filter, + self.append_filter] + lines = self.read_file(self.filename, location=location) + for func in filters: + lines = func(lines, location=location) + + return ''.join(lines), len(lines) + + def show_diff(self, location: tuple[str, int] | None = None) -> list[str]: + new_lines = self.read_file(self.filename) + old_filename = self.options['diff'] + old_lines = self.read_file(old_filename) + diff = unified_diff(old_lines, new_lines, str(old_filename), str(self.filename)) + return list(diff) + + def pyobject_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + pyobject = self.options.get('pyobject') + if pyobject: + from sphinx.pycode import ModuleAnalyzer + analyzer = ModuleAnalyzer.for_file(self.filename, '') + tags = analyzer.find_tags() + if pyobject not in tags: + raise ValueError(__('Object named %r not found in include file %r') % + (pyobject, self.filename)) + start = tags[pyobject][1] + end = tags[pyobject][2] + lines = lines[start - 1:end] + if 'lineno-match' in self.options: + self.lineno_start = start + + return lines + + def lines_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + linespec = self.options.get('lines') + if linespec: + linelist = parselinenos(linespec, len(lines)) + if any(i >= len(lines) for i in linelist): + logger.warning(__('line number spec is out of range(1-%d): %r') % + (len(lines), linespec), location=location) + + if 'lineno-match' in self.options: + # make sure the line list is not "disjoint". + first = linelist[0] + if all(first + i == n for i, n in enumerate(linelist)): + self.lineno_start += linelist[0] + else: + raise ValueError(__('Cannot use "lineno-match" with a disjoint ' + 'set of "lines"')) + + lines = [lines[n] for n in linelist if n < len(lines)] + if lines == []: + raise ValueError(__('Line spec %r: no lines pulled from include file %r') % + (linespec, self.filename)) + + return lines + + def start_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + if 'start-at' in self.options: + start = self.options.get('start-at') + inclusive = False + elif 'start-after' in self.options: + start = self.options.get('start-after') + inclusive = True + else: + start = None + + if start: + for lineno, line in enumerate(lines): + if start in line: + if inclusive: + if 'lineno-match' in self.options: + self.lineno_start += lineno + 1 + + return lines[lineno + 1:] + else: + if 'lineno-match' in self.options: + self.lineno_start += lineno + + return lines[lineno:] + + if inclusive is True: + raise ValueError('start-after pattern not found: %s' % start) + else: + raise ValueError('start-at pattern not found: %s' % start) + + return lines + + def end_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + if 'end-at' in self.options: + end = self.options.get('end-at') + inclusive = True + elif 'end-before' in self.options: + end = self.options.get('end-before') + inclusive = False + else: + end = None + + if end: + for lineno, line in enumerate(lines): + if end in line: + if inclusive: + return lines[:lineno + 1] + else: + if lineno == 0: + pass # end-before ignores first line + else: + return lines[:lineno] + if inclusive is True: + raise ValueError('end-at pattern not found: %s' % end) + else: + raise ValueError('end-before pattern not found: %s' % end) + + return lines + + def prepend_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + prepend = self.options.get('prepend') + if prepend: + lines.insert(0, prepend + '\n') + + return lines + + def append_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + append = self.options.get('append') + if append: + lines.append(append + '\n') + + return lines + + def dedent_filter( + self, lines: list[str], location: tuple[str, int] | None = None, + ) -> list[str]: + if 'dedent' in self.options: + return dedent_lines(lines, self.options.get('dedent'), location=location) + else: + return lines + + +class LiteralInclude(SphinxDirective): + """ + Like ``.. include:: :literal:``, but only warns if the include file is + not found, and does not raise errors. Also has several options for + selecting what to include. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + option_spec: OptionSpec = { + 'dedent': optional_int, + 'linenos': directives.flag, + 'lineno-start': int, + 'lineno-match': directives.flag, + 'tab-width': int, + 'language': directives.unchanged_required, + 'force': directives.flag, + 'encoding': directives.encoding, + 'pyobject': directives.unchanged_required, + 'lines': directives.unchanged_required, + 'start-after': directives.unchanged_required, + 'end-before': directives.unchanged_required, + 'start-at': directives.unchanged_required, + 'end-at': directives.unchanged_required, + 'prepend': directives.unchanged_required, + 'append': directives.unchanged_required, + 'emphasize-lines': directives.unchanged_required, + 'caption': directives.unchanged, + 'class': directives.class_option, + 'name': directives.unchanged, + 'diff': directives.unchanged_required, + } + + def run(self) -> list[Node]: + document = self.state.document + if not document.settings.file_insertion_enabled: + return [document.reporter.warning('File insertion disabled', + line=self.lineno)] + # convert options['diff'] to absolute path + if 'diff' in self.options: + _, path = self.env.relfn2path(self.options['diff']) + self.options['diff'] = path + + try: + location = self.state_machine.get_source_and_line(self.lineno) + rel_filename, filename = self.env.relfn2path(self.arguments[0]) + self.env.note_dependency(rel_filename) + + reader = LiteralIncludeReader(filename, self.options, self.config) + text, lines = reader.read(location=location) + + retnode: Element = nodes.literal_block(text, text, source=filename) + retnode['force'] = 'force' in self.options + self.set_source_info(retnode) + if self.options.get('diff'): # if diff is set, set udiff + retnode['language'] = 'udiff' + elif 'language' in self.options: + retnode['language'] = self.options['language'] + if ('linenos' in self.options or 'lineno-start' in self.options or + 'lineno-match' in self.options): + retnode['linenos'] = True + retnode['classes'] += self.options.get('class', []) + extra_args = retnode['highlight_args'] = {} + if 'emphasize-lines' in self.options: + hl_lines = parselinenos(self.options['emphasize-lines'], lines) + if any(i >= lines for i in hl_lines): + logger.warning(__('line number spec is out of range(1-%d): %r') % + (lines, self.options['emphasize-lines']), + location=location) + extra_args['hl_lines'] = [x + 1 for x in hl_lines if x < lines] + extra_args['linenostart'] = reader.lineno_start + + if 'caption' in self.options: + caption = self.options['caption'] or self.arguments[0] + retnode = container_wrapper(self, retnode, caption) + + # retnode will be note_implicit_target that is linked from caption and numref. + # when options['name'] is provided, it should be primary ID. + self.add_name(retnode) + + return [retnode] + except Exception as exc: + return [document.reporter.warning(exc, line=self.lineno)] + + +def setup(app: Sphinx) -> dict[str, Any]: + directives.register_directive('highlight', Highlight) + directives.register_directive('code-block', CodeBlock) + directives.register_directive('sourcecode', CodeBlock) + directives.register_directive('literalinclude', LiteralInclude) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/directives/other.py b/sphinx/directives/other.py new file mode 100644 index 0000000..65cd90b --- /dev/null +++ b/sphinx/directives/other.py @@ -0,0 +1,443 @@ +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, + } diff --git a/sphinx/directives/patches.py b/sphinx/directives/patches.py new file mode 100644 index 0000000..965b385 --- /dev/null +++ b/sphinx/directives/patches.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import os +from os import path +from typing import TYPE_CHECKING, Any, cast + +from docutils import nodes +from docutils.nodes import Node, make_id +from docutils.parsers.rst import directives +from docutils.parsers.rst.directives import images, tables +from docutils.parsers.rst.directives.misc import Meta # type: ignore[attr-defined] +from docutils.parsers.rst.roles import set_classes + +from sphinx.directives import optional_int +from sphinx.domains.math import MathDomain +from sphinx.locale import __ +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import set_source_info +from sphinx.util.osutil import SEP, os_path, relpath + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import OptionSpec + + +logger = logging.getLogger(__name__) + + +class Figure(images.Figure): + """The figure directive which applies `:name:` option to the figure node + instead of the image node. + """ + + def run(self) -> list[Node]: + name = self.options.pop('name', None) + result = super().run() + if len(result) == 2 or isinstance(result[0], nodes.system_message): + return result + + assert len(result) == 1 + figure_node = cast(nodes.figure, result[0]) + if name: + # set ``name`` to figure_node if given + self.options['name'] = name + self.add_name(figure_node) + + # copy lineno from image node + if figure_node.line is None and len(figure_node) == 2: + caption = cast(nodes.caption, figure_node[1]) + figure_node.line = caption.line + + return [figure_node] + + +class CSVTable(tables.CSVTable): + """The csv-table directive which searches a CSV file from Sphinx project's source + directory when an absolute path is given via :file: option. + """ + + def run(self) -> list[Node]: + if 'file' in self.options and self.options['file'].startswith((SEP, os.sep)): + env = self.state.document.settings.env + filename = self.options['file'] + if path.exists(filename): + logger.warning(__('":file:" option for csv-table directive now recognizes ' + 'an absolute path as a relative path from source directory. ' + 'Please update your document.'), + location=(env.docname, self.lineno)) + else: + abspath = path.join(env.srcdir, os_path(self.options['file'][1:])) + docdir = path.dirname(env.doc2path(env.docname)) + self.options['file'] = relpath(abspath, docdir) + + return super().run() + + +class Code(SphinxDirective): + """Parse and mark up content of a code block. + + This is compatible with docutils' :rst:dir:`code` directive. + """ + optional_arguments = 1 + option_spec: OptionSpec = { + 'class': directives.class_option, + 'force': directives.flag, + 'name': directives.unchanged, + 'number-lines': optional_int, + } + has_content = True + + def run(self) -> list[Node]: + self.assert_has_content() + + set_classes(self.options) + code = '\n'.join(self.content) + node = nodes.literal_block(code, code, + classes=self.options.get('classes', []), + force='force' in self.options, + highlight_args={}) + self.add_name(node) + set_source_info(self, node) + + if self.arguments: + # highlight language specified + node['language'] = self.arguments[0] + else: + # no highlight language specified. Then this directive refers the current + # highlight setting via ``highlight`` directive or ``highlight_language`` + # configuration. + node['language'] = self.env.temp_data.get('highlight_language', + self.config.highlight_language) + + if 'number-lines' in self.options: + node['linenos'] = True + + # if number given, treat as lineno-start. + if self.options['number-lines']: + node['highlight_args']['linenostart'] = self.options['number-lines'] + + return [node] + + +class MathDirective(SphinxDirective): + has_content = True + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + option_spec: OptionSpec = { + 'label': directives.unchanged, + 'name': directives.unchanged, + 'class': directives.class_option, + 'nowrap': directives.flag, + } + + def run(self) -> list[Node]: + latex = '\n'.join(self.content) + if self.arguments and self.arguments[0]: + latex = self.arguments[0] + '\n\n' + latex + label = self.options.get('label', self.options.get('name')) + node = nodes.math_block(latex, latex, + classes=self.options.get('class', []), + docname=self.env.docname, + number=None, + label=label, + nowrap='nowrap' in self.options) + self.add_name(node) + self.set_source_info(node) + + ret: list[Node] = [node] + self.add_target(ret) + return ret + + def add_target(self, ret: list[Node]) -> None: + node = cast(nodes.math_block, ret[0]) + + # assign label automatically if math_number_all enabled + if node['label'] == '' or (self.config.math_number_all and not node['label']): + seq = self.env.new_serialno('sphinx.ext.math#equations') + node['label'] = "%s:%d" % (self.env.docname, seq) + + # no targets and numbers are needed + if not node['label']: + return + + # register label to domain + domain = cast(MathDomain, self.env.get_domain('math')) + domain.note_equation(self.env.docname, node['label'], location=node) + node['number'] = domain.get_equation_number_for(node['label']) + + # add target node + node_id = make_id('equation-%s' % node['label']) + target = nodes.target('', '', ids=[node_id]) + self.state.document.note_explicit_target(target) + ret.insert(0, target) + + +def setup(app: Sphinx) -> dict[str, Any]: + directives.register_directive('figure', Figure) + directives.register_directive('meta', Meta) + directives.register_directive('csv-table', CSVTable) + directives.register_directive('code', Code) + directives.register_directive('math', MathDirective) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } |