From 25dee8b6b50912492d08cbfbb09c4ce6606d0069 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:02:09 +0300 Subject: [PATCH 1/5] Add wemake-python-styleguide --- pytest_mypy_plugins/isolation.py | 51 +++++++++ pytest_mypy_plugins/item.py | 18 ++- pytest_mypy_plugins/logic/files.py | 25 ++++ pytest_mypy_plugins/logic/text.py | 77 +++++++++++++ pytest_mypy_plugins/tests/test_utils.py | 2 +- pytest_mypy_plugins/utils.py | 146 +----------------------- 6 files changed, 167 insertions(+), 152 deletions(-) create mode 100644 pytest_mypy_plugins/isolation.py diff --git a/pytest_mypy_plugins/isolation.py b/pytest_mypy_plugins/isolation.py new file mode 100644 index 0000000..e52f521 --- /dev/null +++ b/pytest_mypy_plugins/isolation.py @@ -0,0 +1,51 @@ +import os +import sys +from collections.abc import Iterator +from pathlib import Path + +from decorator import contextmanager + + +@contextmanager +def temp_environ() -> Iterator[None]: + """Allow the ability to set os.environ temporarily""" + environ = dict(os.environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(environ) + + +@contextmanager +def temp_path() -> Iterator[None]: + """A context manager which allows the ability to set sys.path temporarily""" + path = sys.path[:] + try: + yield + finally: + sys.path = path[:] + + +@contextmanager +def temp_sys_modules() -> Iterator[None]: + sys_modules = sys.modules.copy() + try: + yield + finally: + sys.modules = sys_modules.copy() + + +@contextmanager +def cd(path: str | Path) -> Iterator[None]: + """Context manager to temporarily change working directories""" + if not path: + return + prev_cwd = Path.cwd().as_posix() + if isinstance(path, Path): + path = path.as_posix() + os.chdir(str(path)) + try: + yield + finally: + os.chdir(prev_cwd) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 1e3046a..58749c5 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -25,7 +25,7 @@ SqliteMetadataStore, ) -from pytest_mypy_plugins import configs, utils +from pytest_mypy_plugins import configs, isolation, utils from pytest_mypy_plugins.collect import YamlTestFile from pytest_mypy_plugins.constants import COLON from pytest_mypy_plugins.logic import files @@ -178,7 +178,11 @@ def _typecheck_in_same_process( self, mypy_cmd_options: list[Any] ) -> tuple[int, tuple[str, str]]: return_code = -1 - with utils.temp_environ(), utils.temp_path(), utils.temp_sys_modules(): + with ( + isolation.temp_environ(), + isolation.temp_path(), + isolation.temp_sys_modules(), + ): # add custom environment variables for key, val in self.environment_variables.items(): os.environ[key] = val @@ -302,19 +306,13 @@ def run(self) -> None: # make files for test_file in self.files: - self._make_test_file(test_file) + files.make_test_file(test_file) returncode, (stdout, stderr) = self.mypy_executor.execute( mypy_cmd_options ) self.output_checker.check(returncode, stdout, stderr) - def _make_test_file(self, test_file: File) -> None: - current_directory = Path.cwd() - fpath = current_directory / test_file.path - fpath.parent.mkdir(parents=True, exist_ok=True) - fpath.write_text(test_file.content) - def _prepare_mypy_cmd_options(self) -> list[str]: mypy_cmd_options = [ "--show-traceback", @@ -499,7 +497,7 @@ def runtest(self) -> None: self.execute_extension_hook() execution_path = Path(temp_dir.name).absolute() - with utils.cd(execution_path): + with isolation.cd(execution_path): mypy_executor = MypyExecutor( same_process=self.same_process, execution_path=execution_path, diff --git a/pytest_mypy_plugins/logic/files.py b/pytest_mypy_plugins/logic/files.py index 7635275..bb4bee3 100644 --- a/pytest_mypy_plugins/logic/files.py +++ b/pytest_mypy_plugins/logic/files.py @@ -1,7 +1,11 @@ import os from pathlib import Path +from typing import Final from pytest_mypy_plugins.constants import COLON +from pytest_mypy_plugins.types import File + +_MAX_LINE_SPLIT_PARTS: Final = 3 def replace_fpath_with_module_name(line: str, rootdir: Path) -> str: @@ -33,3 +37,24 @@ def fname_to_module(fpath: Path, root_path: Path) -> str | None: return str(relpath).replace(os.sep, ".") except ValueError: return None + + +def make_test_file(test_file: File) -> None: + current_directory = Path.cwd() + fpath = current_directory / test_file.path + fpath.parent.mkdir(parents=True, exist_ok=True) + fpath.write_text(test_file.content) + + +def sorted_by_file_and_line(lines: list[str]) -> list[str]: + def extract_parts_as_tuple(line: str) -> tuple[str, int]: + if len(line.split(":", maxsplit=2)) < _MAX_LINE_SPLIT_PARTS: + return "", 0 + + file_name, line_number, _ = line.split(":", maxsplit=2) + try: + return file_name, int(line_number) + except ValueError: + return "", 0 + + return sorted(lines, key=extract_parts_as_tuple) diff --git a/pytest_mypy_plugins/logic/text.py b/pytest_mypy_plugins/logic/text.py index 6a1bd72..e9526c6 100644 --- a/pytest_mypy_plugins/logic/text.py +++ b/pytest_mypy_plugins/logic/text.py @@ -1,3 +1,14 @@ +import re +from typing import Final + +_ALIGNMENT_MARKER: Final = "^" +_TRUNCATION_MARKER: Final = "..." +_TRUNCATION_STEP: Final = 10 +_TRUNCATION_WINDOW: Final = 30 +_MIN_ALIGNMENT_STRING_LENGTH: Final = 4 +_MAX_ALIGNMENT_WIDTH: Final = 72 + + def format_mismatched_line(line: str) -> str: return f" {str(line):<45} (diff)" @@ -12,3 +23,69 @@ def format_error_lines(lines: list[str]) -> str: def remove_empty_lines(lines: list[str]) -> list[str]: return list(filter(None, lines)) + + +def add_aligned_message(word1: str, word2: str, error_message: str) -> str: + """Align word1 and word2 so that the their first difference is highlighted. + + For example, if word1 is 'foobar' and word2 is 'fobar', display the + following lines: + + E: foobar + A: fobar + ^ + + If word1 and word2 are long, only display a fragment of the strings around the + first difference. If word1 is very short, do nothing. + """ + + # Seeing what went wrong is trivial even without alignment if the expected + # string is very short. In this case do nothing to simplify output. + if len(word1) < _MIN_ALIGNMENT_STRING_LENGTH: + return error_message + + error_message += "Alignment of first line difference:\n" + + assert word1 != word2 + + trunc = False + while word1[:_TRUNCATION_WINDOW] == word2[:_TRUNCATION_WINDOW]: + word1 = word1[_TRUNCATION_STEP:] + word2 = word2[_TRUNCATION_STEP:] + trunc = True + + if trunc: + word1 = _TRUNCATION_MARKER + word1 + word2 = _TRUNCATION_MARKER + word2 + + max_len = max(len(word1), len(word2)) + extra = _TRUNCATION_MARKER if max_len > _MAX_ALIGNMENT_WIDTH else "" + + # Write a chunk of both lines, aligned. + error_message += f" E: {word1[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" + error_message += f" A: {word2[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" + # Write an indicator character under the different columns. + error_message += " " + # sys.stderr.write(' ') + for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): + if word1[pos : pos + 1] != word2[pos : pos + 1]: + error_message += _ALIGNMENT_MARKER + break + else: + error_message += " " + error_message += "\n" + return error_message + + +def remove_common_prefix(lines: list[str]) -> list[str]: + """Remove common directory prefix from all strings in a. + + This uses a naive string replace; it seems to work well enough. Also + remove trailing carriage returns. + """ + cleaned_lines = [] + for line in lines: + # Ignore spaces at end of line. + line = re.sub(" +$", "", line) + cleaned_lines.append(re.sub("\\r$", "", line)) + return cleaned_lines diff --git a/pytest_mypy_plugins/tests/test_utils.py b/pytest_mypy_plugins/tests/test_utils.py index 58555f1..cc9cda0 100644 --- a/pytest_mypy_plugins/tests/test_utils.py +++ b/pytest_mypy_plugins/tests/test_utils.py @@ -4,12 +4,12 @@ import pytest from pytest_mypy_plugins import utils +from pytest_mypy_plugins.logic.files import sorted_by_file_and_line from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, extract_output_matchers_from_comments, - sorted_by_file_and_line, ) diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index bb2de38..15a5319 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -1,13 +1,10 @@ # Borrowed from Pew. # See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82 import inspect -import os import re -import sys -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from itertools import zip_longest -from pathlib import Path from typing import ( Any, Final, @@ -15,9 +12,8 @@ import jinja2 import regex -from decorator import contextmanager -from pytest_mypy_plugins.logic import text +from pytest_mypy_plugins.logic import files, text _rendering_env = jinja2.Environment() @@ -31,49 +27,12 @@ # the first different line has at least this many characters, _MIN_LINE_LENGTH_FOR_ALIGNMENT: Final = 5 -_MIN_ALIGNMENT_STRING_LENGTH: Final = 4 -_MAX_ALIGNMENT_WIDTH: Final = 72 -_MAX_LINE_SPLIT_PARTS: Final = 3 -_ALIGNMENT_MARKER: Final = "^" -_TRUNCATION_MARKER: Final = "..." -_TRUNCATION_STEP: Final = 10 -_TRUNCATION_WINDOW: Final = 30 _MYPY_OUTPUT_LINE_RE: Final = re.compile( r"^(?P.+):(?P\d+): (?P[A-Za-z]+):((?P\d+):)? (?P.*)$" ) -@contextmanager -def temp_environ() -> Iterator[None]: - """Allow the ability to set os.environ temporarily""" - environ = dict(os.environ) - try: - yield - finally: - os.environ.clear() - os.environ.update(environ) - - -@contextmanager -def temp_path() -> Iterator[None]: - """A context manager which allows the ability to set sys.path temporarily""" - path = sys.path[:] - try: - yield - finally: - sys.path = path[:] - - -@contextmanager -def temp_sys_modules() -> Iterator[None]: - sys_modules = sys.modules.copy() - try: - yield - finally: - sys.modules = sys_modules.copy() - - @dataclass class OutputMatcher: file_name: str @@ -124,86 +83,6 @@ def __str__(self) -> str: return self.error_message -def remove_common_prefix(lines: list[str]) -> list[str]: - """Remove common directory prefix from all strings in a. - - This uses a naive string replace; it seems to work well enough. Also - remove trailing carriage returns. - """ - cleaned_lines = [] - for line in lines: - # Ignore spaces at end of line. - line = re.sub(" +$", "", line) - cleaned_lines.append(re.sub("\\r$", "", line)) - return cleaned_lines - - -def _add_aligned_message(word1: str, word2: str, error_message: str) -> str: - """Align word1 and word2 so that the their first difference is highlighted. - - For example, if word1 is 'foobar' and word2 is 'fobar', display the - following lines: - - E: foobar - A: fobar - ^ - - If word1 and word2 are long, only display a fragment of the strings around the - first difference. If word1 is very short, do nothing. - """ - - # Seeing what went wrong is trivial even without alignment if the expected - # string is very short. In this case do nothing to simplify output. - if len(word1) < _MIN_ALIGNMENT_STRING_LENGTH: - return error_message - - error_message += "Alignment of first line difference:\n" - - assert word1 != word2 - - trunc = False - while word1[:_TRUNCATION_WINDOW] == word2[:_TRUNCATION_WINDOW]: - word1 = word1[_TRUNCATION_STEP:] - word2 = word2[_TRUNCATION_STEP:] - trunc = True - - if trunc: - word1 = _TRUNCATION_MARKER + word1 - word2 = _TRUNCATION_MARKER + word2 - - max_len = max(len(word1), len(word2)) - extra = _TRUNCATION_MARKER if max_len > _MAX_ALIGNMENT_WIDTH else "" - - # Write a chunk of both lines, aligned. - error_message += f" E: {word1[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" - error_message += f" A: {word2[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" - # Write an indicator character under the different columns. - error_message += " " - # sys.stderr.write(' ') - for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): - if word1[pos : pos + 1] != word2[pos : pos + 1]: - error_message += _ALIGNMENT_MARKER - break - else: - error_message += " " - error_message += "\n" - return error_message - - -def sorted_by_file_and_line(lines: list[str]) -> list[str]: - def extract_parts_as_tuple(line: str) -> tuple[str, int]: - if len(line.split(":", maxsplit=2)) < _MAX_LINE_SPLIT_PARTS: - return "", 0 - - file_name, line_number, _ = line.split(":", maxsplit=2) - try: - return file_name, int(line_number) - except ValueError: - return "", 0 - - return sorted(lines, key=extract_parts_as_tuple) - - def assert_expected_matched_actual( expected: list[OutputMatcher], actual: list[str] ) -> None: @@ -213,8 +92,8 @@ def assert_expected_matched_actual( """ expected = sorted(expected, key=lambda om: (om.file_name, om.lnum)) - actual = remove_common_prefix( - sorted_by_file_and_line(text.remove_empty_lines(actual)) + actual = text.remove_common_prefix( + files.sorted_by_file_and_line(text.remove_empty_lines(actual)) ) diff_lines: dict[int, tuple[OutputMatcher, str]] = { @@ -289,7 +168,7 @@ def assert_expected_matched_actual( >= _MIN_LINE_LENGTH_FOR_ALIGNMENT ) ): - error_message = _add_aligned_message( + error_message = text.add_aligned_message( str(first_diff_expected), first_diff_actual, error_message ) @@ -388,18 +267,3 @@ def get_func_first_lnum( if f"def {attr.__name__}" in no_space_line: return lnum, lines[lnum + 1 :] raise ValueError(f'No line "def {attr.__name__}" found') - - -@contextmanager -def cd(path: str | Path) -> Iterator[None]: - """Context manager to temporarily change working directories""" - if not path: - return - prev_cwd = Path.cwd().as_posix() - if isinstance(path, Path): - path = path.as_posix() - os.chdir(str(path)) - try: - yield - finally: - os.chdir(prev_cwd) From 129d8e9ca117165b25b79b6bba1909ed7d32235e Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:31:11 +0300 Subject: [PATCH 2/5] small changes --- pytest_mypy_plugins/collect.py | 10 ++++++---- pytest_mypy_plugins/isolation.py | 10 +++++----- pytest_mypy_plugins/logic/files.py | 6 +++--- setup.cfg | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 setup.cfg diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index cd0a176..a79e8c7 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -23,12 +23,14 @@ _STORE_TRUE_ACTION: Final = "store_true" -def validate_schema(data: Any, *, is_closed: bool = False) -> None: +def validate_schema(yml_data: Any, *, is_closed: bool = False) -> None: """Validate the schema of the file-under-test.""" # Unfortunately, yaml.safe_load() returns Any, # so we make our intention explicit here. - if not isinstance(data, list): - raise TypeError(f"Test file has to be YAML list, got {type(data)!r}.") + if not isinstance(yml_data, list): + raise TypeError( + f"Test file has to be YAML list, got {type(yml_data)!r}." + ) schema = json.loads( (pathlib.Path(__file__).parent / "schema.json").read_text("utf8") @@ -39,7 +41,7 @@ def validate_schema(data: Any, *, is_closed: bool = False) -> None: } schema["items"]["additionalProperties"] = not is_closed - jsonschema.validate(instance=data, schema=schema) + jsonschema.validate(instance=yml_data, schema=schema) class SafeLineLoader(yaml.SafeLoader): diff --git a/pytest_mypy_plugins/isolation.py b/pytest_mypy_plugins/isolation.py index e52f521..9bd538c 100644 --- a/pytest_mypy_plugins/isolation.py +++ b/pytest_mypy_plugins/isolation.py @@ -8,7 +8,7 @@ @contextmanager def temp_environ() -> Iterator[None]: - """Allow the ability to set os.environ temporarily""" + """Allow the ability to set ``os.environ`` temporarily.""" environ = dict(os.environ) try: yield @@ -19,12 +19,12 @@ def temp_environ() -> Iterator[None]: @contextmanager def temp_path() -> Iterator[None]: - """A context manager which allows the ability to set sys.path temporarily""" - path = sys.path[:] + """Allow the ability to set ``sys.path`` temporarily.""" + path = sys.path try: yield finally: - sys.path = path[:] + sys.path = path @contextmanager @@ -38,7 +38,7 @@ def temp_sys_modules() -> Iterator[None]: @contextmanager def cd(path: str | Path) -> Iterator[None]: - """Context manager to temporarily change working directories""" + """Context manager to temporarily change working directories.""" if not path: return prev_cwd = Path.cwd().as_posix() diff --git a/pytest_mypy_plugins/logic/files.py b/pytest_mypy_plugins/logic/files.py index bb4bee3..40d5e75 100644 --- a/pytest_mypy_plugins/logic/files.py +++ b/pytest_mypy_plugins/logic/files.py @@ -26,8 +26,8 @@ def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str: def parse_environment_variables(env_vars: list[str]) -> dict[str, str]: parsed_vars: dict[str, str] = {} for env_var in env_vars: - name, _, value = env_var.partition("=") - parsed_vars[name] = value + name, _, value_env = env_var.partition("=") + parsed_vars[name] = value_env return parsed_vars @@ -47,7 +47,7 @@ def make_test_file(test_file: File) -> None: def sorted_by_file_and_line(lines: list[str]) -> list[str]: - def extract_parts_as_tuple(line: str) -> tuple[str, int]: + def extract_parts_as_tuple(line: str) -> tuple[str, int]: # noqa: WPS430 if len(line.split(":", maxsplit=2)) < _MAX_LINE_SPLIT_PARTS: return "", 0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2c3afc2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[flake8] +format = wemake +show-source = true +doctests = true +statistics = false + +# Plugins: +max-complexity = 6 +max-line-length = 80 + +extend-exclude = + .venv + +select = WPS + +per-file-ignores = + # There are multiple assert`s, and subprocesses in tests: + tests/*.py: WPS226 \ No newline at end of file From b352d747b625f63f8c5dd6eaf3be3589cbae9772 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:19:59 +0300 Subject: [PATCH 3/5] Small improvements --- pytest_mypy_plugins/isolation.py | 2 +- pytest_mypy_plugins/logic/parser.py | 55 +++++++++++++++++------------ pytest_mypy_plugins/logic/text.py | 35 +++++++++++------- setup.cfg | 4 ++- 4 files changed, 58 insertions(+), 38 deletions(-) diff --git a/pytest_mypy_plugins/isolation.py b/pytest_mypy_plugins/isolation.py index 9bd538c..d8dbd04 100644 --- a/pytest_mypy_plugins/isolation.py +++ b/pytest_mypy_plugins/isolation.py @@ -20,7 +20,7 @@ def temp_environ() -> Iterator[None]: @contextmanager def temp_path() -> Iterator[None]: """Allow the ability to set ``sys.path`` temporarily.""" - path = sys.path + path = sys.path.copy() try: yield finally: diff --git a/pytest_mypy_plugins/logic/parser.py b/pytest_mypy_plugins/logic/parser.py index 3677386..6db24aa 100644 --- a/pytest_mypy_plugins/logic/parser.py +++ b/pytest_mypy_plugins/logic/parser.py @@ -5,32 +5,41 @@ def parse_parametrized( - params: list[Mapping[str, Any]], + parameters_case: list[Mapping[str, Any]], ) -> list[Mapping[str, Any]]: - if not params: + if not parameters_case: return [{}] - parsed_params: list[Mapping[str, Any]] = [] - expected_keys: set[str] = set(sorted(params[0].keys())) - for idx, param in enumerate(params): - param_keys = set(sorted(param.keys())) - if expected_keys.intersection(param_keys) != expected_keys: - raise ValueError( - "All parametrized entries must have same keys." - f'First entry is {", ".join(expected_keys)} but {", ".join(param_keys)} ' - f"was spotted at {idx} position", - ) - parsed_params.append( - {k: v for k, v in param.items() if not k.startswith("__")} - ) + expected_keys: set[str] = set(sorted(parameters_case[0].keys())) + return [ + _parse_param_case(param_case, idx, expected_keys) + for idx, param_case in enumerate(parameters_case) + ] + - return parsed_params +def _parse_param_case( + param_case: Mapping[str, Any], idx: int, expected_keys: set[str] +) -> Mapping[str, Any]: + param_keys = set(sorted(param_case.keys())) + if expected_keys.intersection(param_keys) != expected_keys: + raise ValueError( + "All parametrized entries must have same keys." # noqa: WPS237 + f'First entry is {", ".join(expected_keys)} but {", ".join(param_keys)} ' + f"was spotted at {idx} position", + ) + return { + param_key: param_value + for param_key, param_value in param_case.items() + if not param_key.startswith("__") + } -def parse_test_files(test_files: list[dict[str, Any]]) -> list[File]: - files: list[File] = [] - for test_file in test_files: - path = test_file.get("path", "main.py") - file = File(path=path, content=test_file.get("content", "")) - files.append(file) - return files +def parse_test_files(file_specs: list[dict[str, Any]]) -> list[File]: + """Convert raw YAML file entries into ``File`` objects.""" + return [ + File( + path=file_info.get("path", "main.py"), + content=file_info.get("content", ""), + ) + for file_info in file_specs + ] diff --git a/pytest_mypy_plugins/logic/text.py b/pytest_mypy_plugins/logic/text.py index e9526c6..5df2e06 100644 --- a/pytest_mypy_plugins/logic/text.py +++ b/pytest_mypy_plugins/logic/text.py @@ -14,7 +14,8 @@ def format_mismatched_line(line: str) -> str: def format_matched_line(line: str, width: int = 100) -> str: - return f" {line[:width]}..." if len(line) > width else f" {line}" + line = line[:width] + return f" {line}..." if len(line) > width else f" {line}" def format_error_lines(lines: list[str]) -> str: @@ -44,7 +45,7 @@ def add_aligned_message(word1: str, word2: str, error_message: str) -> str: if len(word1) < _MIN_ALIGNMENT_STRING_LENGTH: return error_message - error_message += "Alignment of first line difference:\n" + error_message += "Alignment of first line difference:\n" # noqa: WPS336 assert word1 != word2 @@ -60,20 +61,14 @@ def add_aligned_message(word1: str, word2: str, error_message: str) -> str: max_len = max(len(word1), len(word2)) extra = _TRUNCATION_MARKER if max_len > _MAX_ALIGNMENT_WIDTH else "" + word1_format = word1[:_MAX_ALIGNMENT_WIDTH] + extra + word2_format = word2[:_MAX_ALIGNMENT_WIDTH] + extra # Write a chunk of both lines, aligned. - error_message += f" E: {word1[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" - error_message += f" A: {word2[:_MAX_ALIGNMENT_WIDTH]}{extra}\n" + error_message += f" E: {word1_format}\n" # noqa: WPS336 + error_message += f" A: {word2_format}\n" # noqa: WPS336 # Write an indicator character under the different columns. - error_message += " " - # sys.stderr.write(' ') - for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): - if word1[pos : pos + 1] != word2[pos : pos + 1]: - error_message += _ALIGNMENT_MARKER - break - else: - error_message += " " - error_message += "\n" + error_message += _format_alignment_pointer(word1, word2, max_len) return error_message @@ -89,3 +84,17 @@ def remove_common_prefix(lines: list[str]) -> list[str]: line = re.sub(" +$", "", line) cleaned_lines.append(re.sub("\\r$", "", line)) return cleaned_lines + + +def _format_alignment_pointer(word1: str, word2: str, max_len: int) -> str: + error_message = [" "] + for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): + word1 = word1[pos : pos + 1] + word2 = word2[pos : pos + 1] + if word1 == word2: + error_message.append(" ") + else: + error_message.append(_ALIGNMENT_MARKER) + break + error_message.append("\n") + return "".join(error_message) diff --git a/setup.cfg b/setup.cfg index 2c3afc2..575464d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,6 +7,8 @@ statistics = false # Plugins: max-complexity = 6 max-line-length = 80 +max-try-body-length = 2 +max-local-variables = 8 extend-exclude = .venv @@ -15,4 +17,4 @@ select = WPS per-file-ignores = # There are multiple assert`s, and subprocesses in tests: - tests/*.py: WPS226 \ No newline at end of file + tests/*.py: WPS226 From 5d17c786cb6185cafecf870783fe392f761204dd Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:57:57 +0300 Subject: [PATCH 4/5] Refactor project --- pytest_mypy_plugins/item.py | 5 +- pytest_mypy_plugins/logic/text.py | 6 +- pytest_mypy_plugins/tests/test_utils.py | 2 +- pytest_mypy_plugins/types.py | 46 +++++++- pytest_mypy_plugins/utils.py | 146 ++++++++++++------------ 5 files changed, 123 insertions(+), 82 deletions(-) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 58749c5..d9be632 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -25,13 +25,12 @@ SqliteMetadataStore, ) -from pytest_mypy_plugins import configs, isolation, utils +from pytest_mypy_plugins import configs, isolation from pytest_mypy_plugins.collect import YamlTestFile from pytest_mypy_plugins.constants import COLON from pytest_mypy_plugins.logic import files -from pytest_mypy_plugins.types import File, ReturnCodes +from pytest_mypy_plugins.types import File, OutputMatcher, ReturnCodes from pytest_mypy_plugins.utils import ( - OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, ) diff --git a/pytest_mypy_plugins/logic/text.py b/pytest_mypy_plugins/logic/text.py index 5df2e06..0578663 100644 --- a/pytest_mypy_plugins/logic/text.py +++ b/pytest_mypy_plugins/logic/text.py @@ -89,9 +89,9 @@ def remove_common_prefix(lines: list[str]) -> list[str]: def _format_alignment_pointer(word1: str, word2: str, max_len: int) -> str: error_message = [" "] for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): - word1 = word1[pos : pos + 1] - word2 = word2[pos : pos + 1] - if word1 == word2: + word1_pos = word1[pos : pos + 1] + word2_pos = word2[pos : pos + 1] + if word1_pos == word2_pos: error_message.append(" ") else: error_message.append(_ALIGNMENT_MARKER) diff --git a/pytest_mypy_plugins/tests/test_utils.py b/pytest_mypy_plugins/tests/test_utils.py index cc9cda0..b3e2e44 100644 --- a/pytest_mypy_plugins/tests/test_utils.py +++ b/pytest_mypy_plugins/tests/test_utils.py @@ -5,8 +5,8 @@ from pytest_mypy_plugins import utils from pytest_mypy_plugins.logic.files import sorted_by_file_and_line +from pytest_mypy_plugins.types import OutputMatcher from pytest_mypy_plugins.utils import ( - OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, extract_output_matchers_from_comments, diff --git a/pytest_mypy_plugins/types.py b/pytest_mypy_plugins/types.py index 5514ffd..80be618 100644 --- a/pytest_mypy_plugins/types.py +++ b/pytest_mypy_plugins/types.py @@ -1,6 +1,44 @@ from dataclasses import dataclass from enum import IntEnum, unique -from typing import final +from typing import NamedTuple, final + +import regex + + +@dataclass +class OutputMatcher: + file_name: str + lnum: int + severity: str + message: str + regex: bool + col: str | None = None + + def matches(self, actual: str) -> bool: + if self.regex: + pattern = ( + regex.escape( + f"{self.file_name}:{self.lnum}: {self.severity}: " + if self.col is None + else f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: " + ) + + self.message + ) + return bool(regex.match(pattern, actual)) + return str(self) == actual + + def __str__(self) -> str: + if self.col is None: + return ( + f"{self.file_name}:{self.lnum}: {self.severity}: {self.message}" + ) + return f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: {self.message}" + + def __format__(self, format_spec: str) -> str: + return format_spec.format(str(self)) + + def __len__(self) -> int: + return len(str(self)) @dataclass @@ -15,3 +53,9 @@ class ReturnCodes(IntEnum): SUCCESS = 0 FAIL = 1 FATAL_ERROR = 2 + + +class DiffMessageLines(NamedTuple): + expected: list[str] + actual: list[str] + last_expected_line: OutputMatcher | None diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index 15a5319..eb6a5e6 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -3,7 +3,6 @@ import inspect import re from collections.abc import Callable, Mapping -from dataclasses import dataclass from itertools import zip_longest from typing import ( Any, @@ -11,9 +10,9 @@ ) import jinja2 -import regex from pytest_mypy_plugins.logic import files, text +from pytest_mypy_plugins.types import DiffMessageLines, OutputMatcher _rendering_env = jinja2.Environment() @@ -33,42 +32,6 @@ ) -@dataclass -class OutputMatcher: - file_name: str - lnum: int - severity: str - message: str - regex: bool - col: str | None = None - - def matches(self, actual: str) -> bool: - if self.regex: - pattern = ( - regex.escape( - f"{self.file_name}:{self.lnum}: {self.severity}: " - if self.col is None - else f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: " - ) - + self.message - ) - return bool(regex.match(pattern, actual)) - return str(self) == actual - - def __str__(self) -> str: - if self.col is None: - return ( - f"{self.file_name}:{self.lnum}: {self.severity}: {self.message}" - ) - return f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: {self.message}" - - def __format__(self, format_spec: str) -> str: - return format_spec.format(str(self)) - - def __len__(self) -> int: - return len(str(self)) - - class TypecheckAssertionError(AssertionError): def __init__( self, error_message: str | None = None, lineno: int = 0 @@ -96,39 +59,17 @@ def assert_expected_matched_actual( files.sorted_by_file_and_line(text.remove_empty_lines(actual)) ) - diff_lines: dict[int, tuple[OutputMatcher, str]] = { - i: (e, a) - for i, (e, a) in enumerate(zip_longest(expected, actual)) - if e is None or a is None or not e.matches(a) - } + diff_lines: dict[int, tuple[OutputMatcher, str]] = _create_diff_lines( + expected, actual + ) if diff_lines: first_diff_line = min(diff_lines.keys()) last_diff_line = max(diff_lines.keys()) - expected_message_lines = [] - actual_message_lines = [] - - for i in range(first_diff_line, last_diff_line + 1): - if i in diff_lines: - expected_line, actual_line = diff_lines[i] - if expected_line: - expected_message_lines.append( - text.format_mismatched_line(str(expected_line)) - ) - if actual_line: - actual_message_lines.append( - text.format_mismatched_line(actual_line) - ) - - else: - expected_line, actual_line = expected[i], actual[i] - actual_message_lines.append( - text.format_matched_line(actual_line) - ) - expected_message_lines.append( - text.format_matched_line(str(expected_line)) - ) + message_lines = _create_message_lines( + expected, actual, first_diff_line, last_diff_line, diff_lines + ) first_diff_expected, first_diff_actual = diff_lines[first_diff_line] @@ -138,24 +79,27 @@ def assert_expected_matched_actual( else "Invalid output" ) - if actual_message_lines and expected_message_lines: + if message_lines.actual and message_lines.expected: if first_diff_line > 0: - expected_message_lines.insert(0, " ...") - actual_message_lines.insert(0, " ...") + message_lines.expected.insert(0, " ...") + message_lines.actual.insert(0, " ...") if ( last_diff_line < len(actual) - 1 and last_diff_line < len(expected) - 1 ): - expected_message_lines.append(" ...") - actual_message_lines.append(" ...") + message_lines.expected.append(" ...") + message_lines.actual.append(" ...") error_message = "Actual:\n{}\nExpected:\n{}\n".format( - text.format_error_lines(actual_message_lines), - text.format_error_lines(expected_message_lines), + text.format_error_lines(message_lines.actual), + text.format_error_lines(message_lines.expected), ) - if expected_line and expected_line.regex: + if ( + message_lines.last_expected_line + and message_lines.last_expected_line.regex + ): error_message += ( "The actual output does not match the expected regex." ) @@ -267,3 +211,57 @@ def get_func_first_lnum( if f"def {attr.__name__}" in no_space_line: return lnum, lines[lnum + 1 :] raise ValueError(f'No line "def {attr.__name__}" found') + + +def _create_diff_lines( + expected: list[OutputMatcher], actual: list[str] +) -> dict[int, tuple[OutputMatcher, str]]: + diff_lines = {} + for line, (expected_line, actual_line) in enumerate( + zip_longest(expected, actual) + ): + if ( + expected_line is None + or actual_line is None + or not expected_line.matches(actual_line) + ): + diff_lines[line] = (expected_line, actual_line) + + return diff_lines + + +def _create_message_lines( + expected: list[OutputMatcher], + actual: list[str], + first_diff_line: int, + last_diff_line: int, + diff_lines: dict[int, tuple[OutputMatcher, str]], +) -> DiffMessageLines: + expected_message_lines = [] + actual_message_lines = [] + last_expected_line = None + + for line in range(first_diff_line, last_diff_line + 1): + if line in diff_lines: + expected_line, actual_line = diff_lines[line] + if expected_line: + expected_message_lines.append( + text.format_mismatched_line(str(expected_line)) + ) + if actual_line: + actual_message_lines.append( + text.format_mismatched_line(actual_line) + ) + + else: + last_expected_line, actual_line = expected[line], actual[line] + actual_message_lines.append(text.format_matched_line(actual_line)) + expected_message_lines.append( + text.format_matched_line(str(expected_line)) + ) + + return DiffMessageLines( + expected_message_lines, + actual_message_lines, + last_expected_line + ) From 2af208d25f738abe09e5ae305113806280be8f72 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:02:22 +0300 Subject: [PATCH 5/5] Move TypecheckAssertionError to types --- pytest_mypy_plugins/item.py | 8 ++++++-- pytest_mypy_plugins/tests/test_utils.py | 3 +-- pytest_mypy_plugins/types.py | 14 ++++++++++++++ pytest_mypy_plugins/utils.py | 24 ++++++------------------ 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index d9be632..9bd27e7 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -29,9 +29,13 @@ from pytest_mypy_plugins.collect import YamlTestFile from pytest_mypy_plugins.constants import COLON from pytest_mypy_plugins.logic import files -from pytest_mypy_plugins.types import File, OutputMatcher, ReturnCodes -from pytest_mypy_plugins.utils import ( +from pytest_mypy_plugins.types import ( + File, + OutputMatcher, + ReturnCodes, TypecheckAssertionError, +) +from pytest_mypy_plugins.utils import ( assert_expected_matched_actual, ) diff --git a/pytest_mypy_plugins/tests/test_utils.py b/pytest_mypy_plugins/tests/test_utils.py index b3e2e44..62a2edc 100644 --- a/pytest_mypy_plugins/tests/test_utils.py +++ b/pytest_mypy_plugins/tests/test_utils.py @@ -5,9 +5,8 @@ from pytest_mypy_plugins import utils from pytest_mypy_plugins.logic.files import sorted_by_file_and_line -from pytest_mypy_plugins.types import OutputMatcher +from pytest_mypy_plugins.types import OutputMatcher, TypecheckAssertionError from pytest_mypy_plugins.utils import ( - TypecheckAssertionError, assert_expected_matched_actual, extract_output_matchers_from_comments, ) diff --git a/pytest_mypy_plugins/types.py b/pytest_mypy_plugins/types.py index 80be618..d434afd 100644 --- a/pytest_mypy_plugins/types.py +++ b/pytest_mypy_plugins/types.py @@ -41,6 +41,20 @@ def __len__(self) -> int: return len(str(self)) +class TypecheckAssertionError(AssertionError): + def __init__( + self, error_message: str | None = None, lineno: int = 0 + ) -> None: + self.error_message = error_message or "" + self.lineno = lineno + + def first_line(self) -> str: + return self.__class__.__name__ + '(message="Invalid output")' + + def __str__(self) -> str: + return self.error_message + + @dataclass class File: path: str diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index eb6a5e6..1e1ae7c 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -12,7 +12,11 @@ import jinja2 from pytest_mypy_plugins.logic import files, text -from pytest_mypy_plugins.types import DiffMessageLines, OutputMatcher +from pytest_mypy_plugins.types import ( + DiffMessageLines, + OutputMatcher, + TypecheckAssertionError, +) _rendering_env = jinja2.Environment() @@ -32,20 +36,6 @@ ) -class TypecheckAssertionError(AssertionError): - def __init__( - self, error_message: str | None = None, lineno: int = 0 - ) -> None: - self.error_message = error_message or "" - self.lineno = lineno - - def first_line(self) -> str: - return self.__class__.__name__ + '(message="Invalid output")' - - def __str__(self) -> str: - return self.error_message - - def assert_expected_matched_actual( expected: list[OutputMatcher], actual: list[str] ) -> None: @@ -261,7 +251,5 @@ def _create_message_lines( ) return DiffMessageLines( - expected_message_lines, - actual_message_lines, - last_expected_line + expected_message_lines, actual_message_lines, last_expected_line )