diff --git a/doc/conf.py b/doc/conf.py index 49fcba462b1..9fb3f6033e0 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,11 +1,16 @@ # Sphinx documentation build configuration file +from __future__ import annotations import os import re import time +from typing import TYPE_CHECKING import sphinx +if TYPE_CHECKING: + from sphinx.application import Sphinx + os.environ['SPHINX_AUTODOC_RELOAD_MODULES'] = '1' extensions = [ @@ -208,6 +213,13 @@ ('py:class', 'sphinx.theming.Theme'), ('py:class', 'sphinxcontrib.websupport.errors.DocumentNotFoundError'), ('py:class', 'sphinxcontrib.websupport.errors.UserNotAuthorizedError'), + # stdlib + ('py:class', '_io.StringIO'), + ('py:class', 'typing_extensions.Self'), + ('py:class', 'typing_extensions.Unpack'), + # type variables + ('py:class', 'sphinx.testing.matcher.buffer.T'), + ('py:class', 'sphinx.testing.matcher.options.DT'), ('py:exc', 'docutils.nodes.SkipNode'), ('py:exc', 'sphinx.environment.NoUri'), ('py:func', 'setup'), @@ -230,7 +242,6 @@ ('std:confval', 'globaltoc_maxdepth'), } - # -- Extension interface ------------------------------------------------------- from sphinx import addnodes # NoQA: E402 @@ -274,8 +285,9 @@ def linkify(match): source[0] = source[0].replace('.. include:: ../CHANGES.rst', linkified_changelog) -def setup(app): +def setup(app: Sphinx) -> None: from sphinx.ext.autodoc import cut_lines + from sphinx.roles import code_role from sphinx.util.docfields import GroupedField app.connect('autodoc-process-docstring', cut_lines(4, what=['module'])) @@ -290,3 +302,15 @@ def setup(app): app.add_object_type( 'event', 'event', 'pair: %s; event', parse_event, doc_field_types=[fdesc] ) + + def pycode_role(name, rawtext, text, lineno, inliner, options=None, content=()): + options = (options or {}) | {'language': 'python'} + return code_role(name, rawtext, text, lineno, inliner, options, content) + + def pyrepr_role(name, rawtext, text, lineno, inliner, options=None, content=()): + # restore backslashes instead of null bytes + text = repr(text).replace(r'\x00', '\\') + return pycode_role(name, rawtext, text, lineno, inliner, options, content) + + app.add_role('py3', pycode_role) + app.add_role('py3r', pyrepr_role) diff --git a/doc/development/index.rst b/doc/development/index.rst index 55a31a0c134..25ead1e6945 100644 --- a/doc/development/index.rst +++ b/doc/development/index.rst @@ -15,6 +15,7 @@ the extension interface see :doc:`/extdev/index`. overview tutorials/index builders + testing/index .. toctree:: :caption: Theming diff --git a/doc/development/testing/index.rst b/doc/development/testing/index.rst new file mode 100644 index 00000000000..9e416e16827 --- /dev/null +++ b/doc/development/testing/index.rst @@ -0,0 +1,15 @@ +======= +Testing +======= + +The :mod:`!sphinx.testing` module provides utility classes, functions, fixtures +and markers for testing with `pytest`_. Refer to the following sections to get +started with testing integration. + +.. toctree:: + :maxdepth: 1 + + plugin + matcher + +.. _pytest: https://docs.pytest.org/en/latest/ diff --git a/doc/development/testing/matcher.rst b/doc/development/testing/matcher.rst new file mode 100644 index 00000000000..8959ed59f28 --- /dev/null +++ b/doc/development/testing/matcher.rst @@ -0,0 +1,23 @@ +Testing the Sphinx output +========================= + +.. automodule:: sphinx.testing.matcher + :members: + :member-order: bysource + +.. automodule:: sphinx.testing.matcher.options + :members: + :member-order: bysource + +.. automodule:: sphinx.testing.matcher.buffer + :members: + :member-order: bysource + +Utility functions +----------------- + +.. automodule:: sphinx.testing.matcher.cleaner + :members: + :member-order: bysource + :ignore-module-all: + diff --git a/doc/development/testing/plugin.rst b/doc/development/testing/plugin.rst new file mode 100644 index 00000000000..7d41a6a5125 --- /dev/null +++ b/doc/development/testing/plugin.rst @@ -0,0 +1,19 @@ +The Sphinx testing plugin +========================= + +The testing plugin can be enabled by adding following line in ``conftest.py``: + +.. code-block:: python + :caption: conftest.py + + pytest_plugins = ['sphinx.testing.fixtures'] + +This rest of the section is dedicated to documenting the testing features but +the reader is assumed to have some prior knowledge on `pytest`_. + +.. warning:: + + This topic is incomplete and some features are not yet documented. + +.. _pytest: https://docs.pytest.org/en/latest/ + diff --git a/pyproject.toml b/pyproject.toml index a57df0d2495..042e8b5e0a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dynamic = ["version"] [project.optional-dependencies] docs = [ "sphinxcontrib-websupport", + "typing-extensions", ] lint = [ "flake8>=3.5.0", @@ -88,6 +89,7 @@ lint = [ "sphinx-lint", "types-docutils", "types-requests", + "typing-extensions", "importlib_metadata", # for mypy (Python<=3.9) "tomli", # for mypy (Python<=3.10) "pytest>=6.0", diff --git a/sphinx/testing/matcher/__init__.py b/sphinx/testing/matcher/__init__.py new file mode 100644 index 00000000000..84e17945f85 --- /dev/null +++ b/sphinx/testing/matcher/__init__.py @@ -0,0 +1,602 @@ +"""Public module containing the matcher interface.""" + +from __future__ import annotations + +__all__ = ('LineMatcher',) + +import contextlib +import re +from collections.abc import Sequence, Set +from typing import TYPE_CHECKING, Union, cast, final + +from sphinx.testing.matcher import _engine, _util, cleaner +from sphinx.testing.matcher._util import BlockPattern, LinePattern +from sphinx.testing.matcher.buffer import Block +from sphinx.testing.matcher.options import Options, OptionsHolder + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from io import StringIO + from typing import Any, ClassVar, Literal + + from typing_extensions import Self, TypeAlias, Unpack + + from sphinx.testing.matcher._util import PatternLike, Patterns + from sphinx.testing.matcher.buffer import Line, Region + from sphinx.testing.matcher.options import CompleteOptions, Flavor + + _RegionType: TypeAlias = Literal['line', 'block'] + +LineSet: TypeAlias = Union[LinePattern, Set[LinePattern], Sequence[LinePattern]] +"""One or more valid lines to find. + +Non-compiled patterns are compiled according to the matcher's flavor. +""" + +BlockLike: TypeAlias = Union[str, BlockPattern] +"""A pattern for a block to find. + +A non-compiled pattern is compiled according to :class:`LineMatcher`'s flavor, +or the flavor of methods such as :meth:`LineMatcher.assert_block_literal`. +""" + + +class LineMatcher(OptionsHolder): + r"""Helper object for matching output lines. + + Matching output lines is achieved by matching against compiled regular + expressions, i.e., :class:`~re.Pattern` objects, e.g.,: + + >>> matcher = LineMatcher.from_lines(('Line 1', 'Line 2.0', r'Line \d+')) + >>> matcher.find(re.compile(r'Line \d+')) + ('Line 1', 'Line 2.0') + + The interface also supports non-compiled :class:`str` expressions, which + are interpreted according to the matcher :attr:`~.OptionsHolder.flavor`. + + The default flavor is :py3r:`literal`, meaning that such expressions are + escaped via :func:`re.escape` before being compiled into *exact match* + patterns, i.e., literal strings are assumed to span the entire line, + and thus are prefixed with :py3r:`\A` and :py3r:`\Z` meta-characters: + + >>> matcher.find('Line 1') + ('Line 1',) + >>> matcher.find('Line 2') + () + >>> matcher.find('Line 2.0') + ('Line 2.0',) + >>> matcher.find(r'Line \d+') + ('Line \\d+',) + + A useful flavor is :py3r:`re` which compiles :class:`str` expressions into + patterns *without* escaping them first or adding meta-characters. + + >>> matcher.find(r'Line \w+', flavor='re') + ('Line 1', 'Line 2.0') + + For some users, it might also be useful to support :mod:`fnmatch`-style + patterns described by the :py3r:`fnmatch` flavor and where strings are + translated into :mod:`fnmatch` patterns via :func:`fnmatch.translate` + but allowed to be + """ + + __slots__ = ('__content', '__stack') + + default_options: ClassVar[CompleteOptions] = OptionsHolder.default_options.copy() + + def __init__(self, content: str | StringIO, /, **options: Unpack[Options]) -> None: + """Construct a :class:`LineMatcher` for the given string content. + + :param content: The source string or stream. + :param options: The matcher options. + """ + super().__init__(**options) + self.__content = content if isinstance(content, str) else content.getvalue() + # stack of cached cleaned lines (with a possible indirection) + self.__stack: list[int | Block | None] = [None] + + @classmethod + def from_lines(cls, lines: Iterable[str] = (), /, **options: Unpack[Options]) -> Self: + r"""Construct a :class:`LineMatcher` object from a list of lines. + + This is typically useful when writing tests for :class:`LineMatcher` + since writing the lines instead of a long string is usually cleaner. + + The lines are glued together depending on the :py3r:`keep_break` + option, whose default value is given by :attr:`default_options`: + + >>> text = 'foo\nbar' + >>> m1 = LineMatcher(text) + >>> m2 = LineMatcher.from_lines(text.splitlines()) + >>> m2.lines() == m1.lines() + True + >>> m2.lines() + ('foo', 'bar') + >>> + >>> m1 = LineMatcher(text, keep_break=True) + >>> m2 = LineMatcher.from_lines(text.splitlines(True), keep_break=True) + >>> m1.lines() == m2.lines() + True + >>> m1.lines() + ('foo\n', 'bar') + """ + keep_break = options.get('keep_break', cls.default_options['keep_break']) + glue = '' if keep_break else '\n' + return cls(glue.join(lines), **options) + + def __iter__(self) -> Iterator[Line]: + """An iterator on the cached lines.""" + return self.lines().lines_iterator() + + @contextlib.contextmanager + def override(self, /, **options: Unpack[Options]) -> Iterator[None]: + """Temporarily extend the set of options with *options*.""" + self.__stack.append(None) # prepare the next cache entry + try: + with super().override(**options): + yield + finally: + self.__stack.pop() # pop the cached lines + + @property + def content(self) -> str: + """The raw content.""" + return self.__content + + def lines(self) -> Block: + """The content lines, cleaned up according to the current options. + + This method is efficient in the sense that the lines are computed + once per set of options and cached for subsequent calls. + """ + stack = self.__stack + assert stack, 'invalid stack state' + cached = stack[-1] + + if cached is not None: + if isinstance(cached, int): + return cast(Block, self.__stack[cached]) + return cached + + lines = self.__get_clean_lines() + # check if the value is the same as any of a previously cached value + # but do not use slices to avoid a copy of the stack + for addr, value in zip(range(len(stack) - 1), stack): + if isinstance(value, int): + cached = cast(Block, stack[value]) + if cached.buffer == lines: + # compare only the lines as strings + stack[-1] = value # indirection near to beginning + return cached + + if isinstance(value, Block): + if value.buffer == lines: + stack[-1] = addr # indirection + return value + + # the value did not exist yet, so we store it at most once + stack[-1] = cached = Block(lines, _check=False) + return cached + + def iterfind( + self, patterns: LineSet, /, *, flavor: Flavor | None = None + ) -> Iterator[Line]: + """Yield the lines that match one (or more) of the given patterns. + + :param patterns: The patterns deciding whether a line is selected. + :param flavor: Optional temporary flavor for non-compiled patterns. + + By convention, the following are equivalent:: + + matcher.iterfind('line to find', ...) + matcher.iterfind(['line to find'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+---------------------------+ + | Flavor | Alias | + +===========+===========================+ + | `literal` | :meth:`iterfind_literal` | + +-----------+---------------------------+ + | `re` | :meth:`iterfind_matching` | + +-----------+---------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # make sure that the patterns are correctly normalized + patterns = _engine.to_line_patterns(patterns) + if not patterns: # nothinig to match + return + + compiled_patterns = self.__compile(patterns, flavor=flavor) + # remove duplicated patterns but retain order + unique_compiled_patterns = _util.unique_everseen(compiled_patterns) + # faster to iterate over a tuple rather than a set or a list + matchers = tuple(pattern.match for pattern in unique_compiled_patterns) + + def predicate(line: Line) -> bool: + text = line.buffer + return any(matcher(text) for matcher in matchers) + + yield from filter(predicate, self) + + @final + def iterfind_literal(self, lines: LineSet, /) -> Iterator[Line]: + """Partialization of :meth:`iterfind` for the :py3r:`literal` flavor.""" + return self.iterfind(lines, flavor='literal') + + @final + def iterfind_matching(self, lines: LineSet) -> Iterator[Line]: + """Partialization of :meth:`iterfind` for the :py3r:`re` flavor.""" + return self.iterfind(lines, flavor='re') + + def find(self, patterns: LineSet, /, *, flavor: Flavor | None = None) -> tuple[Line, ...]: + """Same as :meth:`iterfind` but returns a sequence of lines.""" + # use tuple to preserve immutability + return tuple(self.iterfind(patterns, flavor=flavor)) + + @final + def find_literal(self, lines: LineSet, /) -> tuple[Line, ...]: + """Partialization of :meth:`find` for the :py3r:`literal` flavor.""" + return self.find(lines, flavor='literal') + + @final + def find_matching(self, lines: LineSet) -> tuple[Line, ...]: + """Partialization of :meth:`find` for the :py3r:`re` flavor.""" + return self.find(lines, flavor='re') + + def iterfind_blocks( + self, patterns: BlockLike, /, *, flavor: Flavor | None = None + ) -> Iterator[Block]: + r"""Yield non-overlapping blocks matching the given line patterns. + + :param patterns: The line patterns that a block must satisfy. + :param flavor: Optional temporary flavor for non-compiled patterns. + :return: An iterator on the matching blocks. + + By convention, the following are equivalent:: + + matcher.iterfind_blocks('line1\nline2', ...) + matcher.iterfind_blocks(['line1', 'line2'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+----------------------------------+ + | Flavor | Alias | + +===========+==================================+ + | `literal` | :meth:`iterfind_literal_blocks` | + +-----------+----------------------------------+ + | `re` | :meth:`iterfind_matching_blocks` | + +-----------+----------------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # The number of patterns is usually smaller than the expected lines + # and thus it is more efficient to normalize and count the number of + # patterns rather than cleaning up the entire text source. + patterns = _engine.to_block_pattern(patterns) + if not patterns: # no pattern to locate + return + + lines: Sequence[str] = self.lines() + if not lines: # no line to match + return + + if (blocksize := len(patterns)) > len(lines): # too many lines to match + return + + match_function = re.Pattern.match + compiled_block = self.__compile(patterns, flavor=flavor) + block_iterator = enumerate(_util.strict_windowed(lines, blocksize)) + for start, block in block_iterator: + # check if the block matches the patterns line by line + if all(map(match_function, compiled_block, block)): + yield Block(block, start, _check=False) + # Consume the iterator so that the next block consists + # of lines just after the block that was just yielded. + # + # Note that since the iterator yielded *block*, its + # state is already on the "next" line, so we need to + # advance the iterator by *blocksize - 1* steps. + _util.consume(block_iterator, blocksize - 1) + + @final + def iterfind_literal_blocks(self, block: BlockLike, /) -> Iterator[Block]: + """Partialization of :meth:`iterfind_blocks` for the :py3r:`literal` flavor.""" + return self.iterfind_blocks(block, flavor='literal') + + @final + def iterfind_matching_blocks(self, block: BlockLike) -> Iterator[Block]: + """Partialization of :meth:`iterfind_blocks` for the :py3r:`re` flavor.""" + return self.iterfind_blocks(block, flavor='re') + + def find_blocks( + self, pattern: BlockLike, /, *, flavor: Flavor | None = None + ) -> tuple[Block, ...]: + """Same as :meth:`iterfind_blocks` but returns a sequence of blocks. + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+------------------------------+ + | Flavor | Alias | + +===========+==============================+ + | `literal` | :meth:`find_literal_blocks` | + +-----------+------------------------------+ + | `re` | :meth:`find_matching_blocks` | + +-----------+------------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # use tuple to preserve immutability + return tuple(self.iterfind_blocks(pattern, flavor=flavor)) + + @final + def find_literal_blocks(self, block: BlockLike, /) -> tuple[Block, ...]: + """Partialization of :meth:`find_blocks` for the :py3r:`literal` flavor.""" + return self.find_blocks(block, flavor='literal') + + @final + def find_matching_blocks(self, block: BlockLike) -> tuple[Block, ...]: + """Partialization of :meth:`find_blocks` for the :py3r:`re` flavor.""" + return self.find_blocks(block, flavor='re') + + # assert methods + + def assert_any_of( + self, patterns: LineSet, /, *, count: int | None = None, flavor: Flavor | None = None + ) -> None: + """Assert the number of matching lines for the given patterns. + + :param patterns: The patterns deciding whether a line is counted. + :param count: If specified, the exact number of matching lines. + :param flavor: Optional temporary flavor for non-compiled patterns. + + By convention, the following are equivalent:: + + matcher.assert_any_of('line to find', ...) + matcher.assert_any_of(['line to find'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+----------------------------+ + | Flavor | Alias | + +===========+============================+ + | `literal` | :meth:`assert_any_literal` | + +-----------+----------------------------+ + | `re` | :meth:`assert_any_match` | + +-----------+----------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # Normalize the patterns now so that we can have a nice debugging, + # even if `to_line_patterns` is called in `iterfind` (it is a no-op + # the second time). + patterns = _engine.to_line_patterns(patterns) + lines = self.iterfind(patterns, flavor=flavor) + self.__assert_found('line', lines, patterns, count, flavor) + + @final + def assert_any_literal(self, lines: LineSet, /, *, count: int | None = None) -> None: + """Partialization of :meth:`assert_any_of` for the :py3r:`literal` flavor.""" + return self.assert_any_of(lines, count=count, flavor='literal') + + @final + def assert_any_match(self, lines: LineSet, /, *, count: int | None = None) -> None: + """Partialization of :meth:`assert_any_of` for the :py3r:`re` flavor.""" + return self.assert_any_of(lines, count=count, flavor='re') + + def assert_none_of( + self, patterns: LineSet, /, *, context: int = 3, flavor: Flavor | None = None + ) -> None: + """Assert that there exist no matching line for the given patterns. + + :param patterns: The patterns deciding whether a line is counted. + :param context: Number of lines to print around a failing line. + :param flavor: Optional temporary flavor for non-compiled patterns. + + By convention, the following are equivalent:: + + matcher.assert_none_of('some bad line', ...) + matcher.assert_none_of(['some bad line'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+---------------------------+ + | Flavor | Alias | + +===========+===========================+ + | `literal` | :meth:`assert_no_literal` | + +-----------+---------------------------+ + | `re` | :meth:`assert_no_match` | + +-----------+---------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # Normalize the patterns now so that we can have a nice debugging, + # even if `to_line_patterns` is called in `iterfind` (it is a no-op + # the second time). + if patterns := _engine.to_line_patterns(patterns): + lines = self.iterfind(patterns, flavor=flavor) + self.__assert_not_found('line', lines, patterns, context, flavor) + + @final + def assert_no_literal(self, lines: LineSet, /, *, context: int = 3) -> None: + """Partialization of :meth:`assert_no_match` for the :py3r:`literal` flavor.""" + return self.assert_none_of(lines, context=context, flavor='literal') + + @final + def assert_no_match(self, lines: LineSet, /, *, context: int = 3) -> None: + """Partialization of :meth:`assert_no_match` for the :py3r:`re` flavor.""" + return self.assert_none_of(lines, context=context, flavor='re') + + def assert_block( + self, pattern: BlockLike, /, *, count: int | None = None, flavor: Flavor | None = None + ) -> None: + r"""Assert that the number of matching blocks for the given patterns. + + :param pattern: The line patterns that a block must satisfy. + :param count: The number of blocks that should be found. + :param flavor: Optional temporary flavor for non-compiled patterns. + + By convention, the following are equivalent:: + + matcher.assert_block('line1\nline2', ...) + matcher.assert_block(['line1', 'line2'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+-------------------------------+ + | Flavor | Alias | + +===========+===============================+ + | `literal` | :meth:`assert_literal_block` | + +-----------+-------------------------------+ + | `re` | :meth:`assert_matching_block` | + +-----------+-------------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # Normalize the patterns now so that we can have a nice debugging, + # even if `to_block_pattern` is called in `iterfind` (it is a no-op + # the second time). + patterns = _engine.to_block_pattern(pattern) + blocks = self.iterfind_blocks(patterns, flavor=flavor) + self.__assert_found('block', blocks, patterns, count, flavor) + + @final + def assert_literal_block(self, block: BlockLike, /, *, count: int | None = None) -> None: + """Partialization of :meth:`assert_block` for the :py3r:`literal` flavor.""" + return self.assert_block(block, count=count, flavor='literal') + + @final + def assert_matching_block(self, block: BlockLike, /, *, count: int | None = None) -> None: + """Partialization of :meth:`assert_block` for the :py3r:`re` flavor.""" + return self.assert_block(block, count=count, flavor='re') + + def assert_no_block( + self, pattern: str | BlockPattern, /, *, context: int = 3, flavor: Flavor | None = None + ) -> None: + r"""Assert that there exist no matching blocks for the given patterns. + + :param pattern: The line patterns that a block must satisfy. + :param context: Number of lines to print around a failing block. + :param flavor: Optional temporary flavor for non-compiled patterns. + + By convention, the following are equivalent:: + + matcher.assert_no_block('line1\nline2', ...) + matcher.assert_no_block(['line1', 'line2'], ...) + + For simple usages, consider using the following flavor-binding aliases: + + .. default-role:: py3r + + +-----------+----------------------------------+ + | Flavor | Alias | + +===========+==================================+ + | `literal` | :meth:`assert_no_literal_block` | + +-----------+----------------------------------+ + | `re` | :meth:`assert_no_matching_block` | + +-----------+----------------------------------+ + + .. default-role:: + + .. seealso:: :attr:`Options.flavor ` + """ + # Normalize the patterns now so that we can have a nice debugging, + # even if `to_block_pattern` is called in `iterfind` (it is a no-op + # the second time). + if patterns := _engine.to_block_pattern(pattern): + blocks = self.iterfind_blocks(patterns, flavor=flavor) + self.__assert_not_found('block', blocks, patterns, context, flavor) + + @final + def assert_no_literal_block(self, block: BlockLike, /, *, context: int = 3) -> None: + """Partialization of :meth:`assert_no_block` for the :py3r:`literal` flavor.""" + return self.assert_no_block(block, context=context, flavor='literal') + + @final + def assert_no_matching_block(self, block: BlockLike, /, *, context: int = 3) -> None: + """Partialization of :meth:`assert_no_block` for the :py3r:`re` flavor.""" + return self.assert_no_block(block, context=context, flavor='re') + + # private + + def __assert_found( + self, + typ: _RegionType, # the region's type + regions: Iterator[Region[Any]], # the regions that were found + patterns: Iterable[PatternLike], # the patterns that were used (debug only) + count: int | None, # the expected number of regions + flavor: Flavor | None, # the flavor that was used to compile the patterns + ) -> None: + if count is None: + if next(regions, None) is not None: + return + + ctx = _util.highlight(self.lines(), keepends=self.keep_break) + pat = self.__pformat_patterns(typ, patterns) + logs = [f'{typ} pattern', pat, 'not found in', ctx] + raise AssertionError('\n\n'.join(logs)) + + indices = {region.offset: region.length for region in regions} + if (found := len(indices)) == count: + return + + ctx = _util.highlight(self.lines(), indices, keepends=self.keep_break) + pat = self.__pformat_patterns(typ, patterns) + noun = _util.plural_form(typ, count) + logs = [f'found {found} != {count} {noun} matching', pat, 'in', ctx] + raise AssertionError('\n\n'.join(logs)) + + def __assert_not_found( + self, + typ: _RegionType, + regions: Iterator[Region[Any]], + patterns: Sequence[PatternLike], + context: int, + flavor: Flavor | None, + ) -> None: + if (region := next(regions, None)) is None: + return + + pat = self.__pformat_patterns(typ, patterns) + ctx = _util.get_context_lines(self.lines(), region, context) + logs = [f'{typ} pattern', pat, 'found in', '\n'.join(ctx)] + raise AssertionError('\n\n'.join(logs)) + + def __pformat_patterns(self, typ: _RegionType, patterns: Iterable[PatternLike]) -> str: + """Prettify the *patterns* as a string to print.""" + lines = (p if isinstance(p, str) else p.pattern for p in patterns) + source = sorted(lines) if typ == 'line' else lines + return _util.indent_source(source, highlight=False) + + def __compile(self, patterns: Iterable[PatternLike], flavor: Flavor | None) -> Patterns: + flavor = self.flavor if flavor is None else flavor + return _engine.compile(patterns, flavor=flavor) + + def __get_clean_lines(self) -> tuple[str, ...]: + options = cast(Options, self.complete_options) + return tuple(cleaner.clean(self.content, **options)) diff --git a/sphinx/testing/matcher/_cleaner.py b/sphinx/testing/matcher/_cleaner.py new file mode 100644 index 00000000000..1f6d58df279 --- /dev/null +++ b/sphinx/testing/matcher/_cleaner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from functools import partial +from itertools import filterfalse +from typing import TYPE_CHECKING, TypedDict, final + +from sphinx.testing.matcher._util import unique_everseen, unique_justseen + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from typing_extensions import TypeAlias + + from sphinx.testing.matcher.options import OpCode, OptionsHolder, PrunePattern, StripChars + + DispatcherFunc: TypeAlias = Callable[[Iterable[str]], Iterable[str]] + + +@final +class HandlerMap(TypedDict): + # Whenever a new operation code is supported, do not forget to + # update :func:`get_dispatcher_map` and :func.`get_active_opcodes`. + strip: DispatcherFunc + check: DispatcherFunc + compress: DispatcherFunc + unique: DispatcherFunc + prune: DispatcherFunc + filter: DispatcherFunc + + +def get_active_opcodes(options: OptionsHolder) -> Iterable[OpCode]: + """Get the iterable of operation's codes to execute.""" + disable: set[OpCode] = set() + + if options.strip_line is False: + disable.add('strip') + + if options.keep_empty: + disable.add('check') + + if not options.compress: + disable.add('compress') + + if not options.unique: + disable.add('unique') + + if not isinstance(prune_patterns := options.prune, str) and not prune_patterns: + disable.add('prune') + + if not callable(options.ignore): + disable.add('filter') + + return filterfalse(disable.__contains__, options.ops) + + +def make_handlers(args: OptionsHolder) -> HandlerMap: + return { + 'strip': partial(_strip_lines_aux, args.strip_line), + 'check': partial(filter, None), + 'compress': unique_justseen, + 'unique': unique_everseen, + 'prune': partial(_prune_lines_aux, args.prune), + 'filter': partial(filterfalse, args.ignore), + } + + +# we do not want to expose a non-positional-only public interface +# and we want to be able to have a pickable right partialization +# in case future multi-processing is added +def _strip_lines_aux(chars: StripChars, lines: Iterable[str]) -> Iterable[str]: + # local import to break circular imports (but the module should already + # be loaded since `get_handlers` is expected to be called from there) + from .cleaner import strip_lines + + return strip_lines(lines, chars) + + +# we do not want to expose a non-positional-only public interface +# and we want to be able to have a pickable right partialization +# in case future multi-processing is added +def _prune_lines_aux(patterns: PrunePattern, lines: Iterable[str]) -> Iterable[str]: + # local import to break circular imports (but the module should already + # be loaded since `get_handlers` is expected to be called from there) + from .cleaner import prune_lines + + return prune_lines(lines, patterns, trace=None) diff --git a/sphinx/testing/matcher/_engine.py b/sphinx/testing/matcher/_engine.py new file mode 100644 index 00000000000..7f4ddc96402 --- /dev/null +++ b/sphinx/testing/matcher/_engine.py @@ -0,0 +1,181 @@ +"""Private regular expressions utilities for :mod:`sphinx.testing.matcher`. + +All objects provided by this module are considered an implementation detail. +""" + +from __future__ import annotations + +__all__ = () + +import fnmatch +import re +from collections.abc import Set +from typing import TYPE_CHECKING, overload + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Sequence + + from sphinx.testing.matcher._util import BlockPattern, LinePattern, PatternLike, Patterns + from sphinx.testing.matcher.options import Flavor + + +def _check_flavor(flavor: Flavor) -> None: + allowed: Sequence[Flavor] = ('literal', 'fnmatch', 're') + if flavor not in allowed: + msg = f'unknown flavor: {flavor!r} (choose from: {allowed})' + raise ValueError(msg) + + +def _sort_pattern(s: PatternLike) -> tuple[str, int, int]: + if isinstance(s, str): + return (s, -1, -1) + return (s.pattern, s.flags, s.groups) + + +@overload +def to_line_patterns(line: str, /) -> tuple[str]: ... # NoQA: E704 +@overload +def to_line_patterns(pattern: re.Pattern[str], /) -> tuple[re.Pattern[str]]: ... # NoQA: E704 +@overload # NoqA: E302 +def to_line_patterns( # NoQA: E704 + patterns: Set[LinePattern] | Sequence[LinePattern], / +) -> tuple[LinePattern, ...]: ... +def to_line_patterns( # NoqA: E302 + patterns: LinePattern | Set[LinePattern] | Sequence[LinePattern], / +) -> tuple[LinePattern, ...]: + """Get a read-only sequence of line-matching patterns. + + :param patterns: One or more patterns a line should match (in its entirety). + :return: The possible line patterns. + + By convention,:: + + to_line_patterns("my pattern") == to_line_patterns(["my pattern"]) + + .. note:: + + If *expect* is a :class:`~collections.abc.Set`-like object, the order + of the output sequence is an implementation detail but guaranteed to + be the same for the same inputs. Otherwise, the order of *expect* is + retained, in case this could make a difference. + """ + # This function is usually called *twice* in ``assert_*``-like routines + # and thus, we expect the inputs to mainly be strings or tuples. + # + # Nevertheless, tuples could appear more frequently than strings since + # the inputs could arise from variadic functions and thus we check for + # tuples first. + if isinstance(patterns, tuple): + return patterns + if isinstance(patterns, (str, re.Pattern)): + return (patterns,) + if isinstance(patterns, Set): + return tuple(sorted(patterns, key=_sort_pattern)) + return tuple(patterns) + + +@overload +def to_block_pattern(pattern: str, /) -> tuple[str, ...]: ... # NoQA: E704 +@overload +def to_block_pattern(pattern: re.Pattern[str], /) -> tuple[re.Pattern[str]]: ... # NoQA: E704 +@overload +def to_block_pattern(patterns: BlockPattern, /) -> tuple[LinePattern, ...]: ... # NoQA: E704 +def to_block_pattern(patterns: PatternLike | BlockPattern, /) -> tuple[LinePattern, ...]: # NoQA: E302 + r"""Get a read-only sequence for a s single block pattern. + + :param patterns: A string, :class:`~re.Pattern` or a sequence thereof. + :return: The line patterns of the block. + + When *expect* is a single string, it is split into lines to produce + the corresponding block pattern, e.g.:: + + to_block_pattern('line1\nline2') == ('line1', 'line2') + """ + # See `to_line_patterns` for the `if` statements evaluation order. + if isinstance(patterns, tuple): + return patterns + if isinstance(patterns, str): + return tuple(patterns.splitlines()) + if isinstance(patterns, re.Pattern): + return (patterns,) + return tuple(patterns) + + +@overload +def format_expression(fn: Callable[[str], str], x: str, /) -> str: ... # NoQA: E704 +@overload +def format_expression(fn: Callable[[str], str], x: re.Pattern[str], /) -> re.Pattern[str]: ... # NoQA: E704 +def format_expression(fn: Callable[[str], str], x: PatternLike, /) -> PatternLike: # NoQA: E302 + """Transform regular expressions, leaving compiled patterns untouched.""" + return fn(x) if isinstance(x, str) else x + + +def string_expression(line: str, /) -> str: + """A regular expression matching exactly *line*.""" + # use '\A' and '\Z' to match the beginning and end of the string + return rf'\A{re.escape(line)}\Z' + + +def translate( + patterns: Iterable[PatternLike], + *, + flavor: Flavor, + escape: Callable[[str], str] | None = string_expression, + regular_translate: Callable[[str], str] | None = None, + fnmatch_translate: Callable[[str], str] | None = fnmatch.translate, +) -> Iterable[PatternLike]: + r"""Translate regular expressions according to *flavor*. + + Non-compiled regular expressions are translated by the translation function + corresponding to the given *flavor* while compiled patterns are kept as is. + + :param patterns: An iterable of regular expressions to translate. + :param flavor: The translation flavor for non-compiled patterns. + :param escape: Translation function for :py3r:`literal` flavor. + :param regular_translate: Translation function for :py3r:`re` flavor. + :param fnmatch_translate: Translation function for :py3r:`fnmatch` flavor. + :return: An iterable of :class:`re`-style pattern-like objects. + """ + _check_flavor(flavor) + + if flavor == 'literal' and callable(translator := escape): + return (format_expression(translator, expr) for expr in patterns) + + if flavor == 're' and callable(translator := regular_translate): + return (format_expression(translator, expr) for expr in patterns) + + if flavor == 'fnmatch' and callable(translator := fnmatch_translate): + return (format_expression(translator, expr) for expr in patterns) + + return patterns + + +def compile( + patterns: Iterable[PatternLike], + *, + flavor: Flavor, + escape: Callable[[str], str] | None = string_expression, + regular_translate: Callable[[str], str] | None = None, + fnmatch_translate: Callable[[str], str] | None = fnmatch.translate, +) -> Patterns: + """Compile one or more patterns into :class:`~re.Pattern` objects. + + :param patterns: An iterable of patterns to translate and compile. + :param flavor: The translation flavor for non-compiled patterns. + :param escape: Translation function for :py3r:`literal` flavor. + :param regular_translate: Translation function for :py3r:`re` flavor. + :param fnmatch_translate: Translation function for :py3r:`fnmatch` flavor. + :return: A sequence of compiled regular expressions. + """ + patterns = translate( + patterns, + flavor=flavor, + escape=escape, + regular_translate=regular_translate, + fnmatch_translate=fnmatch_translate, + ) + + # mypy does not like map + re.compile() although it is correct + # + # xref: https://github.com/python/mypy/issues/11880 + return tuple(re.compile(pattern) for pattern in patterns) diff --git a/sphinx/testing/matcher/_util.py b/sphinx/testing/matcher/_util.py new file mode 100644 index 00000000000..5e39698c04e --- /dev/null +++ b/sphinx/testing/matcher/_util.py @@ -0,0 +1,229 @@ +"""Private utility functions for :mod:`sphinx.testing.matcher`. + +All objects provided by this module are considered an implementation detail. +""" + +from __future__ import annotations + +__all__ = () + +import itertools +import re +import textwrap +from collections import deque +from collections.abc import Callable, Sequence +from operator import itemgetter +from typing import TYPE_CHECKING, Union, overload + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Mapping + from typing import Any, TypeVar + + from typing_extensions import Never, TypeAlias + + from sphinx.testing.matcher.buffer import Region + + _T = TypeVar('_T') + +PatternLike: TypeAlias = Union[str, re.Pattern[str]] +"""A regular expression (compiled or not).""" +LinePattern: TypeAlias = Union[str, re.Pattern[str]] +"""A regular expression (compiled or not) for an entire line.""" +LinePredicate: TypeAlias = Callable[[str], object] +"""A predicate called on an entire line.""" +BlockPattern: TypeAlias = Sequence[LinePattern] +"""A sequence of regular expressions (compiled or not) for a block. + +For instance, ``['a', re.compile('b*')]`` matches blocks +with the line ``'a'`` followed by a line matching ``'b*'``. +""" + +Patterns: TypeAlias = tuple[re.Pattern[str], ...] +"""Sequence of compiled patterns to use.""" + + +def consume(iterator: Iterator[object], /, n: int | None = None) -> None: + """Advance the iterator *n*-steps ahead, or entirely if *n* is ``None``. + + Taken from `itertools recipes`__. + + __ https://docs.python.org/3/library/itertools.html#itertools-recipes + """ + # use the C API to efficiently consume iterators + if n is None: + deque(iterator, maxlen=0) + else: + next(itertools.islice(iterator, n, n), None) + + +def unique_justseen(iterable: Iterable[_T], /) -> Iterator[_T]: + """Yield elements in order, ignoring serial duplicates. + + Taken from `itertools recipes`__. + + __ https://docs.python.org/3/library/itertools.html#itertools-recipes + """ + return map(next, map(itemgetter(1), itertools.groupby(iterable))) + + +def unique_everseen(iterable: Iterable[_T], /) -> Iterator[_T]: + """Yield elements in order, ignoring duplicates. + + Taken from `itertools recipes`__. + + __ https://docs.python.org/3/library/itertools.html#itertools-recipes + """ + seen: set[_T] = set() + mark, pred = seen.add, seen.__contains__ + for element in itertools.filterfalse(pred, iterable): + mark(element) + yield element + + +def strict_windowed(iterable: Iterable[_T], n: int, /) -> Iterator[Sequence[_T]]: + """Return a sliding window of width *n* over the given iterable. + + When *n* is *0*, the iterator does not yield anything. + + Adapted from `itertools recipes`__ for the case *n = 0*. + + __ https://docs.python.org/3/library/itertools.html#itertools-recipes + """ + if n == 0: + return + + iterator = iter(iterable) + window = deque(itertools.islice(iterator, n), maxlen=n) + if len(window) == n: + yield window + for group in iterator: + window.append(group) + yield window + + +def plural_form(noun: str, n: int, /) -> str: + """Append ``'s'`` to *noun* if *n* is more than *1*.""" + return noun + 's' if n > 1 else noun + + +def omit_message(n: int, /) -> str: + """The message to indicate that *n* lines where omitted.""" + noun = plural_form('line', n) + return f'... (omitted {n} {noun}) ...' + + +def omit_line(n: int, /) -> list[str]: + """Wrap :func:`omit_message` in a list, if any. + + If no lines are omitted, this returns the empty list. This is typically + useful when used in combination to ``lines.extend(omit_line(n))``. + """ + return [omit_message(n)] if n else [] + + +def make_prefix(indent: int, /, *, highlight: bool = False) -> str: + """Create the prefix used for indentation or highlighting.""" + prefix = ' ' * indent + return f'>{prefix[1:]}' if highlight else prefix + + +@overload +def indent_source( # NoQA: E704 + text: str, /, *, sep: Never = ..., indent: int = ..., highlight: bool = ... +) -> str: ... +@overload # NoQA: E302 +def indent_source( # NoQA: E704 + lines: Iterable[str], /, *, sep: str = ..., indent: int = ..., highlight: bool = ... +) -> str: ... +def indent_source( # NoQA: E302 + src: Iterable[str], /, *, sep: str = '\n', indent: int = 4, highlight: bool = False +) -> str: + """Indent a string or an iterable of lines, returning a single string. + + :param indent: The number of indentation spaces. + :param highlight: Indicate whether the prefix is a highlighter. + :return: An indented line, possibly highlighted. + """ + if isinstance(src, str): + prefix = make_prefix(indent, highlight=highlight) + return textwrap.indent(src, prefix) + return sep.join(indent_lines(src, indent=indent, highlight=highlight)) + + +def indent_lines( + lines: Iterable[str], /, *, indent: int = 4, highlight: bool = False +) -> list[str]: + """Return a list of lines prefixed by an indentation string. + + :param lines: The lines to indent. + :param indent: The number of indentation spaces. + :param highlight: Indicate whether the prefix is a highlighter. + :return: A list of lines, possibly highlighted. + """ + prefix = make_prefix(indent, highlight=highlight) + return [prefix + line for line in lines] + + +def get_context_lines( + source: Sequence[str], region: Region[Any], /, context: int, *, indent: int = 4 +) -> list[str]: + """Get some context lines around *block* and highlight the *region*. + + :param source: The source containing the *block*. + :param region: A region to highlight (a line or a block). + :param context: The number of lines to display around the block. + :param indent: The number of indentation spaces. + :return: A list of formatted lines. + """ + assert region <= source, 'the region must be contained in the source' + + logs: list[str] = [] + writelines = logs.extend + has_context = int(context > 0) + before, after = region.context(context, limit := len(source)) + writelines(omit_line(has_context * before.start)) + writelines(indent_lines(source[before], indent=indent, highlight=False)) + # use region.span to ensure that single lines are wrapped in lists + writelines(indent_lines(source[region.span], indent=indent, highlight=True)) + writelines(indent_lines(source[after], indent=indent, highlight=False)) + writelines(omit_line(has_context * (limit - after.stop))) + + return logs + + +def _highlight( + source: Iterable[str], sections: Mapping[int, int], *, prefix: str, highlight_prefix: str +) -> Iterator[str]: + iterator = enumerate(source) + for index, line in iterator: + if count := sections.get(index, None): + yield highlight_prefix + line # the first line of the block + # yield the remaining lines of the block + tail = map(itemgetter(1), itertools.islice(iterator, count - 1)) + yield from map(highlight_prefix.__add__, tail) + else: + yield prefix + line + + +def highlight( + source: Iterable[str], + sections: Mapping[int, int] | None = None, + /, + *, + indent: int = 4, + keepends: bool = False, +) -> str: + """Highlight one or more blocks in *source*. + + :param source: The source to format. + :param sections: The blocks to highlight given as their offset and size. + :param indent: The number of indentation spaces. + :param keepends: Indicate whether the *source* contains line breaks or not. + :return: An indented text. + """ + sep = '' if keepends else '\n' + if sections: + tab, accent = make_prefix(indent), make_prefix(indent, highlight=True) + lines = _highlight(source, sections, prefix=tab, highlight_prefix=accent) + return sep.join(lines) + return indent_source(source, sep=sep, indent=indent, highlight=False) diff --git a/sphinx/testing/matcher/buffer.py b/sphinx/testing/matcher/buffer.py new file mode 100644 index 00000000000..9e15b2892c0 --- /dev/null +++ b/sphinx/testing/matcher/buffer.py @@ -0,0 +1,605 @@ +"""Interface for comparing strings or list of strings.""" + +from __future__ import annotations + +__all__ = ('Region', 'Line', 'Block') + +import abc +import contextlib +import itertools +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Generic, TypeVar, overload + +from sphinx.testing.matcher._util import consume as _consume + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from typing import Any, Union + + from typing_extensions import Self, TypeAlias + + from sphinx.testing.matcher._util import LinePattern, LinePredicate, PatternLike + + SubStringLike: TypeAlias = PatternLike + """A line's substring or a compiled substring pattern.""" + + BlockLineLike: TypeAlias = Union[object, LinePattern, LinePredicate] + """A block's line, a compiled pattern or a predicate.""" + +# We would like to have a covariant buffer type but Python does not +# support higher-kinded type, so we can only use an invariant type. +T = TypeVar('T', bound=Sequence[str]) + + +class Region(Generic[T], Sequence[str], abc.ABC): + """A string or a sequence of strings implementing rich comparison. + + Given an implicit *source* as a list of strings, a :class:`Region` is + of that of that implicit *source* starting at some :attr:`offset`. + """ + + # add __weakref__ to allow the object being weak-referencable + __slots__ = ('__buffer', '__offset', '__weakref__') + + def __init__(self, buffer: T, /, offset: int = 0, *, _check: bool = True) -> None: + """Construct a :class:`Region` object. + + :param buffer: The region's content (a string or a list of strings). + :param offset: The region's offset with respect to the original source. + :param _check: An internal parameter used for validating inputs. + + The *_check* parameter is only meant for internal usage and strives + to speed-up the construction of :class:`Region` objects for which + their constructor arguments are known to be valid at call time. + """ + if _check: + if not isinstance(offset, int): + msg = f'offset must be an integer, got: {offset!r}' + raise TypeError(msg) + + if offset < 0: + msg = f'offset must be >= 0, got: {offset!r}' + raise ValueError(msg) + + self.__buffer = buffer + self.__offset = offset + + @property + def buffer(self) -> T: + """The internal (immutable) buffer.""" + return self.__buffer + + @property + def offset(self) -> int: + """The index of this region in the original source.""" + return self.__offset + + @property + def length(self) -> int: + """The number of "atomic" items in this region.""" + return len(self) + + @property + @abc.abstractmethod + def span(self) -> slice: + """A slice representing this region in its source.""" + + def context(self, delta: int, limit: int) -> tuple[slice, slice]: + """A slice object indicating a context around this region. + + :param delta: The number of context lines to show. + :param limit: The number of lines in the source the region belongs to. + :return: The slices for the 'before' and 'after' lines. + + Example:: + + source = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] + block = Block(['4', '5', '6'], 3) + before, after = block.context(2, 10) + assert source[before] == ['2', '3'] + assert source[after] == ['7', '8'] + """ + assert delta >= 0, 'context size must be >= 0' + assert limit >= 0, 'source length must be >= 0' + + span = self.span + before_start, before_stop = max(0, span.start - delta), min(span.start, limit) + before_slice = slice(before_start, before_stop) + + after_start, after_stop = min(span.stop, limit), min(span.stop + delta, limit) + after_slice = slice(after_start, after_stop) + + return before_slice, after_slice + + @abc.abstractmethod + # The 'value' is 'Any' so that subclasses do not violate Liskov's substitution principle + def count(self, value: Any, /) -> int: + """Count the number of occurences of matching item.""" + + # The 'value' is 'Any' so that subclasses do not violate Liskov's substitution principle + def index(self, value: Any, start: int = 0, stop: int | None = None, /) -> int: + """Return the lowest index of a matching item. + + :raise ValueError: The value does not exist. + """ + index = self.find(value, start, stop) + if index == -1: + raise ValueError(value) + return index + + @abc.abstractmethod + # The 'value' is 'Any' so that subclasses do not violate Liskov's substitution principle. + def find(self, value: Any, start: int = 0, stop: int | None = None, /) -> int: + """Return the lowest index of a matching item or *-1* on failure.""" + + def pformat(self) -> str: + """A nice representation of this region.""" + return f'{self.__class__.__name__}({self!r}, @={self.offset}, #={self.length})' + + def __repr__(self) -> str: + return repr(self.buffer) + + def __copy__(self) -> Self: + return self.__class__(self.buffer, self.offset, _check=False) + + def __bool__(self) -> bool: + """Indicate whether this region is empty or not.""" + return bool(self.buffer) + + def __iter__(self) -> Iterator[str]: + """An iterator over the string items.""" + return iter(self.buffer) + + def __len__(self) -> int: + """The number of "atomic" items in this region.""" + return len(self.buffer) + + def __contains__(self, value: object, /) -> bool: + """Check that an "atomic" value is represented by this region.""" + return value in self.buffer or self.find(value) != -1 + + @abc.abstractmethod + def __lt__(self, other: object, /) -> bool: + """Check that this region is strictly contained in *other*.""" + + def __le__(self, other: object, /) -> bool: + """Check that this region is contained in *other*. + + By default, ``self == other`` is called before ``self < other``, but + subclasses should override this method for an efficient alternative. + """ + return self == other or self < other + + def __ge__(self, other: object, /) -> bool: + """Check that *other* is contained by this region. + + By default, ``self == other`` is called before ``self > other``, but + subclasses should override this method for an efficient alternative. + """ + return self == other or self > other + + @abc.abstractmethod + def __gt__(self, other: object, /) -> bool: + """Check that this region strictly contains *other*.""" + + +class Line(Region[str]): + """A line found by :meth:`.LineMatcher.find`. + + A :class:`Line` can be compared to: + + - a :class:`str`, in which case the :attr:`text <.buffer>` is compared, + - a pair ``(line_content, line_offset)`` where *line_content* is a string + and *line_offset* is an nonnegative integer, or another :class:`Line`, + in which case both the offset and the content must match. + """ + + # NOTE(picnixz): this class could be extended to support arbitrary + # character's types, but it would not be possible to use the C API + # implementing the :class:`str` interface anymore. + + def __init__(self, line: str = '', /, offset: int = 0, *, _check: bool = True) -> None: + """Construct a :class:`Line` object.""" + super().__init__(line, offset, _check=_check) + + @property + def span(self) -> slice: + """A slice representing this line in its source. + + Example:: + + source = ['L1', 'L2', 'L3'] + line = Line('L2', 1) + assert source[line.span] == ['L2'] + """ + return slice(self.offset, self.offset + 1) + + def count(self, sub: SubStringLike, /) -> int: + """Count the number of occurrences of a substring or pattern. + + :raise TypeError: *sub* is not a string or a compiled pattern. + """ + if isinstance(sub, re.Pattern): + # avoid using sub.findall() since we only want the length + # of the corresponding iterator (the following lines are + # more efficient from a memory perspective) + counter = itertools.count() + _consume(zip(sub.finditer(self.buffer), counter)) + return next(counter) + + # buffer.count() raises a TypeError if *sub* is not a string + return self.buffer.count(sub) + + # explicitly add the method since its signature differs from :meth:`Region.index` + def index(self, sub: SubStringLike, start: int = 0, stop: int | None = None, /) -> int: + """Find the lowest index of a substring. + + :raise TypeError: *sub* is not a string or a compiled pattern. + """ + return super().index(sub, start, stop) + + def find(self, sub: SubStringLike, start: int = 0, stop: int | None = None, /) -> int: + """Find the lowest index of a substring or *-1* on failure. + + :raise TypeError: *sub* is not a string or a compiled pattern. + """ + if isinstance(sub, re.Pattern): + # Do not use sub.search(buffer, start, end) since the '^' pattern + # character matches at the *real* beginning of *buffer* but *not* + # necessarily at the index where the search is to start. + # + # Ref: https://docs.python.org/3/library/re.html#re.Pattern.search + if match := sub.search(self.buffer[start:stop]): + # normalize the start position + start_index, _, _ = slice(start, stop).indices(self.length) + return match.start() + start_index + return -1 + + # buffer.find() raises a TypeError if *sub* is not a string + return self.buffer.find(sub, start, stop) + + def startswith(self, prefix: str, start: int = 0, end: int | None = None, /) -> bool: + """Test whether the line starts with the given *prefix*. + + :param prefix: A line prefix to test. + :param start: The test start position. + :param end: The test stop position. + """ + return self.buffer.startswith(prefix, start, end) + + def endswith(self, suffix: str, start: int = 0, end: int | None = None, /) -> bool: + """Test whether the line ends with the given *suffix*. + + :param suffix: A line suffix to test. + :param start: The test start position. + :param end: The test stop position. + """ + return self.buffer.endswith(suffix, start, end) + + def __str__(self) -> str: + """The line as a string.""" + return self.buffer + + def __getitem__(self, index: int | slice, /) -> str: + return self.buffer[index] + + def __eq__(self, other: object, /) -> bool: + if isinstance(other, str): + return self.buffer == other + + other = _parse_non_string(other) + if other is None: + return NotImplemented + + # separately check offsets before the buffers for efficiency + return self.offset == other[1] and self.buffer == other[0] + + def __lt__(self, other: object, /) -> bool: + if isinstance(other, str): + return self.buffer < other + + other = _parse_non_string(other) + if other is None: + return NotImplemented + + # separately check offsets before the buffers for efficiency + return self.offset == other[1] and self.buffer < other[0] + + def __gt__(self, other: object, /) -> bool: + if isinstance(other, str): + return self.buffer > other + + other = _parse_non_string(other) + if other is None: + return NotImplemented + + # separately check offsets before the buffers for efficiency + return self.offset == other[1] and self.buffer > other[0] + + +class Block(Region[tuple[str, ...]]): + """Block found by :meth:`.LineMatcher.find_blocks`. + + A block is a *sequence* of lines comparable to :class:`Line` objects, + usually given as :class:`str` objects or ``(line, line_offset)`` pairs. + + A block can be compared to pairs ``(block_lines, block_offset)`` where + + - *block_lines* is a sequence of line-like objects, and + - *block_offset* is an integer (matched against :attr:`.offset`). + + Pairs ``(line, line_offset)`` or ``(block_lines, block_offset)`` are to + be given as any two-elements sequences (tuple, list, deque, ...), e.g.:: + + assert Block(['a', 'b', 'c', 'd'], 2) == [ + 'a', + ('b', 3), + ['c', 4], + Line('d', 5), + ] + + By convention, ``block[i]`` and ``block[i:j]`` return :class:`str` + and tuples of :class:`str` respectively. Consider using :meth:`at` + to convert the output to :class:`Line` or :class:`Block` objects. + + Similarly, ``iter(block)`` returns an iterator on strings. Consider + using :meth:`lines_iterator` to iterate over :class:`Line` objects. + """ + + __slots__ = ('__cached_lines',) + + def __init__( + self, buffer: Iterable[str] = (), /, offset: int = 0, *, _check: bool = True + ) -> None: + # It is more efficient to first consume everything and then + # iterate over the values for checks rather than to add the + # validated values one by one. + buffer = tuple(buffer) + if _check: + for line in buffer: + if not isinstance(line, str): + err = f'expecting a native string, got: {line!r}' + raise TypeError(err) + + super().__init__(buffer, offset, _check=_check) + self.__cached_lines: tuple[Line, ...] | None = None + """This block as a tuple of :class:`Line` objects. + + The rationale behind duplicating the buffer's data is to ease + comparison by relying on the C API for comparing tuples which + dispatches to the :class:`Line` comparison operators. + """ + + @property + def span(self) -> slice: + """A slice representing this block in its source. + + Example:: + + source = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] + block = Block(['4', '5', '6'], 3) + assert source[block.span] == ['4', '5', '6'] + """ + return slice(self.offset, self.offset + self.length) + + def count(self, target: BlockLineLike, /) -> int: + """Count the number of occurrences of matching lines. + + For :class:`~re.Pattern` inputs, the following are equivalent:: + + block.count(target) + block.count(target.match) + """ + if isinstance(target, re.Pattern): + # Apply the pattern to the entire line unlike :class:`Line` + # objects that detect non-overlapping matching substrings. + return self.count(target.match) + + if callable(target): + counter = itertools.count() + _consume(zip(filter(target, self.buffer), counter)) + return next(counter) + + return self.buffer.count(target) + + # explicitly add the method since its signature differs from :meth:`Region.index` + def index(self, target: BlockLineLike, start: int = 0, stop: int | None = None, /) -> int: + """Find the lowest index of a matching line. + + For :class:`~re.Pattern` inputs, the following are equivalent:: + + block.index(target, ...) + block.index(target.match, ...) + """ + return super().index(target, start, stop) + + def find(self, target: BlockLineLike, start: int = 0, stop: int | None = None, /) -> int: + """Find the lowest index of a matching line or *-1* on failure. + + For :class:`~re.Pattern` inputs, the following are equivalent:: + + block.find(target, ...) + block.find(target.match, ...) + """ + if isinstance(target, re.Pattern): + return self.find(target.match, start, stop) + + if callable(target): + start, stop, _ = slice(start, stop).indices(self.length) + sliced = itertools.islice(self.buffer, start, stop) + return next(itertools.compress(itertools.count(start), map(target, sliced)), -1) + + with contextlib.suppress(ValueError): + if stop is None: + return self.buffer.index(target, start) + return self.buffer.index(target, start, stop) + return -1 + + def lines(self) -> tuple[Line, ...]: + """This region as a tuple of :class:`Line` objects.""" + if self.__cached_lines is None: + self.__cached_lines = tuple(self.lines_iterator()) + return self.__cached_lines + + def lines_iterator(self) -> Iterator[Line]: + """This region as an iterator of :class:`Line` objects.""" + for index, line in enumerate(self, self.offset): + yield Line(line, index, _check=False) + + @overload + def at(self, index: int, /) -> Line: ... # NoQA: E704 + @overload + def at(self, index: slice, /) -> Self: ... # NoQA: E704 + def at(self, index: int | slice, /) -> Line | Block: # NoQA: E301 + """Get a :class:`Line` or a contiguous region as a :class:`Block`.""" + if isinstance(index, slice): + # exception for invalid step is handled by __getitem__ + buffer = self[index] + offset = self.offset + index.indices(self.length)[0] + return self.__class__(buffer, offset, _check=False) + + # normalize negative index + start, _, _ = slice(index, -1).indices(self.length) + return Line(self.buffer[index], self.offset + start, _check=False) + + @overload + def __getitem__(self, index: int, /) -> str: ... # NoQA: E704 + @overload + def __getitem__(self, index: slice, /) -> tuple[str, ...]: ... # NoQA: E704 + def __getitem__(self, index: int | slice, /) -> str | tuple[str, ...]: # NoQA: E301 + """Get a line or a contiguous sub-block.""" + if isinstance(index, slice): + # normalize negative and None slice fields + _, _, step = index.indices(self.length) + if step != 1: + msg = 'only contiguous regions can be extracted' + raise ValueError(msg) + return self.buffer[index] + + def __eq__(self, other: object, /) -> bool: + if isinstance(other, self.__class__): + # more efficient to first check the offsets + return (self.offset, self.buffer) == (other.offset, other.buffer) + + other = _parse_non_block(other) + if other is None: + return NotImplemented + + lines, offset = other + # check offsets before computing len(lines) + if offset != -1 and offset != self.offset: + return False + + # check the lengths before computing the cached lines if possible + return self.length == len(lines) and self.lines() == lines + + def __lt__(self, other: object, /) -> bool: + if isinstance(other, self.__class__): + # More efficient to first check if the indices are valid before + # checking the lines using tuple comparisons (both objects have + # compatible types at runtime). + aligned = _can_be_strict_in(self.offset, self.length, other.offset, other.length) + return aligned and self.buffer < other.buffer + + other = _parse_non_block(other) + if other is None: + return NotImplemented + + lines, other_offset = other + if other_offset != -1: + aligned = _can_be_strict_in(self.offset, self.length, other_offset, len(lines)) + return aligned and self.lines() < lines + # we want to find this block in the *other* block (at any place) + return self.lines() < lines + + def __gt__(self, other: object, /) -> bool: + if isinstance(other, self.__class__): + return other < self + + other = _parse_non_block(other) + if other is None: + return NotImplemented + + lines, other_offset = other + if other_offset != -1: + aligned = _can_be_strict_in(other_offset, len(lines), self.offset, self.length) + return aligned and self.lines() > lines + return self.lines() > lines + + +# Those functions are private and are not included in :class:`Line` +# or :class:`Block` to minimize the size of the class dictionary. + + +def _parse_non_string(other: object, /) -> tuple[str, int] | None: + """Try to parse *other* as a ``(line_content, line_offset)`` pair. + + Do **NOT** call this method on :class:`str` instances since they are + handled separately and more efficiently by :class:`Line`'s operators. + """ + assert not isinstance(other, str) + if isinstance(other, Line): + return other.buffer, other.offset + if isinstance(other, Sequence) and len(other) == 2: + buffer, offset = other + if isinstance(buffer, str) and isinstance(offset, int): + return buffer, offset + return None + + +def _is_block_line_like(other: object, /) -> bool: + if isinstance(other, (str, Line)): + return True + + if isinstance(other, Sequence) and len(other) == 2: + buffer, offset = other + if isinstance(buffer, str) and isinstance(offset, int): + return True + + return False + + +def _parse_non_block(other: object, /) -> tuple[tuple[object, ...], int] | None: + """Try to parse *other* as a pair ``(block lines, block offset)``. + + Do **NOT** call this method on :class:`Block` instances since they are + handled separately and more efficiently by :class:`Block`'s operators. + """ + assert not isinstance(other, Block) + if not isinstance(other, Sequence): + return None + + if all(map(_is_block_line_like, other)): + # offset will never be given in this scenario + return tuple(other), -1 + + if len(other) == 2: + lines, offset = other + if not isinstance(lines, Sequence) or not isinstance(offset, int): + return None + + if isinstance(lines, str): + # do not allow [line, offset] with single string 'line' + return None + + if not all(map(_is_block_line_like, lines)): + return None + + return tuple(lines), offset + + return None + + +def _can_be_strict_in(i1: int, l1: int, i2: int, l2: int) -> bool: + """Check that a block can be strictly contained in another block. + + :param i1: The address (index) of the first block. + :param l1: The length of the first block. + :param i2: The address (index) of the second block. + :param l2: The length of the second block. + """ + j1, j2 = i1 + l1, i2 + l2 + # Case 1: i1 == i2 and j1 < j2 (block1 is at most block2[:-1]) + # Case 2: i1 > i2 and j1 <= j2 (block1 is at most block2[1:]) + return l1 < l2 and ((i1 >= i2) and (j1 < j2) or (i1 > i2) and (j1 <= j2)) diff --git a/sphinx/testing/matcher/cleaner.py b/sphinx/testing/matcher/cleaner.py new file mode 100644 index 00000000000..f15b0b4bc01 --- /dev/null +++ b/sphinx/testing/matcher/cleaner.py @@ -0,0 +1,145 @@ +"""Public cleaning functions for :mod:`sphinx.testing.matcher`.""" + +from __future__ import annotations + +__all__ = () + +from functools import reduce +from itertools import accumulate, islice +from typing import TYPE_CHECKING + +from sphinx.testing.matcher import _cleaner, _engine +from sphinx.testing.matcher.options import OptionsHolder +from sphinx.util.console import strip_escape_sequences + +if TYPE_CHECKING: + from collections.abc import Iterable + from re import Pattern + + from typing_extensions import TypeAlias, Unpack + + from sphinx.testing.matcher._util import Patterns + from sphinx.testing.matcher.options import Options, PrunePattern, StripChars + + TraceInfo: TypeAlias = list[list[tuple[str, list[str]]]] + + +def clean(text: str, /, **options: Unpack[Options]) -> Iterable[str]: + """Clean a text, returning an iterable of lines. + + :param text: The text to clean. + :return: An iterable of cleaned lines. + + See :class:`~.options.Options` for the meaning of each supported option. + """ + args = OptionsHolder(**options) + + # clean the text as a string + if not args.keep_ansi: + text = strip_escape_sequences(text) + text = strip_chars(text, args.strip) + # obtain the lines + lines: Iterable[str] = text.splitlines(args.keep_break) + # process the lines according to the operation codes sequence$ + handlers = _cleaner.make_handlers(args) + for opcode in _cleaner.get_active_opcodes(args): + if (handler := handlers.get(opcode)) is None: + raise ValueError('unknown operation code: %r' % opcode) + lines = handler(lines) + return lines + + +def strip_chars(text: str, chars: StripChars = True, /) -> str: + """Return a copy of *text* with leading and trailing characters removed. + + See :attr:`~.options.Options.strip` for the meaning of *chars*. + """ + if isinstance(chars, bool): + return text.strip() if chars else text + return text.strip(chars) + + +def strip_lines(lines: Iterable[str], chars: StripChars = True, /) -> Iterable[str]: + """Same as :func:`strip_chars` but applied to each line in *lines*. + + See :attr:`~.options.Options.strip_line` for the meaning of *chars*. + """ + if isinstance(chars, bool): + return map(str.strip, lines) if chars else lines + return (line.strip(chars) for line in lines) + + +def prune_lines( + lines: Iterable[str], patterns: PrunePattern, /, *, trace: TraceInfo | None = None +) -> Iterable[str]: + r"""Eliminate substrings in each line. + + :param lines: The source to transform. + :param patterns: One or more substring patterns to delete. + :param trace: A buffer where intermediate results are stored. + :return: An iterable of transformed lines. + + Example:: + + >>> lines = prune_lines(['1111a', 'b1'], r'^\d+') + >>> list(lines) + ['a', 'b1'] + + When specified, the *trace* contains the line's reduction chains, e.g.:: + + >>> trace = [] + >>> list(prune_lines(['ABC#123'], [r'^[A-Z]', r'\d$'], trace=trace)) + ['#'] + >>> trace # doctest: +NORMALIZE_WHITESPACE + [[('ABC#123', ['BC#123', 'BC#12']), + ('BC#12', ['C#12', 'C#1']), + ('C#1', ['#1', '#'])]] + """ + patterns = _engine.to_line_patterns(patterns) + compiled = _engine.compile(patterns, flavor='re') + if trace is None: + return _prune(lines, compiled) + return _prune_debug(lines, compiled, trace) + + +def _prune_pattern(line: str, pattern: Pattern[str]) -> str: + return pattern.sub('', line) + + +def _prune(lines: Iterable[str], compiled: Patterns) -> Iterable[str]: + def apply(line: str) -> str: + return reduce(_prune_pattern, compiled, line) + + def prune(line: str) -> str: + text = apply(line) + while text != line: + line, text = text, apply(text) + return text + + return map(prune, lines) + + +def _prune_debug(lines: Iterable[str], compiled: Patterns, trace: TraceInfo) -> Iterable[str]: + def apply(line: str) -> tuple[str, list[str]]: + values = accumulate(compiled, _prune_pattern, initial=line) + states = list(islice(values, 1, None)) # skip initial value + return states[-1], states + + def prune(line: str) -> str: + text, states = apply(line) + # first reduction is always logged + trace_item: list[tuple[str, list[str]]] = [(line, states)] + + while text != line: + line, (text, states) = text, apply(text) + trace_item.append((line, states)) + + if len(trace_item) >= 2: + # the while-loop was executed at least once and + # the last appended item represents the identity + trace_item.pop() + + trace.append(trace_item) + return text + + return map(prune, lines) diff --git a/sphinx/testing/matcher/options.py b/sphinx/testing/matcher/options.py new file mode 100644 index 00000000000..ee666d1194a --- /dev/null +++ b/sphinx/testing/matcher/options.py @@ -0,0 +1,466 @@ +"""Module for the :class:`~sphinx.testing.matcher.LineMatcher` options.""" + +from __future__ import annotations + +__all__ = ('Options', 'CompleteOptions', 'OptionsHolder') + +import contextlib +from collections.abc import Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Literal, TypedDict, Union, final, overload + +from sphinx.testing.matcher._util import LinePredicate, PatternLike + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping + from typing import Any, ClassVar, TypeVar + + from typing_extensions import TypeAlias, Unpack + + DT = TypeVar('DT') + +_FLAG: TypeAlias = Literal['keep_ansi', 'keep_break', 'keep_empty', 'compress', 'unique'] + +_STRIP: TypeAlias = Literal['strip', 'strip_line'] +StripChars: TypeAlias = Union[bool, str, None] +"""Allowed values for :attr:`Options.strip` and :attr:`Options.strip_line`.""" + +_PRUNE: TypeAlias = Literal['prune'] +PrunePattern: TypeAlias = Union[PatternLike, Sequence[PatternLike]] +"""One or more (non-empty) patterns to prune.""" + +_IGNORE: TypeAlias = Literal['ignore'] +IgnorePredicate: TypeAlias = Union[LinePredicate, None] + +_OPCODES = Literal['ops'] +# must be kept in sync with :mod:`sphinx.testing.matcher._codes` +# and must be present at runtime for testing the synchronization +OpCode: TypeAlias = Literal['strip', 'check', 'compress', 'unique', 'prune', 'filter'] +"""Known operation codes (see :attr:`Options.ops`).""" +OpCodes: TypeAlias = Sequence[OpCode] + +_FLAVOR: TypeAlias = Literal['flavor'] +# When Python 3.11 becomes the minimal version, change this for a string enumeration. +Flavor: TypeAlias = Literal['literal', 'fnmatch', 're'] +"""Allowed values for :attr:`Options.flavor`.""" + +# For some reason, mypy does not like Union of Literal when used as keys +# of a TypedDict (see: https://github.com/python/mypy/issues/16818), so +# we instead use a Literal of those (which is equivalent). +_OPTION: TypeAlias = Literal[_FLAG, _STRIP, _PRUNE, _IGNORE, _OPCODES, _FLAVOR] + + +@final +class Options(TypedDict, total=False): + """Options for a :class:`~sphinx.testing.matcher.LineMatcher` object. + + Some options directly act on the original string (e.g., :attr:`strip`), + while others (e.g., :attr:`strip_line`) act on the lines obtained after + splitting the (transformed) original string. + + .. seealso:: :mod:`sphinx.testing.matcher.cleaner` + """ + + # only immutable fields should be used as options, otherwise undesired + # side-effects might occur when using a default option mutable value + + keep_ansi: bool + """Indicate whether to keep the ANSI escape sequences. + + The default value is :py3:`True`. + """ + + strip: StripChars + """Describe the characters to strip from the source. + + The allowed values for :attr:`strip` are: + + * :py3:`False` -- keep leading and trailing whitespaces (the default). + * :py3:`True` or :py3:`None` -- remove leading and trailing whitespaces. + * a string (*chars*) -- remove leading and trailing characters in *chars*. + """ + + keep_break: bool + """Indicate whether to keep line breaks at the end of each line. + + The default value is :py3:`False` (to mirror :meth:`str.splitlines`). + """ + + strip_line: StripChars + """Describe the characters to strip from each source's line. + + The allowed values for :attr:`strip_line` are: + + * :py3:`False` -- keep leading and trailing whitespaces (the default). + * :py3:`True` or :py3:`None` -- remove leading and trailing whitespaces. + * a string (*chars*) -- remove leading and trailing characters in *chars*. + """ + + keep_empty: bool + """Indicate whether to keep empty lines in the output. + + The default value is :py3:`True`. + """ + + compress: bool + """Eliminate duplicated consecutive lines in the output. + + The default value is :py3:`False`. + """ + + unique: bool + """Eliminate multiple occurrences of lines in the output. + + The default value is :py3:`False`. + """ + + prune: PrunePattern + r"""Regular expressions for substrings to prune from the output lines. + + The output lines are pruned from their matching substrings (checked + using :func:`re.match`) until the output lines are stabilized. + + See :func:`sphinx.testing.matcher.cleaner.prune_lines` for an example. + """ + + ignore: IgnorePredicate + """A predicate for filtering the output lines. + + Lines that satisfy this predicate are not included in the output. + + The default is :py3:`None`, meaning that all lines are included. + """ + + ops: OpCodes + """A sequence of *opcode* representing the line operations. + + The following table describes the allowed *opcode*. + + .. default-role:: py3r + + +------------+--------------------+---------------------------------------+ + | Op. Code | Option | Description | + +============+====================+=======================================+ + | `strip` | :attr:`strip_line` | Strip leading and trailing characters | + +------------+--------------------+---------------------------------------+ + | `check` | :attr:`keep_empty` | Remove empty lines | + +------------+--------------------+---------------------------------------+ + | `compress` | :attr:`compress` | Remove consecutive duplicated lines | + +------------+--------------------+---------------------------------------+ + | `unique` | :attr:`unique` | Remove duplicated lines | + +------------+--------------------+---------------------------------------+ + | `prune` | :attr:`prune` | Remove matching substrings | + +------------+--------------------+---------------------------------------+ + | `filter` | :attr:`ignore` | Ignore matching lines | + +------------+--------------------+---------------------------------------+ + + .. default-role:: + + The default value:: + + ('strip', 'check', 'compress', 'unique', 'prune', 'filter') + + .. rubric:: Example + + Consider the following setup:: + + lines = ['a', '', 'a', '', 'a'] + options = Options(strip_line=True, keep_empty=False, compress=True) + + By default, the lines are transformed into :py3:`['a']` since empty lines + are removed before serial duplicates. On the other hand, assume that:: + + options = Options(strip_line=True, keep_empty=False, compress=True, + ops=('strip', 'compress', 'check')) + + Here, the empty lines will be removed *after* the serial duplicates, + and therefore the lines are trasnformed into :py3:`['a', 'a', 'a']`. + """ + + flavor: Flavor + """Indicate how strings are matched against non-compiled patterns. + + The allowed values for :attr:`flavor` are: + + * :py3r:`literal` -- match lines using string equality (the default). + * :py3r:`fnmatch` -- match lines using :mod:`fnmatch`-style patterns. + * :py3r:`re` -- match lines using :mod:`re`-style patterns. + + This option only affects non-compiled patterns. Unless stated otherwise, + matching is performed on compiled patterns by :meth:`re.Pattern.match`. + """ + + +@final +class CompleteOptions(TypedDict): + """Same as :class:`Options` but as a total dictionary.""" + + keep_ansi: bool + strip: StripChars + strip_line: StripChars + + keep_break: bool + keep_empty: bool + compress: bool + unique: bool + + prune: PrunePattern + ignore: IgnorePredicate + + ops: OpCodes + flavor: Flavor + + +class OptionsHolder: + """Mixin supporting a known set of options. + + An :class:`OptionsHolder` object stores a set of partial options, + overriding the default values specified by :attr:`default_options`. + + At runtime, only the options given at construction time, explicitly + set via :meth:`set_option` or the corresponding property are stored + by this object. + + As such, :attr:`options` and :attr:`complete_options` return a proxy + on :class:`Options` and :class:`CompleteOptions` respectively, e.g.:: + + obj = OptionsHolder(strip=True) + assert obj.options == {'strip': True} + assert obj.complete_options == dict(obj.default_options, strip=True) + """ + + __slots__ = ('__options',) + + default_options: ClassVar[CompleteOptions] = CompleteOptions( + keep_ansi=True, + strip=False, + strip_line=False, + keep_break=False, + keep_empty=True, + compress=False, + unique=False, + prune=(), + ignore=None, + ops=('strip', 'check', 'compress', 'unique', 'prune', 'filter'), + flavor='literal', + ) + """The supported options specifications and their default values. + + Subclasses should override this field for different default options. + """ + + def __init__(self, /, **options: Unpack[Options]) -> None: + """Construct an :class:`OptionsHolder` object.""" + self.__options = options + + @property + def options(self) -> Mapping[str, object]: + """A read-only view of the *current* mapping of options. + + It can be regarded as a proxy on an :class:`Options` dictionary. + """ + return MappingProxyType(self.__options) + + @property + def complete_options(self) -> Mapping[str, object]: + """A read-only view of the *complete* mapping of options. + + It can be regarded as a proxy on a :class:`CompleteOptions` dictionary. + """ + return MappingProxyType(self.default_options | self.__options) + + @contextlib.contextmanager + def set_options(self, /, **options: Unpack[Options]) -> Iterator[None]: + """Temporarily replace the set of options with *options*.""" + return self.__set_options(options) + + @contextlib.contextmanager + def override(self, /, **options: Unpack[Options]) -> Iterator[None]: + """Temporarily extend the set of options with *options*.""" + return self.__set_options(self.__options | options) + + def __set_options(self, options: Options) -> Iterator[None]: + saved_options = self.__options.copy() + self.__options = options + try: + yield + finally: + self.__options = saved_options + + # When an option is added, add an overloaded definition + # so that mypy can correctly deduce the option's type. + # + # boolean-like options + @overload + def get_option(self, name: _FLAG, /) -> bool: ... # NoQA: E704 + @overload + def get_option(self, name: _FLAG, default: bool, /) -> bool: ... # NoQA: E704 + @overload + def get_option(self, name: _FLAG, default: DT, /) -> bool | DT: ... # NoQA: E704 + # strip-like options + @overload + def get_option(self, name: _STRIP, /) -> StripChars: ... # NoQA: E704 + @overload + def get_option(self, name: _STRIP, default: StripChars, /) -> StripChars: ... # NoQA: E704 + @overload + def get_option(self, name: _STRIP, default: DT, /) -> StripChars | DT: ... # NoQA: E704 + # pruning option + @overload + def get_option(self, name: _PRUNE, /) -> PrunePattern: ... # NoQA: E704 + @overload + def get_option(self, name: _PRUNE, default: PrunePattern, /) -> PrunePattern: ... # NoQA: E704 + @overload + def get_option(self, name: _PRUNE, default: DT, /) -> PrunePattern | DT: ... # NoQA: E704 + # filtering options + @overload + def get_option(self, name: _IGNORE, /) -> IgnorePredicate: ... # NoQA: E704 + @overload + def get_option(self, name: _IGNORE, default: IgnorePredicate, /) -> IgnorePredicate: ... # NoQA: E704 + @overload + def get_option(self, name: _IGNORE, default: DT, /) -> IgnorePredicate | DT: ... # NoQA: E704 + # miscellaneous options + @overload + def get_option(self, name: _OPCODES, /) -> OpCodes: ... # NoQA: E704 + @overload + def get_option(self, name: _OPCODES, default: OpCodes, /) -> OpCodes: ... # NoQA: E704 + @overload + def get_option(self, name: _OPCODES, default: DT, /) -> OpCodes | DT: ... # NoQA: E704 + @overload + def get_option(self, name: _FLAVOR, /) -> Flavor: ... # NoQA: E704 + @overload + def get_option(self, name: _FLAVOR, default: Flavor, /) -> Flavor: ... # NoQA: E704 + @overload + def get_option(self, name: _FLAVOR, default: DT, /) -> Flavor | DT: ... # NoQA: E704 + def get_option(self, name: _OPTION, /, *default: object) -> object: # NoQA: E301 + """Get an option value, or a default value. + + :param name: An option name specified in :attr:`default_options`. + :return: An option value. + + When *default* is specified and *name* is not explicitly stored by + this object, that *default* is returned instead of the default value + specified in :attr:`default_options`. + """ + if name in self.__options: + return self.__options[name] + return default[0] if default else self.default_options[name] + + @overload + def set_option(self, name: _FLAG, value: bool, /) -> None: ... # NoQA: E704 + @overload + def set_option(self, name: _STRIP, value: StripChars, /) -> None: ... # NoQA: E704 + @overload + def set_option(self, name: _PRUNE, value: PrunePattern, /) -> None: ... # NoQA: E704 + @overload + def set_option(self, name: _IGNORE, value: LinePredicate | None, /) -> None: ... # NoQA: E704 + @overload + def set_option(self, name: _OPCODES, value: OpCodes, /) -> None: ... # NoQA: E704 + @overload + def set_option(self, name: _FLAVOR, value: Flavor, /) -> None: ... # NoQA: E704 + def set_option(self, name: _OPTION, value: Any, /) -> None: # NoQA: E301 + """Set a persistent option value. + + The *name* should be an option for which a default value is specified + in :attr:`default_options`, but this is not enforced at runtime; thus, + the consistency of this object's state is left to the user. + """ + self.__options[name] = value + + @property + def keep_ansi(self) -> bool: + """See :attr:`Options.keep_ansi`.""" + return self.get_option('keep_ansi') + + @keep_ansi.setter + def keep_ansi(self, value: bool) -> None: + self.set_option('keep_ansi', value) + + @property + def strip(self) -> StripChars: + """See :attr:`Options.strip`.""" + return self.get_option('strip') + + @strip.setter + def strip(self, value: StripChars) -> None: + self.set_option('strip', value) + + @property + def strip_line(self) -> StripChars: + """See :attr:`Options.strip_line`.""" + return self.get_option('strip_line') + + @strip_line.setter + def strip_line(self, value: StripChars) -> None: + self.set_option('strip_line', value) + + @property + def keep_break(self) -> bool: + """See :attr:`Options.keep_break`.""" + return self.get_option('keep_break') + + @keep_break.setter + def keep_break(self, value: bool) -> None: + self.set_option('keep_break', value) + + @property + def keep_empty(self) -> bool: + """See :attr:`Options.keep_empty`.""" + return self.get_option('keep_empty') + + @keep_empty.setter + def keep_empty(self, value: bool) -> None: + self.set_option('keep_empty', value) + + @property + def compress(self) -> bool: + """See :attr:`Options.compress`.""" + return self.get_option('compress') + + @compress.setter + def compress(self, value: bool) -> None: + self.set_option('compress', value) + + @property + def unique(self) -> bool: + """See :attr:`Options.unique`.""" + return self.get_option('unique') + + @unique.setter + def unique(self, value: bool) -> None: + self.set_option('unique', value) + + @property + def prune(self) -> PrunePattern: + """See :attr:`Options.prune`.""" + return self.get_option('prune') + + @prune.setter + def prune(self, value: PrunePattern) -> None: + self.set_option('prune', value) + + @property + def ignore(self) -> LinePredicate | None: + """See :attr:`Options.ignore`.""" + return self.get_option('ignore') + + @ignore.setter + def ignore(self, value: LinePredicate | None) -> None: + self.set_option('ignore', value) + + @property + def ops(self) -> Sequence[OpCode]: + """See :attr:`Options.ops`.""" + return self.get_option('ops') + + @ops.setter + def ops(self, value: OpCodes) -> None: + self.set_option('ops', value) + + @property + def flavor(self) -> Flavor: + """See :attr:`Options.flavor`.""" + return self.get_option('flavor') + + @flavor.setter + def flavor(self, value: Flavor) -> None: + self.set_option('flavor', value) diff --git a/sphinx/testing/util.py b/sphinx/testing/util.py index b2df709eea8..325220fc292 100644 --- a/sphinx/testing/util.py +++ b/sphinx/testing/util.py @@ -17,16 +17,20 @@ import sphinx.application import sphinx.locale import sphinx.pycode +from sphinx.testing.matcher import LineMatcher from sphinx.util.console import strip_colors from sphinx.util.docutils import additional_nodes if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path - from typing import Any + from typing import Any, ClassVar from xml.etree.ElementTree import ElementTree from docutils.nodes import Node + from typing_extensions import Unpack + + from sphinx.testing.matcher.options import CompleteOptions, Options def assert_node(node: Node, cls: Any = None, xpath: str = "", **kwargs: Any) -> None: @@ -77,6 +81,12 @@ def etree_parse(path: str | os.PathLike[str]) -> ElementTree: return xml_parse(path) +class _SphinxLineMatcher(LineMatcher): + default_options: ClassVar[CompleteOptions] = LineMatcher.default_options.copy() + default_options['keep_ansi'] = False + default_options['strip'] = True + + class SphinxTestApp(sphinx.application.Sphinx): """A subclass of :class:`~sphinx.application.Sphinx` for tests. @@ -191,6 +201,14 @@ def warning(self) -> StringIO: assert isinstance(self._warning, StringIO) return self._warning + def stdout(self, /, **options: Unpack[Options]) -> LineMatcher: + """Create a line matcher object for the status messages.""" + return _SphinxLineMatcher(self.status, **options) + + def stderr(self, /, **options: Unpack[Options]) -> LineMatcher: + """Create a line matcher object for the warning messages.""" + return _SphinxLineMatcher(self.warning, **options) + def cleanup(self, doctrees: bool = False) -> None: sys.path[:] = self._saved_path _clean_up_global_state() diff --git a/tests/test_testing/__init__.py b/tests/test_testing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_testing/test_matcher.py b/tests/test_testing/test_matcher.py new file mode 100644 index 00000000000..9cc624aa724 --- /dev/null +++ b/tests/test_testing/test_matcher.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import dataclasses +import itertools +from functools import cached_property +from typing import TYPE_CHECKING + +import pytest + +import sphinx.testing.matcher._util as util +import sphinx.util.console as term +from sphinx.testing.matcher import LineMatcher + +if TYPE_CHECKING: + from collections.abc import Sequence, Set + + from _pytest._code import ExceptionInfo + + from sphinx.testing.matcher._util import LinePattern + from sphinx.testing.matcher.options import Flavor + + +@dataclasses.dataclass +class Source: + total: int + """The total number of lines in the source.""" + start: int + """The start index of the main block.""" + width: int + """The size of the main block.""" + dedup: int + """Number of times the main block is duplicated.""" + + @property + def ncopy(self) -> int: + """The number of copies of the base block in the main block.""" + return self.dedup + 1 + + @property + def stop(self) -> int: + """Stop index of the main block.""" + # possibly out of bounds if the test fixture requires + # more copies than possible + return self.start + self.ncopy * self.width + + @cached_property + def lines(self) -> list[str]: + """The source's lines.""" + return [*self.head, *self.main, *self.tail] + + @cached_property + def text(self) -> str: + """The source as a single string.""" + return '\n'.join(self.lines) + + @cached_property + def head(self) -> list[str]: + """The lines before the highlighted block.""" + return list(map(self.outer_line, range(self.start))) + + @cached_property + def tail(self) -> list[str]: + """The lines after the highlighted block.""" + return list(map(self.outer_line, range(self.stop, self.total))) + + @cached_property + def base(self) -> list[str]: + """Single main block (no duplication).""" + return list(map(self.block_line, range(self.start, self.start + self.width))) + + @cached_property + def main(self) -> list[str]: + """The block that could be highlighted (possibly duplicated).""" + parts = itertools.repeat(self.base, self.ncopy) + block = list(itertools.chain.from_iterable(parts)) + assert len(block) == self.ncopy * self.width, 'ill-formed block' + return block + + def peek_prev(self, context_size: int) -> list[str]: + """The context lines before the main block.""" + imin = max(0, self.start - context_size) + peek = [Source.outer_line(i) for i in range(imin, self.start)] + assert len(peek) <= context_size + return peek + + def peek_next(self, context_size: int) -> list[str]: + """The context lines after the main block.""" + imax = min(self.stop + context_size, self.total) + peek = [Source.outer_line(i) for i in range(self.stop, imax)] + assert len(peek) <= context_size + return peek + + @staticmethod + def outer_line(i: int) -> str: + """Line not in the main block.""" + return f'L{i}' + + @staticmethod + def block_line(i: int) -> str: + """Line in the main block.""" + return f'B{i}' + + +def make_debug_context( + block: list[str], # highlighted block + /, + view_prev: list[str], # context lines before the main block + omit_prev: int, # number of lines that were omitted before 'view_prev' + view_next: list[str], # context lines after the main block + omit_next: int, # number of lines that were omitted after 'view_next' + *, + context_size: int, # the original value of the 'context_size' parameter + indent: int = 4, +) -> list[str]: + """Other API for :func:`sphinx.testing.matcher._util.diff`. + + The resulting lines are of the form:: + + - a line indicating that *omit_prev* lines were omitted, + - the block *view_prev*, + - the main *block* (highlighted), + - the block *view_next*, + - a line indicating that *omit_next* lines were omitted. + + If *context_size = 0*, the lines indicating omitted lines are not included. + """ + lines: list[str] = [] + writelines = lines.extend + writelines(util.omit_line(bool(context_size) * omit_prev)) + writelines(util.indent_lines(view_prev, indent=indent, highlight=False)) + writelines(util.indent_lines(block, indent=indent, highlight=True)) + writelines(util.indent_lines(view_next, indent=indent, highlight=False)) + writelines(util.omit_line(bool(context_size) * omit_next)) + return lines + + +def parse_excinfo(excinfo: ExceptionInfo[AssertionError]) -> list[str]: + # see: https://github.com/pytest-dev/pytest/issues/12175 + assert excinfo.type is AssertionError + assert excinfo.value is not None + return str(excinfo.value).removeprefix('AssertionError: ').splitlines() + + +def test_matcher_cache(): + source = [term.blue('hello'), '', 'world'] + matcher = LineMatcher.from_lines(source) + + stack_attribute = f'_{matcher.__class__.__name__.lstrip("_")}__stack' + stack = getattr(matcher, stack_attribute) + assert len(stack) == 1 + assert stack[0] is None + + cached = matcher.lines() + assert len(stack) == 1 + assert stack[0] is cached + assert cached == (term.blue('hello'), '', 'world') + + assert matcher.lines() is cached # cached result + assert len(stack) == 1 + + with matcher.override(): + assert len(stack) == 2 + assert stack[0] is cached + assert stack[1] is None + + assert matcher.lines() == cached + assert len(stack) == 2 + assert stack[1] == 0 # do not duplicate the lines + + assert matcher.lines() is cached + assert len(stack) == 2 + + assert len(stack) == 1 + assert stack[0] is cached + assert matcher.lines() is cached + + with matcher.override(keep_ansi=False): + assert len(stack) == 2 + assert stack[0] is cached + assert stack[1] is None + + assert matcher.lines() == ('hello', '', 'world') + assert len(stack) == 2 + assert stack[1] == ('hello', '', 'world') + + +@pytest.mark.parametrize( + ('lines', 'flavor', 'pattern', 'expect'), + [ + ([], 'literal', [], []), + (['a'], 'literal', '', []), + (['a'], 'literal', [], []), + (['1', 'b', '3', 'a', '5', '!'], 'literal', ('a', 'b'), [('b', 1), ('a', 3)]), + (['blbl', 'yay', 'hihi', '^o^'], 'fnmatch', '*[ao]*', [('yay', 1), ('^o^', 3)]), + (['111', 'hello', 'world', '222'], 're', r'\d+', [('111', 0), ('222', 3)]), + (['hello', 'world', 'yay'], 'literal', {'hello', 'yay'}, [('hello', 0), ('yay', 2)]), + (['hello', 'world', 'yay'], 'fnmatch', {'hello', 'y*y'}, [('hello', 0), ('yay', 2)]), + (['hello', 'world', 'yay'], 're', {'hello', r'^y\wy$'}, [('hello', 0), ('yay', 2)]), + ], +) +def test_matcher_find( + lines: list[str], + flavor: Flavor, + pattern: LinePattern | Set[LinePattern] | Sequence[LinePattern], + expect: Sequence[tuple[str, int]], +) -> None: + matcher = LineMatcher.from_lines(lines, flavor=flavor) + assert matcher.find(pattern) == tuple(expect) + + matcher = LineMatcher.from_lines(lines, flavor='literal') + assert matcher.find(pattern, flavor=flavor) == tuple(expect) + + +def test_matcher_find_blocks(): + lines = ['hello', 'world', 'yay', 'hello', 'world', '!', 'yay'] + matcher = LineMatcher.from_lines(lines) + + assert matcher.find_blocks(['hello', 'world']) == ( + [('hello', 0), ('world', 1)], + [('hello', 3), ('world', 4)], + ) + + assert matcher.find_blocks(['hello', 'w[oO]rld'], flavor='fnmatch') == ( + [('hello', 0), ('world', 1)], + [('hello', 3), ('world', 4)], + ) + + assert matcher.find_blocks(['hello', r'^w[a-z]{2}\wd$'], flavor='re') == ( + [('hello', 0), ('world', 1)], + [('hello', 3), ('world', 4)], + ) + + +def test_assert_match(): + matcher = LineMatcher.from_lines(['a', 'b', 'c', 'd']) + matcher.assert_any_of('.+', flavor='re') + matcher.assert_any_of('[abcd]', flavor='fnmatch') + + matcher = LineMatcher('') + with pytest.raises(AssertionError, match=r'(?s:.+not found in.+)'): + matcher.assert_any_of('.+', flavor='re') + + matcher = LineMatcher('') + with pytest.raises(AssertionError, match=r'(?s:.+not found in.+)'): + matcher.assert_any_of('.*', flavor='re') + + matcher = LineMatcher.from_lines(['\n']) + assert matcher.lines() == [''] + matcher.assert_any_of('.*', flavor='re') + + +@pytest.mark.parametrize( + ('lines', 'pattern', 'flavor', 'expect'), + [ + ( + ['a', 'b', 'c', 'd', 'e'], + '[a-z]{3,}', + 're', + [ + 'line pattern', + '', + ' [a-z]{3,}', + '', + 'not found in', + '', + ' a', + ' b', + ' c', + ' d', + ' e', + ], + ), + ], +) +def test_assert_match_debug(lines, pattern, flavor, expect): + matcher = LineMatcher.from_lines(lines) + + with pytest.raises(AssertionError) as exc_info: + matcher.assert_any_of(pattern, flavor=flavor) + + assert parse_excinfo(exc_info) == expect + + +def test_assert_no_match(): + matcher = LineMatcher.from_lines(['a', 'b', 'c', 'd']) + matcher.assert_none_of(r'\d+', flavor='re') + matcher.assert_none_of('[1-9]', flavor='fnmatch') + + +@pytest.mark.parametrize( + ('lines', 'pattern', 'flavor', 'context', 'expect'), + [ + ( + ['a', 'b', '11X', '22Y', '33Z', 'c', 'd'], + '[1-9]{2}[A-Z]', + 're', + 2, + [ + 'line pattern', + '', + ' [1-9]{2}[A-Z]', + '', + 'found in', + '', + ' a', + ' b', + '> 11X', + ' 22Y', + ' 33Z', + '... (omitted 2 lines) ...', + ], + ), + ], +) +def test_assert_no_match_debug(lines, pattern, flavor, context, expect): + matcher = LineMatcher.from_lines(lines) + + with pytest.raises(AssertionError) as exc_info: + matcher.assert_none_of(pattern, context=context, flavor=flavor) + + assert parse_excinfo(exc_info) == expect + + +@pytest.mark.parametrize('dedup', range(3)) +@pytest.mark.parametrize(('maxsize', 'start', 'count'), [(10, 3, 4)]) +def test_assert_block_coverage(maxsize, start, count, dedup): + # 'maxsize' might be smaller than start + (dedup + 1) * count + # but it is fine since stop indices are clamped internally + source = Source(maxsize, start, count, dedup=dedup) + matcher = LineMatcher(source.text) + + # the main block is matched exactly once + matcher.assert_block(source.main, count=1, flavor='literal') + assert source.base * source.ncopy == source.main + matcher.assert_block(source.base, count=source.ncopy, flavor='literal') + + for subidx in range(1, count + 1): + # check that the sub-blocks are matched correctly + subblock = [Source.block_line(start + i) for i in range(subidx)] + matcher.assert_block(subblock, count=source.ncopy, flavor='literal') + + +@pytest.mark.parametrize( + ('lines', 'pattern', 'count', 'expect'), + [ + ( + ['a', 'b', 'c', 'a', 'b', 'd'], + ['x', 'y'], + None, + [ + 'block pattern', + '', + ' x', + ' y', + '', + 'not found in', + '', + ' a', + ' b', + ' c', + ' a', + ' b', + ' d', + ], + ), + ( + ['a', 'b', 'c', 'a', 'b', 'd'], + ['a', 'b'], + 1, + [ + 'found 2 != 1 block matching', + '', + ' a', + ' b', + '', + 'in', + '', + '> a', + '> b', + ' c', + '> a', + '> b', + ' d', + ], + ), + (['a', 'b', 'c', 'a', 'b', 'd'], ['a', 'b'], 2, None), + ( + ['a', 'b', 'c', 'a', 'b', 'd'], + ['a', 'b'], + 3, + [ + 'found 2 != 3 blocks matching', + '', + ' a', + ' b', + '', + 'in', + '', + '> a', + '> b', + ' c', + '> a', + '> b', + ' d', + ], + ), + ], +) +def test_assert_block_debug(lines, pattern, count, expect): + matcher = LineMatcher.from_lines(lines, flavor='literal') + + if expect is None: + matcher.assert_block(pattern, count=count) + return + + with pytest.raises(AssertionError, match='.*') as exc_info: + matcher.assert_block(pattern, count=count) + + assert parse_excinfo(exc_info) == expect + + +@pytest.mark.parametrize(('maxsize', 'start', 'count'), [ + # combinations of integers (a, b, c) such that c >= 1 and a >= b + c + (1, 0, 1), + (2, 0, 1), (2, 0, 2), (2, 1, 1), + (3, 0, 1), (3, 0, 2), (3, 0, 3), (3, 1, 1), (3, 1, 2), (3, 2, 1), +]) # fmt: skip +@pytest.mark.parametrize('dedup', range(3)) +def test_assert_no_block_coverage(maxsize, start, count, dedup): + # 'maxsize' might be smaller than start + (dedup + 1) * count + # but it is fine since stop indices are clamped internally + source = Source(maxsize, start, count, dedup=dedup) + matcher = LineMatcher(source.text, flavor='literal') + + with pytest.raises(AssertionError) as exc_info: + matcher.assert_no_block(source.main, context=0) + + assert parse_excinfo(exc_info) == [ + 'block pattern', + '', + *util.indent_lines(source.main, indent=4, highlight=False), + '', + 'found in', + '', + *util.indent_lines(source.main, indent=4, highlight=True), + ] + + +@pytest.mark.parametrize( + ('lines', 'pattern', 'flavor', 'context', 'expect'), + [ + ( + ['a', 'b', '11X', '22Y', '33Z', 'c', 'd', 'e', 'f'], + [r'\d{2}X', r'\d*\w+', r'^33Z$'], + 're', + 2, + [ + 'block pattern', + '', + r' \d{2}X', + r' \d*\w+', + r' ^33Z$', + '', + 'found in', + '', + ' a', + ' b', + '> 11X', + '> 22Y', + '> 33Z', + ' c', + ' d', + '... (omitted 2 lines) ...', + ], + ), + ], +) +def test_assert_no_block_debug(lines, pattern, flavor, context, expect): + matcher = LineMatcher.from_lines(lines) + + with pytest.raises(AssertionError) as exc_info: + matcher.assert_no_block(pattern, context=context, flavor=flavor) + + assert parse_excinfo(exc_info) == expect + + +@pytest.mark.parametrize( + ('maxsize', 'start', 'count', 'dedup', 'omit_prev', 'omit_next', 'context_size'), + [ + # with small context + (10, 2, 4, 0, 1, 3, 1), # [--, L1, B2, B3, B4, B5, L6, --, --, --] + (10, 3, 4, 0, 2, 2, 1), # [--, --, L2, B3, B4, B5, B6, L7, --, --] + (10, 4, 4, 0, 3, 1, 1), # [--, --, --, L3, B4, B5, B6, B7, L8, --] + # with large context + (10, 2, 4, 0, 0, 1, 3), # [L0, L1, B2, B3, B4, B5, L6, L7, L8, --] + (10, 4, 4, 0, 0, 0, 5), # [L0, L1, L2, L3, B4, B5, B6, B7, L8, L9] + (10, 4, 4, 0, 1, 0, 3), # [--, L1, L2, L3, B4, B5, B6, B7, L8, L9] + # with duplicated block and small context + # [--, L1, (B2, B3, B4, B5) (2x), L10, -- (9x)] + (20, 2, 4, 1, 1, 9, 1), + # [--, --, L2, (B3, B4, B5, B6) (2x), L10, -- (8x)] + (20, 3, 4, 1, 2, 8, 1), + # [--, --, --, L3, (B4, B5, B6, B7) (2x), L11, -- (7x)] + (20, 4, 4, 1, 3, 7, 1), + # with duplicated block and large context + # [L0, L1, (B2, B3, B4, B5) (2x), L10, L11, L12, L13, L14, -- (5x)] + (20, 2, 4, 1, 0, 5, 5), + # [L0, L1, (B2, B3, B4, B5) (3x), L17, L18, L19] + (20, 2, 4, 2, 0, 0, 10), + # [--, --, --, --, --, L5, L6, L7, (B8, B9) (5x), L18, L19] + (20, 8, 2, 4, 5, 0, 3), + ], +) +def test_assert_no_block_debug_coverage( + maxsize, start, count, dedup, omit_prev, omit_next, context_size +): + source = Source(maxsize, start, count, dedup=dedup) + matcher = LineMatcher(source.text, flavor='literal') + with pytest.raises(AssertionError) as exc_info: + matcher.assert_no_block(source.main, context=context_size) + + assert parse_excinfo(exc_info) == [ + 'block pattern', + '', + *util.indent_lines(source.main, indent=4, highlight=False), + '', + 'found in', + '', + *make_debug_context( + source.main, + source.peek_prev(context_size), + omit_prev, + source.peek_next(context_size), + omit_next, + context_size=context_size, + indent=4, + ), + ] diff --git a/tests/test_testing/test_matcher_buffer.py b/tests/test_testing/test_matcher_buffer.py new file mode 100644 index 00000000000..fab03b01eda --- /dev/null +++ b/tests/test_testing/test_matcher_buffer.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import contextlib +import itertools +import operator +import re +from typing import TYPE_CHECKING + +import pytest + +from sphinx.testing.matcher.buffer import Block, Line + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Any + + from sphinx.testing.matcher.buffer import Region + + +@pytest.mark.parametrize('cls', [Line, Block]) +def test_offset_value(cls: type[Region[Any]]) -> None: + with pytest.raises(TypeError, match=re.escape('offset must be an integer, got: None')): + cls('', None) # type: ignore[arg-type] + + with pytest.raises(ValueError, match=re.escape('offset must be >= 0, got: -1')): + cls('', -1) + + +def test_line_region_span(): + for n in range(3): + # the empty line is still a line in the source + assert Line('', n).span == slice(n, n + 1) + + line = Line('', 1) + assert ['L1', '', 'L3', 'L4', 'L4'][line.span] == [''] + + +def test_line_slice_context(): + assert Line('L2', 1).context(delta=4, limit=5) == (slice(0, 1), slice(2, 5)) + assert Line('L2', 3).context(delta=2, limit=9) == (slice(1, 3), slice(4, 6)) + + +def test_line_startswith(): + line = Line('abac') + assert line.startswith('a') + assert line.startswith('ab') + assert not line.startswith('no') + + line = Line('ab bb c') + assert line.startswith(' ', 2) + assert line.startswith(' bb', 2) + assert not line.startswith('a', 2) + + +def test_line_endswith(): + line = Line('ab1ac') + assert line.endswith('c') + assert line.endswith('ac') + assert not line.endswith('no') + + line = Line('ab 4b 3c ') + assert line.endswith(' ', 2) + assert line.endswith('3c ', 2) + assert not line.endswith('b 3c ', 0, 4) + + +def test_line_type_errors(): + line = Line() + pytest.raises(TypeError, line.count, 2) + pytest.raises(TypeError, line.index, 2) + pytest.raises(TypeError, line.find, 2) + + +def test_line_count_substrings(): + line = Line('abac') + assert line.count('no') == 0 + assert line.count('a') == 2 + + line = Line('ab bb ac cc') + assert line.count(re.compile(r'^\Z')) == 0 + assert line.count(re.compile(r'a[bc]')) == 2 + + +@pytest.mark.parametrize( + ('line', 'data'), + [ + ( + Line('abaz'), + [ + ('a', (), 0), + ('a', (1,), 2), + ('not_found', (), -1), + ('z', (0, 2), -1), # do not include last character + ], + ), + ( + # -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 + # 0 1 2 3 4 5 6 7 8 9 10 + Line(''.join(('a', 'b', ' ', 'b', 'b', ' ', 'x', 'c', ' ', 'c', 'c'))), # NoQA: FLY002 + [ + (re.compile(r'a\w'), (), 0), + (re.compile(r'\bx'), (2,), 6), + *itertools.product( + [re.compile(r'\s+')], + [(3,), (-8,)], + [5], + ), + *itertools.product( + [re.compile(r'c ')], + [(6, 9), (6, -2), (-5, -2), (-5, 9)], # all equivalent to (6, 9) + [7], + ), + *itertools.product( + [re.compile(r'^bb')], + [(3, 8), (3, -3), (-8, -3), (-8, -3)], # all equivalent to (3, 8) + [3], + ), + (re.compile(r'^\Z'), (), -1), + *itertools.product( + [re.compile(r'c[cd]')], + [(0, 5), (-6, 5)], + [-1], + ), + ], + ), + ], +) +def test_line_find(line: Line, data: list[tuple[str, tuple[int, ...], int]]) -> None: + for target, args, expect in data: + actual = line.find(target, *args) + + if expect == -1: + assert actual == expect, (line.buffer, target, args) + with pytest.raises(ValueError, match=re.escape(str(target))): + line.index(target, *args) + else: + assert actual == expect, (line.buffer, target, args) + assert line.index(target, *args) == expect + + +def test_empty_line_operators(): + assert Line() == '' + assert Line() == ['', 0] + + assert Line() != ['', 1] + assert Line() != ['a'] + assert Line() != ['a', 0] + assert Line() != object() + + assert Line() <= '' + assert Line() <= 'a' + assert Line() <= ['a', 0] + assert Line() <= Line('a', 0) + + assert Line() < 'a' + assert Line() < ['a', 0] + assert Line() < Line('a', 0) + + # do not simplify these expressions + assert not operator.__lt__(Line(), '') # NoQA: PLC2801 + assert not operator.__lt__(Line(), ['', 0]) # NoQA: PLC2801 + assert not operator.__lt__(Line(), Line()) # NoQA: PLC2801 + + assert not operator.__gt__(Line(), '') # NoQA: PLC2801 + assert not operator.__gt__(Line(), ['', 0]) # NoQA: PLC2801 + assert not operator.__gt__(Line(), Line()) # NoQA: PLC2801 + + +def test_non_empty_line_operators(): + assert Line('a', 1) == 'a' + assert Line('a', 1) == ('a', 1) + assert Line('a', 1) == ['a', 1] + assert Line('a', 1) == Line('a', 1) + + assert Line('a', 2) != 'b' + assert Line('a', 2) != ('a', 1) + assert Line('a', 2) != ('b', 2) + assert Line('a', 2) != ['a', 1] + assert Line('a', 2) != ['b', 2] + assert Line('a', 2) != Line('a', 1) + assert Line('a', 2) != Line('b', 2) + + # order + assert Line('ab', 1) > 'a' + assert Line('ab', 1) > ('a', 1) + assert Line('ab', 1) > ['a', 1] + assert Line('ab', 1) > Line('a', 1) + + assert Line('a', 1) < 'ab' + assert Line('a', 1) < ('ab', 1) + assert Line('a', 1) < ['ab', 1] + assert Line('a', 1) < Line('ab', 1) + + assert Line('ab', 1) >= 'ab' + assert Line('ab', 1) >= ('ab', 1) + assert Line('ab', 1) >= ['ab', 1] + assert Line('ab', 1) >= Line('ab', 1) + + assert Line('ab', 1) <= 'ab' + assert Line('ab', 1) <= ('ab', 1) + assert Line('ab', 1) <= ['ab', 1] + assert Line('ab', 1) <= Line('ab', 1) + + +@pytest.mark.parametrize( + 'operand', + [ + '', + '12', # 2-element sequence + 'abcdef', + ['L1', 0], + ('L1', 1), + Line(), + ], +) +def test_line_supported_operators(operand): + with contextlib.nullcontext(): + for dispatcher in [operator.__lt__, operator.__le__, operator.__ge__, operator.__gt__]: + dispatcher(Line(), operand) + + +@pytest.mark.parametrize( + 'operand', + [ + [], + [Line()], + [Line(), 0], + [chr(1)], + [chr(1), chr(2)], + [chr(1), chr(2), chr(3)], + [[chr(1), chr(2)], 0], + ], +) +def test_line_unsupported_operators(operand): + for dispatcher in [operator.__lt__, operator.__le__, operator.__ge__, operator.__gt__]: + pytest.raises(TypeError, dispatcher, Line(), operand) + + assert Line() != operand + + +def test_block_constructor(): + empty = Block() + assert empty.buffer == () + assert empty.offset == 0 + + match = re.escape('expecting a native string, got: 1234') + with pytest.raises(TypeError, match=match): + Block([1234]) # type: ignore[list-item] + + +def test_empty_block_operators(): + assert Block() == [] + assert Block() == [[], 0] + + assert Block() != [[], 1] + assert Block() != ['a'] + assert Block() != [['a'], 0] + assert Block() != object() + + assert Block() <= [] + assert Block() <= ['a'] + assert Block() <= [['a'], 0] + assert Block() <= [[Line('a', 0)], 0] + + assert Block() < ['a'] + assert Block() < [['a'], 0] + assert Block() < [[Line('a', 0)], 0] + + # do not simplify these expressions + assert not operator.__lt__(Block(), []) # NoQA: PLC2801 + assert not operator.__lt__(Block(), [[], 0]) # NoQA: PLC2801 + + assert not operator.__gt__(Block(), []) # NoQA: PLC2801 + assert not operator.__gt__(Block(), ['a']) # NoQA: PLC2801 + assert not operator.__gt__(Block(), [['a'], 0]) # NoQA: PLC2801 + assert not operator.__gt__(Block(), [[('a', 0)], 0]) # NoQA: PLC2801 + assert not operator.__gt__(Block(), [[Line('a', 0)], 0]) # NoQA: PLC2801 + + +@pytest.mark.parametrize( + ('lines', 'foreign', 'expect'), + [ + (['a', 'b', 'c'], 'd', ('a', 'b', 'c')), + (['a', 'b', 'c'], 'd', ('a', ('b', 2), Line('c', 3))), + (['a', 'b', 'c'], 'd', ('a', ['b', 2], Line('c', 3))), + ], +) +def test_non_empty_block_operators( + lines: list[str], foreign: str, expect: Sequence[str | tuple[str, int] | Line] +) -> None: + assert Block(lines, 1) == expect + assert Block(lines, 1) == [expect, 1] + + assert Block(lines, 1) != [*expect, foreign] + assert Block(lines, 1) != [expect, 2] + + assert Block(lines, 1) <= expect + assert Block(lines, 1) <= [expect, 1] + + assert Block(lines[:2], 1) <= expect + assert Block(lines[:2], 1) <= [expect, 1] + + assert Block(lines[:2], 1) < expect + assert Block(lines[:2], 1) < [expect, 1] + + assert Block(lines, 1) >= expect + assert Block(lines, 1) >= [expect, 1] + + assert Block([*lines, foreign], 1) > expect + assert Block([*lines, foreign], 1) > [expect, 1] + + assert Block([foreign, *lines, foreign], 1) > expect + assert Block([foreign, *lines, foreign], 1) > [expect, 1] + + +@pytest.mark.parametrize( + 'operand', + [ + [], + [[], 0], + ['L1'], + [Line()], + ['AA', 'AA'], # outer: 2 items, inner: 2 items + ['AAA', 'AAA'], # outer: 2 items, inner: 3 items + ['AA', ('AA', 1)], # first line, second line + offset + ['L1', Line()], + ['L1', 'L2', 'L3'], + ['L1', 'L2', Line()], + [['L1'], 0], + [[Line()], 0], + [['L1', 'L2'], 0], + [['L1', Line()], 0], + ], +) +def test_block_supported_operators(operand): + with contextlib.nullcontext(): + for dispatcher in [operator.__lt__, operator.__le__, operator.__ge__, operator.__gt__]: + dispatcher(Block(), operand) + + +@pytest.mark.parametrize( + 'operand', + [ + object(), # bad lines + ['L1', object(), 'L3'], # bad lines (no offset) + [['a', object()], 1], # bad lines (with offset) + [1, 'L1'], # two-elements bad inputs + ['L1', 1], # single line + offset not allowed + ['AA', (1, 1)], # outer: 2 items, inner: 2 items + ['AA', ('AA', '102')], + [[], object()], # no lines + bad offset + [['L1', 'L2'], object()], # ok lines + bad offset + [[object(), object()], object()], # bad lines + bad offset + ], +) +def test_block_unsupported_operators(operand): + for dispatcher in [operator.__lt__, operator.__le__, operator.__ge__, operator.__gt__]: + pytest.raises(TypeError, dispatcher, Block(), operand) + + assert Block() != operand + + +def test_block_region_span(): + for n in range(3): + assert Block([], n).span == slice(n, n) + + block = Block(['B', 'C', 'D'], 1) + assert block.span == slice(1, 4) + assert ['A', 'B', 'C', 'D', 'E'][block.span] == ['B', 'C', 'D'] + + +def test_block_slice_context(): + assert Block(['a', 'b'], 1).context(delta=4, limit=5) == (slice(0, 1), slice(3, 5)) + assert Block(['a', 'b'], 3).context(delta=2, limit=9) == (slice(1, 3), slice(5, 7)) + + +def test_block_count_lines(): + block = Block(['a', 'b', 'a', 'c']) + assert block.count('no') == 0 + assert block.count('a') == 2 + + block = Block(['ab', 'bb', 'ac']) + # this also tests the predicate-based implementation + assert block.count(re.compile(r'^\Z')) == 0 + assert block.count(re.compile(r'a\w')) == 2 + + +@pytest.mark.parametrize( + ('block', 'data'), + [ + ( + Block(['a', 'b', 'a', 'end']), + [ + ('a', (), 0), + ('a', (1,), 2), + ('not_found', (), -1), + ('end', (0, 2), -1), # do not include last line + ], + ), + ( + # -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 + # 0 1 2 3 4 5 6 7 8 9 10 + Block(('a0', 'b1', ' ', 'b3', 'b4', ' ', '6a', 'a7', ' ', 'cc', 'c?')), + [ + (re.compile(r'a\d'), (), 0), + (re.compile(r'a\d'), (1,), 7), + *itertools.product( + [re.compile(r'\d\w')], # '6a' + [(3, 9), (3, -2), (-8, 9), (-8, -2)], # all equivalent to (3, 9) + [6], + ), + *itertools.product( + [re.compile(r'^\s+')], + [(5, 8), (5, -3), (-6, 8), (-6, -3)], # all equivalent to (5, 8) + [5], + ), + (re.compile(r'^\Z'), (), -1), + *itertools.product( + [re.compile(r'c\?')], + [(0, 4), (-7, 9)], + [-1], + ), + ], + ), + ], +) +def test_block_find(block: Block, data: list[tuple[str, tuple[int, ...], int]]) -> None: + for target, args, expect in data: + actual = block.find(target, *args) + + if expect == -1: + assert actual == expect, (block.buffer, target, args) + with pytest.raises(ValueError, match=re.escape(str(target))): + block.index(target, *args) + else: + assert actual == expect, (block.buffer, target, args) + assert block.index(target, *args) == expect diff --git a/tests/test_testing/test_matcher_cleaner.py b/tests/test_testing/test_matcher_cleaner.py new file mode 100644 index 00000000000..1a6d4ffa3c9 --- /dev/null +++ b/tests/test_testing/test_matcher_cleaner.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING +from unittest import mock + +import pytest + +from sphinx.testing.matcher import cleaner +from sphinx.testing.matcher._cleaner import HandlerMap, make_handlers +from sphinx.testing.matcher.options import OpCode, Options + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sphinx.testing.matcher._util import PatternLike + from sphinx.testing.matcher.cleaner import TraceInfo + + +def test_implementation_details(): + # expected and supported operation codes + expect = sorted(getattr(OpCode, '__args__', [])) + qualname = f'{HandlerMap.__module__}.{HandlerMap.__name__}' + assert expect, f'{qualname}: invalid literal type: {OpCode}' + + # ensure that the typed dictionary is synchronized + actual = sorted(HandlerMap.__annotations__.keys()) + qualname = f'{HandlerMap.__module__}.{HandlerMap.__name__}' + assert actual == expect, f'invalid operation codes in: {qualname!r}' + + handlers = make_handlers(mock.Mock()) + assert isinstance(handlers, dict) + actual = sorted(handlers.keys()) + assert actual == expect, 'invalid factory function' + + +def test_strip_chars(): + assert cleaner.strip_chars('abaaa\n') == 'abaaa' + assert cleaner.strip_chars('abaaa\n', False) == 'abaaa\n' + assert cleaner.strip_chars('abaaa', 'a') == 'b' + assert cleaner.strip_chars('abaaa', 'ab') == '' + + +def test_strip_lines(): + assert list(cleaner.strip_lines(['aba\n', 'aba\n'])) == ['aba', 'aba'] + assert list(cleaner.strip_lines(['aba\n', 'aba\n'], False)) == ['aba\n', 'aba\n'] + assert list(cleaner.strip_lines(['aba', 'aba'], 'a')) == ['b', 'b'] + assert list(cleaner.strip_lines(['aba', 'aba'], 'ab')) == ['', ''] + + +def test_filter_lines(): + src = '\n'.join(['a', 'a', '', 'a', 'b', 'c', 'a']) # NoQA: FLY002 + + expect = ['a', 'b', 'c', 'a'] + assert list(cleaner.clean(src, keep_empty=False, compress=True)) == expect + + expect = ['a', 'b', 'c'] + assert list(cleaner.clean(src, keep_empty=False, unique=True)) == expect + + expect = ['a', '', 'a', 'b', 'c', 'a'] + assert list(cleaner.clean(src, keep_empty=True, compress=True)) == expect + + expect = ['a', '', 'b', 'c'] + assert list(cleaner.clean(src, keep_empty=True, unique=True)) == expect + + +@pytest.mark.parametrize( + ('lines', 'patterns', 'expect', 'trace'), + [ + ( + ['88D79F0A2', '###'], + r'\d+', + ['DFA', '###'], + [ + [('88D79F0A2', ['DFA'])], + [('###', ['###'])], + ], + ), + ( + ['11a1', 'b1'], + '^1', + ['a1', 'b1'], + [ + [('11a1', ['1a1']), ('1a1', ['a1'])], + [('b1', ['b1'])], + ], + ), + ( + ['ABC#123'], + [r'^[A-Z]', r'\d$'], + ['#'], + [ + [ + ('ABC#123', ['BC#123', 'BC#12']), + ('BC#12', ['C#12', 'C#1']), + ('C#1', ['#1', '#']), + ], + ], + ), + ( + ['a 123\n456x7\n8\n b'], + [re.compile(r'\d\d'), re.compile(r'\n+')], + ['a x b'], + [ + [ + # elimination of double digits and new lines (in that order) + ('a 123\n456x7\n8\n b', ['a 3\n6x7\n8\n b', 'a 36x78 b']), + # new digits appeared so we re-eliminated them + ('a 36x78 b', ['a x b', 'a x b']), + ] + ], + ), + ( + ['a 123\n456x7\n8\n b'], + [re.compile(r'\n+'), re.compile(r'\d\d')], + ['a x b'], + [ + [ + # elimination of new lines and double digits (in that order) + ('a 123\n456x7\n8\n b', ['a 123456x78 b', 'a x b']), + ] + ], + ), + ], +) +def test_prune_lines( + lines: Sequence[str], + patterns: PatternLike | Sequence[PatternLike], + expect: Sequence[str], + trace: TraceInfo, +) -> None: + actual_trace: TraceInfo = [] + actual = cleaner.prune_lines(lines, patterns, trace=actual_trace) + assert list(actual) == list(expect) + assert actual_trace == list(trace) + + +def test_opcodes(): + options = Options(strip_line=True, keep_empty=False, compress=True) + + src = '\n'.join(['a', '', 'a', '', 'a']) # NoQA: FLY002 + # empty lines removed before duplicates + assert list(cleaner.clean(src, **options)) == ['a'] + + # empty lines removed after duplicates + options_with_opcodes = options | {'ops': ('strip', 'compress', 'check')} + assert list(cleaner.clean(src, **options_with_opcodes)) == ['a', 'a', 'a'] diff --git a/tests/test_testing/test_matcher_engine.py b/tests/test_testing/test_matcher_engine.py new file mode 100644 index 00000000000..d1f76a2cddb --- /dev/null +++ b/tests/test_testing/test_matcher_engine.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import random +import re + +import pytest + +from sphinx.testing.matcher import _engine as engine + + +def test_line_pattern(): + assert engine.to_line_patterns('a') == ('a',) + assert engine.to_line_patterns(['a', 'b']) == ('a', 'b') + + p = re.compile('b') + assert engine.to_line_patterns(p) == (p,) + assert engine.to_line_patterns(['a', p]) == ('a', p) + + # ensure build reproducibility + assert engine.to_line_patterns({'a', p}) == ('a', p) + + p1 = re.compile('a') + p2 = re.compile('a', re.MULTILINE) + p3 = re.compile('ab') + p4 = re.compile('ab', re.MULTILINE) + ps = (p1, p2, p3, p4) + + for _ in range(100): + random_patterns = list(ps) + random.shuffle(random_patterns) + patterns: set[str | re.Pattern[str]] = {*random_patterns, 'a'} + patterns.update(random_patterns) + assert engine.to_line_patterns(patterns) == ('a', p1, p2, p3, p4) + assert engine.to_line_patterns(frozenset(patterns)) == ('a', p1, p2, p3, p4) + + +def test_block_patterns(): + assert engine.to_block_pattern('a\nb\nc') == ('a', 'b', 'c') + + p = re.compile('a') + assert engine.to_block_pattern(p) == (p,) + assert engine.to_block_pattern(['a', p]) == ('a', p) + + +def test_format_expression(): + assert engine.format_expression(str.upper, 'a') == 'A' + + p = re.compile('') + assert engine.format_expression(str.upper, p) is p + + +@pytest.mark.parametrize(('string', 'expect'), [('foo.bar', r'\Afoo\.bar\Z')]) +def test_string_expression(string, expect): + assert engine.string_expression(string) == expect + pattern = re.compile(engine.string_expression(string)) + for func in (pattern.match, pattern.search, pattern.fullmatch): + assert func(string) is not None + assert func(string + '.') is None + assert func('.' + string) is None + + +def test_translate_expressions(): + string, compiled = 'a*', re.compile('.*') + patterns = (string, compiled) + + assert [*engine.translate(patterns, flavor='literal')] == [r'\Aa\*\Z', compiled] + assert [*engine.translate(patterns, flavor='fnmatch')] == [r'(?s:a.*)\Z', compiled] + assert [*engine.translate(patterns, flavor='re')] == [string, compiled] + + expect, func = [string.upper(), compiled], str.upper + assert [*engine.translate(patterns, flavor='literal', escape=func)] == expect + assert [*engine.translate(patterns, flavor='fnmatch', fnmatch_translate=func)] == expect + assert [*engine.translate(patterns, flavor='re', regular_translate=func)] == expect + + +def test_compile_patterns(): + string, compiled = 'a*', re.compile('.*') + patterns = (string, compiled) + + assert engine.compile(patterns, flavor='literal') == (re.compile(r'\Aa\*\Z'), compiled) + assert engine.compile(patterns, flavor='fnmatch') == (re.compile(r'(?s:a.*)\Z'), compiled) + assert engine.compile(patterns, flavor='re') == (re.compile(string), compiled) + + expect = (re.compile('A*'), compiled) + assert engine.compile(patterns, flavor='literal', escape=str.upper) == expect + assert engine.compile(patterns, flavor='fnmatch', fnmatch_translate=str.upper) == expect + assert engine.compile(patterns, flavor='re', regular_translate=str.upper) == expect diff --git a/tests/test_testing/test_matcher_options.py b/tests/test_testing/test_matcher_options.py new file mode 100644 index 00000000000..0a322604ee3 --- /dev/null +++ b/tests/test_testing/test_matcher_options.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING + +import pytest + +from sphinx.testing.matcher.options import CompleteOptions, Options, OptionsHolder + +if TYPE_CHECKING: + from typing import ClassVar + + from sphinx.testing.matcher.options import _OPTION + + +def test_options_type_implementation_details(): + """Test total and non-total synchronized typed dictionaries.""" + assert len(Options.__annotations__) > 0, 'missing annotations' + + # ensure that the classes are kept synchronized + missing_keys = Options.__annotations__.keys() - CompleteOptions.__annotations__ + assert not missing_keys, f'missing option(s): {", ".join(missing_keys)}' + + foreign_keys = CompleteOptions.__annotations__.keys() - Options.__annotations__ + assert not foreign_keys, f'unknown option(s): {", ".join(foreign_keys)}' + + +def test_default_options(): + """Check the synchronization of default options and classes in Sphinx.""" + default_options = OptionsHolder.default_options.copy() + + processed = set() + + def check(option: _OPTION, default: object) -> None: + assert option not in processed + assert option in default_options + assert default_options[option] == default + processed.add(option) + + check('keep_ansi', True) + + check('strip', False) + check('strip_line', False) + + check('keep_break', False) + check('keep_empty', True) + check('compress', False) + check('unique', False) + + check('prune', ()) + check('ignore', None) + + check('ops', ('strip', 'check', 'compress', 'unique', 'prune', 'filter')) + check('flavor', 'literal') + + # check that there are no leftover options + assert sorted(processed) == sorted(Options.__annotations__) + + +def test_options_holder(): + obj = OptionsHolder() + assert isinstance(obj.options, MappingProxyType) + assert isinstance(obj.complete_options, MappingProxyType) + + obj = OptionsHolder() + assert 'keep_break' not in obj.options + assert 'keep_break' in obj.complete_options + + +def test_get_options(): + class Config(OptionsHolder): + default_options: ClassVar[CompleteOptions] = OptionsHolder.default_options.copy() + default_options['keep_break'] = True + + obj = Config() + assert obj.keep_break is True + assert obj.get_option('keep_break') is True + assert obj.get_option('keep_break', False) is False + + obj = Config(prune='abc') + assert obj.get_option('prune') == 'abc' + assert obj.get_option('prune', 'unused') == 'abc' + + +def test_set_option(): + obj = OptionsHolder() + + assert 'prune' not in obj.options + assert obj.prune == () + obj.set_option('prune', 'abc') + + assert 'prune' in obj.options + assert obj.prune == 'abc' + assert obj.get_option('prune') == 'abc' + assert obj.get_option('prune', 'unused') == 'abc' + + +@pytest.mark.parametrize('option_name', list(Options.__annotations__)) +def test_property_implementation(option_name: _OPTION) -> None: + """Test that the implementation is correct and do not have typos.""" + obj = OptionsHolder() + + descriptor = obj.__class__.__dict__.get(option_name) + assert isinstance(descriptor, property) + + # make sure that the docstring is correct + docstring = descriptor.__doc__ + assert docstring is not None + assert docstring.startswith(f'See :attr:`Options.{option_name}`.') + + assert descriptor.fget is not None + assert descriptor.fget.__doc__ == descriptor.__doc__ + assert descriptor.fget.__name__ == option_name + + assert descriptor.fset is not None + assert descriptor.fset.__doc__ in (None, '') + assert descriptor.fset.__name__ == option_name + + assert descriptor.fdel is None # no deleter + + # assert that the default value being returned is the correct one + default_value = obj.__class__.default_options[option_name] + assert descriptor.fget(obj) is default_value + assert obj.get_option(option_name) is default_value + + # assert that the setter is correctly implemented + descriptor.fset(obj, val := object()) + assert descriptor.fget(obj) is val + assert obj.get_option(option_name) is val