From 0332aa0bfb663158e0a9597b74203ed5eecf3d79 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 3 Feb 2025 18:02:31 +0100 Subject: [PATCH 1/2] Make pygments dependency required Closes #7683 --- changelog/7683.improvement.rst | 1 + pyproject.toml | 2 +- src/_pytest/_io/terminalwriter.py | 54 ++++++++++--------------------- 3 files changed, 19 insertions(+), 38 deletions(-) create mode 100644 changelog/7683.improvement.rst diff --git a/changelog/7683.improvement.rst b/changelog/7683.improvement.rst new file mode 100644 index 00000000000..311abe4df93 --- /dev/null +++ b/changelog/7683.improvement.rst @@ -0,0 +1 @@ +The formerly optional ``pygments`` dependency is now required, causing output always to be source-highlighted (unless disabled via the ``--code-highlight=no`` CLI option). diff --git a/pyproject.toml b/pyproject.toml index 3c3c04d2d5b..263e0c23836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dependencies = [ "iniconfig", "packaging", "pluggy>=1.5,<2", + "pygments>=2.7.2", "tomli>=1; python_version<'3.11'", ] optional-dependencies.dev = [ @@ -58,7 +59,6 @@ optional-dependencies.dev = [ "attrs>=19.2", "hypothesis>=3.56", "mock", - "pygments>=2.7.2", "requests", "setuptools", "xmlschema", diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py index 50ce463f6b2..fd808f8b3b7 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -9,17 +9,17 @@ from typing import final from typing import Literal from typing import TextIO -from typing import TYPE_CHECKING + +import pygments +from pygments.formatters.terminal import TerminalFormatter +from pygments.lexer import Lexer +from pygments.lexers.diff import DiffLexer +from pygments.lexers.python import PythonLexer from ..compat import assert_never from .wcwidth import wcswidth -if TYPE_CHECKING: - from pygments.formatter import Formatter - from pygments.lexer import Lexer - - # This code was initially copied from py 1.8.1, file _io/terminalwriter.py. @@ -201,37 +201,22 @@ def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> No for indent, new_line in zip(indents, new_lines): self.line(indent + new_line) - def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer | None: - try: - if lexer == "python": - from pygments.lexers.python import PythonLexer - - return PythonLexer() - elif lexer == "diff": - from pygments.lexers.diff import DiffLexer - - return DiffLexer() - else: - assert_never(lexer) - except ModuleNotFoundError: - return None - - def _get_pygments_formatter(self) -> Formatter | None: - try: - import pygments.util - except ModuleNotFoundError: - return None + def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer: + if lexer == "python": + return PythonLexer() + elif lexer == "diff": + return DiffLexer() + else: + assert_never(lexer) + def _get_pygments_formatter(self) -> TerminalFormatter: from _pytest.config.exceptions import UsageError theme = os.getenv("PYTEST_THEME") theme_mode = os.getenv("PYTEST_THEME_MODE", "dark") try: - from pygments.formatters.terminal import TerminalFormatter - return TerminalFormatter(bg=theme_mode, style=theme) - except pygments.util.ClassNotFound as e: raise UsageError( f"PYTEST_THEME environment variable has an invalid value: '{theme}'. " @@ -251,16 +236,11 @@ def _highlight( return source pygments_lexer = self._get_pygments_lexer(lexer) - if pygments_lexer is None: - return source - pygments_formatter = self._get_pygments_formatter() - if pygments_formatter is None: - return source - - from pygments import highlight - highlighted: str = highlight(source, pygments_lexer, pygments_formatter) + highlighted: str = pygments.highlight( + source, pygments_lexer, pygments_formatter + ) # pygments terminal formatter may add a newline when there wasn't one. # We don't want this, remove. if highlighted[-1] == "\n" and source[-1] != "\n": From cb089b6f64b4ce66051d94e4d5a292c63a8fbf51 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 3 Feb 2025 18:16:00 +0100 Subject: [PATCH 2/2] Also highlight comparisons between strings Fixes #13175 --- changelog/13175.bugfix.rst | 1 + src/_pytest/assertion/util.py | 29 ++++++++++++++++++++++------- testing/test_assertion.py | 10 ++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 changelog/13175.bugfix.rst diff --git a/changelog/13175.bugfix.rst b/changelog/13175.bugfix.rst new file mode 100644 index 00000000000..bdbb72b41e1 --- /dev/null +++ b/changelog/13175.bugfix.rst @@ -0,0 +1 @@ +The diff is now also highlighted correctly when comparing two strings. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 3fe7eb9d862..30aee185d57 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -43,6 +43,14 @@ def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> """Apply highlighting to the given source.""" +def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str: + """Dummy highlighter that returns the text unprocessed. + + Needed for _notin_text, as the diff gets post-processed to only show the "+" part. + """ + return source + + def format_explanation(explanation: str) -> str: r"""Format an explanation. @@ -242,7 +250,7 @@ def _compare_eq_any( ) -> list[str]: explanation = [] if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) + explanation = _diff_text(left, right, highlighter, verbose) else: from _pytest.python_api import ApproxBase @@ -274,7 +282,9 @@ def _compare_eq_any( return explanation -def _diff_text(left: str, right: str, verbose: int = 0) -> list[str]: +def _diff_text( + left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0 +) -> list[str]: """Return the explanation for the diff between text. Unless --verbose is used this will skip leading and trailing @@ -315,10 +325,15 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> list[str]: explanation += ["Strings contain only whitespace, escaping them using repr()"] # "right" is the expected base against which we compare "left", # see https://github.com/pytest-dev/pytest/issues/3333 - explanation += [ - line.strip("\n") - for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) - ] + explanation.extend( + highlighter( + "\n".join( + line.strip("\n") + for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) + ), + lexer="diff", + ).splitlines() + ) return explanation @@ -586,7 +601,7 @@ def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]: head = text[:index] tail = text[index + len(term) :] correct_text = head + tail - diff = _diff_text(text, correct_text, verbose) + diff = _diff_text(text, correct_text, dummy_highlighter, verbose) newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"] for line in diff: if line.startswith("Skipping"): diff --git a/testing/test_assertion.py b/testing/test_assertion.py index a2e2304d342..e3d45478466 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -2019,6 +2019,16 @@ def test(): "{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}", ], ), + ( + """ + def test(): + assert "abcd" == "abce" + """, + [ + "{bold}{red}E {reset}{light-red}- abce{hl-reset}{endline}{reset}", + "{bold}{red}E {light-green}+ abcd{hl-reset}{endline}{reset}", + ], + ), ), ) def test_comparisons_handle_colors(