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/directives/code.py | 482 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 sphinx/directives/code.py (limited to 'sphinx/directives/code.py') 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, + } -- cgit v1.2.3