| 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, |
| } |