From 79c6617cf597c4f1a400a6523abec8c997b23a7c Mon Sep 17 00:00:00 2001 From: Khabib Shakhbanov <136262760+Khabib73@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:01:45 +0300 Subject: [PATCH 01/12] Update utils.py --- pytest_mypy_plugins/utils.py | 82 ++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index db11fd2..b41020d 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -25,6 +25,15 @@ "W": "warning", } +# AssertStringArraysEqual displays special line alignment helper messages if +# 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 = "..." @contextmanager def temp_environ() -> Iterator[None]: @@ -64,11 +73,6 @@ def fname_to_module(fpath: Path, root_path: Path) -> str | None: return None -# AssertStringArraysEqual displays special line alignment helper messages if -# the first different line has at least this many characters, -_MIN_LINE_LENGTH_FOR_ALIGNMENT: Final = 5 - - @dataclass class OutputMatcher: fname: str @@ -89,14 +93,12 @@ def matches(self, actual: str) -> bool: + self.message ) return bool(regex.match(pattern, actual)) - else: - return str(self) == actual + return str(self) == actual def __str__(self) -> str: if self.col is None: return f"{self.fname}:{self.lnum}: {self.severity}: {self.message}" - else: - return f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: {self.message}" + return f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: {self.message}" def __format__(self, format_spec: str) -> str: return format_spec.format(str(self)) @@ -131,55 +133,51 @@ def remove_common_prefix(lines: list[str]) -> list[str]: return cleaned_lines -def _add_aligned_message(s1: str, s2: str, error_message: str) -> str: - """Align s1 and s2 so that the their first difference is highlighted. +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 s1 is 'foobar' and s2 is 'fobar', display the + For example, if word1 is 'foobar' and word2 is 'fobar', display the following lines: E: foobar A: fobar ^ - If s1 and s2 are long, only display a fragment of the strings around the - first difference. If s1 is very short, do nothing. + 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(s1) < 4: + if len(word1) < _MIN_ALIGNMENT_STRING_LENGTH: return error_message - maxw = 72 # Maximum number of characters shown - error_message += "Alignment of first line difference:\n" - assert s1 != s2 + assert word1 != word2 trunc = False - while s1[:30] == s2[:30]: - s1 = s1[10:] - s2 = s2[10:] + while word1[:30] == word2[:30]: + word1 = word1[10:] + word2 = word2[10:] trunc = True if trunc: - s1 = "..." + s1 - s2 = "..." + s2 + word1= _TRUNCATION_MARKER + word1 + word2 = _TRUNCATION_MARKER + word2 - max_len = max(len(s1), len(s2)) - extra = "" - if max_len > maxw: - extra = "..." + 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: {s1[:maxw]}{extra}\n" - error_message += f" A: {s2[:maxw]}{extra}\n" + 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 j in range(min(maxw, max(len(s1), len(s2)))): - if s1[j : j + 1] != s2[j : j + 1]: - error_message += "^" + for j in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): + if word1[j : j + 1] != word2[j : j + 1]: + error_message += _ALIGNMENT_MARKER break else: error_message += " " @@ -188,19 +186,15 @@ def _add_aligned_message(s1: str, s2: str, error_message: str) -> str: def remove_empty_lines(lines: list[str]) -> list[str]: - filtered_lines = [] - for line in lines: - if line: - filtered_lines.append(line) - return filtered_lines + return list(filter(None, lines)) 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)) < 3: + if len(line.split(":", maxsplit=2)) < _MAX_LINE_SPLIT_PARTS: return "", 0 - fname, line_number, _ = line.split(":", maxsplit=2) + file_name, line_number, _ = line.split(":", maxsplit=2) try: return fname, int(line_number) except ValueError: @@ -225,9 +219,7 @@ def format_error_lines(lines: list[str]) -> str: return "\n".join(lines) if lines else " (empty)" expected = sorted(expected, key=lambda om: (om.fname, om.lnum)) - actual = sorted_by_file_and_line(remove_empty_lines(actual)) - - actual = remove_common_prefix(actual) + actual = remove_common_prefix(sorted_by_file_and_line(remove_empty_lines(actual))) diff_lines: dict[int, tuple[OutputMatcher, str]] = { i: (e, a) @@ -290,13 +282,13 @@ def format_error_lines(lines: list[str]) -> str: ) -def extract_output_matchers_from_comments(fname: str, input_lines: list[str], regex: bool) -> list[OutputMatcher]: +def extract_output_matchers_from_comments(file_name: str, input_lines: list[str], regex: bool) -> list[OutputMatcher]: """Transform comments such as '# E: message' or '# E:3: message' in input. The result is a list pf output matchers """ - fname = fname.removesuffix(".py") + file_name = file_name.removesuffix(".py") matchers = [] for index, line in enumerate(input_lines): # The first in the split things isn't a comment @@ -310,7 +302,7 @@ def extract_output_matchers_from_comments(fname: str, input_lines: list[str], re col = match.group("col") matchers.append( OutputMatcher( - fname, + file_name, index + 1, severity, message=match.group("message"), From 3cb29c3ecf5aedf5f95754b73f665c07c15a376a Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:09:19 +0300 Subject: [PATCH 02/12] Small fixes --- pytest_mypy_plugins/collect.py | 2 +- pytest_mypy_plugins/item.py | 20 +++++++++++++------- pytest_mypy_plugins/utils.py | 17 +++++++++-------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index ad7da4d..82b228a 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -75,7 +75,7 @@ def parse_parametrized(params: list[Mapping[str, Any]]) -> list[Mapping[str, Any raise ValueError( "All parametrized entries must have same keys." f'First entry is {", ".join(expected_keys)} but {", ".join(param_keys)} ' - "was spotted at {idx} position", + f"was spotted at {idx} position", ) parsed_params.append({k: v for k, v in param.items() if not k.startswith("__")}) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 5be9e07..5e9a4e7 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -8,8 +8,9 @@ import subprocess import sys import tempfile +from enum import IntEnum, unique from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TextIO +from typing import TYPE_CHECKING, Any, Literal, TextIO, final import pytest from _pytest._code import ExceptionInfo @@ -78,7 +79,9 @@ def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str: return str(rootdir / rel_or_abs) -class ReturnCodes: +@final +@unique +class ReturnCodes(IntEnum): SUCCESS = 0 FAIL = 1 FATAL_ERROR = 2 @@ -415,11 +418,7 @@ def remove_cache_files(self, fpath_no_suffix: Path) -> None: for store in stores: try: - for entry in entries: - try: - store.remove(entry) - except FileNotFoundError: - pass + self._remove_if_exists(store, entries) store.commit() finally: # ``MetadataStore.close`` was added in mypy 1.20; guard for older @@ -533,3 +532,10 @@ def repr_failure( def reportinfo(self) -> tuple[Path | str, int | None, str]: return self.path, None, self.name + + def _remove_if_exists(self, store: MetadataStore, entries: list[str]) -> None: + for entry in entries: + try: + store.remove(entry) + except FileNotFoundError: + pass diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index b41020d..c941f31 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -35,6 +35,7 @@ _ALIGNMENT_MARKER: Final = "^" _TRUNCATION_MARKER: Final = "..." + @contextmanager def temp_environ() -> Iterator[None]: """Allow the ability to set os.environ temporarily""" @@ -75,7 +76,7 @@ def fname_to_module(fpath: Path, root_path: Path) -> str | None: @dataclass class OutputMatcher: - fname: str + file_name: str lnum: int severity: str message: str @@ -86,9 +87,9 @@ def matches(self, actual: str) -> bool: if self.regex: pattern = ( regex.escape( - f"{self.fname}:{self.lnum}: {self.severity}: " + f"{self.file_name}:{self.lnum}: {self.severity}: " if self.col is None - else f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: " + else f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: " ) + self.message ) @@ -97,8 +98,8 @@ def matches(self, actual: str) -> bool: def __str__(self) -> str: if self.col is None: - return f"{self.fname}:{self.lnum}: {self.severity}: {self.message}" - return f"{self.fname}:{self.lnum}:{self.col}: {self.severity}: {self.message}" + 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)) @@ -163,7 +164,7 @@ def _add_aligned_message(word1: str, word2: str, error_message: str) -> str: trunc = True if trunc: - word1= _TRUNCATION_MARKER + word1 + word1 = _TRUNCATION_MARKER + word1 word2 = _TRUNCATION_MARKER + word2 max_len = max(len(word1), len(word2)) @@ -196,7 +197,7 @@ def extract_parts_as_tuple(line: str) -> tuple[str, int]: file_name, line_number, _ = line.split(":", maxsplit=2) try: - return fname, int(line_number) + return file_name, int(line_number) except ValueError: return "", 0 @@ -218,7 +219,7 @@ def format_matched_line(line: str, width: int = 100) -> str: def format_error_lines(lines: list[str]) -> str: return "\n".join(lines) if lines else " (empty)" - expected = sorted(expected, key=lambda om: (om.fname, om.lnum)) + expected = sorted(expected, key=lambda om: (om.file_name, om.lnum)) actual = remove_common_prefix(sorted_by_file_and_line(remove_empty_lines(actual))) diff_lines: dict[int, tuple[OutputMatcher, str]] = { From 12cfa69a7252b2153d9d16a125329a7bac0175e3 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:41:14 +0300 Subject: [PATCH 03/12] Use line-length format --- pyproject.toml | 2 +- pytest_mypy_plugins/collect.py | 84 ++++++++--- pytest_mypy_plugins/configs.py | 51 ++++--- pytest_mypy_plugins/item.py | 137 ++++++++++++++---- .../test_configs/test_join_toml_configs.py | 20 ++- .../tests/test_explicit_configs.py | 8 +- .../tests/test_input_schema.py | 13 +- pytest_mypy_plugins/tests/test_marks.py | 17 ++- pytest_mypy_plugins/tests/test_mypy_cache.py | 78 ++++++++-- .../tests/test_site_packages.py | 6 +- pytest_mypy_plugins/tests/test_utils.py | 47 ++++-- pytest_mypy_plugins/utils.py | 94 ++++++++---- setup.py | 10 +- 13 files changed, 431 insertions(+), 136 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aab129c..e22e20e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ markers = [ ] [tool.black] -line-length = 120 +line-length = 80 target-version = ["py310"] [tool.isort] diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index 82b228a..e026b56 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -36,7 +36,9 @@ def validate_schema(data: Any, *, is_closed: bool = False) -> None: if not isinstance(data, list): raise TypeError(f"Test file has to be YAML list, got {type(data)!r}.") - schema = json.loads((pathlib.Path(__file__).parent / "schema.json").read_text("utf8")) + schema = json.loads( + (pathlib.Path(__file__).parent / "schema.json").read_text("utf8") + ) schema["items"]["properties"]["__line__"] = { "type": "integer", "description": "Line number where the test starts (`pytest-mypy-plugins` internal)", @@ -63,7 +65,9 @@ def parse_environment_variables(env_vars: list[str]) -> dict[str, str]: return parsed_vars -def parse_parametrized(params: list[Mapping[str, Any]]) -> list[Mapping[str, Any]]: +def parse_parametrized( + params: list[Mapping[str, Any]], +) -> list[Mapping[str, Any]]: if not params: return [{}] @@ -77,13 +81,17 @@ def parse_parametrized(params: list[Mapping[str, Any]]) -> list[Mapping[str, Any 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("__")}) + parsed_params.append( + {k: v for k, v in param.items() if not k.startswith("__")} + ) return parsed_params class SafeLineLoader(yaml.SafeLoader): - def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]: + def construct_mapping( + self, node: yaml.MappingNode, deep: bool = False + ) -> dict[Hashable, Any]: mapping = super().construct_mapping(node, deep=deep) # Add 1 so line numbering starts at 1 starting_line = node.start_mark.line + 1 @@ -99,51 +107,75 @@ class YamlTestFile(pytest.File): def collect(self) -> Iterator["YamlTestItem"]: from pytest_mypy_plugins.item import YamlTestItem - parsed_file = yaml.load(stream=self.path.read_text("utf8"), Loader=SafeLineLoader) + parsed_file = yaml.load( + stream=self.path.read_text("utf8"), Loader=SafeLineLoader + ) if parsed_file is None: return - validate_schema(parsed_file, is_closed=self.config.option.mypy_closed_schema) + validate_schema( + parsed_file, is_closed=self.config.option.mypy_closed_schema + ) if not isinstance(parsed_file, list): - raise ValueError(f"Test file has to be YAML list, got {type(parsed_file)!r}.") + raise ValueError( + f"Test file has to be YAML list, got {type(parsed_file)!r}." + ) for raw_test in parsed_file: test_name_prefix = raw_test["case"] if " " in test_name_prefix: - raise ValueError(f"Invalid test name {test_name_prefix!r}, only '[a-zA-Z0-9_]' is allowed.") + raise ValueError( + f"Invalid test name {test_name_prefix!r}, only '[a-zA-Z0-9_]' is allowed." + ) else: - parametrized = parse_parametrized(raw_test.get("parametrized", [])) + parametrized = parse_parametrized( + raw_test.get("parametrized", []) + ) for params in parametrized: if params: - test_name_suffix = ",".join(f"{k}={v}" for k, v in params.items()) + test_name_suffix = ",".join( + f"{k}={v}" for k, v in params.items() + ) test_name_suffix = f"[{test_name_suffix}]" else: test_name_suffix = "" test_name = f"{test_name_prefix}{test_name_suffix}" - main_content = utils.render_template(template=raw_test["main"], data=params) + main_content = utils.render_template( + template=raw_test["main"], data=params + ) main_file = File(path="main.py", content=main_content) - test_files = [main_file] + parse_test_files(raw_test.get("files", [])) + test_files = [main_file] + parse_test_files( + raw_test.get("files", []) + ) expect_fail = raw_test.get("expect_fail", False) regex = raw_test.get("regex", False) expected_output = [] for test_file in test_files: output_lines = utils.extract_output_matchers_from_comments( - test_file.path, test_file.content.split("\n"), regex=regex + test_file.path, + test_file.content.split("\n"), + regex=regex, ) expected_output.extend(output_lines) starting_lineno = raw_test["__line__"] marks = raw_test.get("marks", []) - extra_environment_variables = parse_environment_variables(raw_test.get("env", [])) + extra_environment_variables = parse_environment_variables( + raw_test.get("env", []) + ) disable_cache = raw_test.get("disable_cache", False) expected_output.extend( - utils.extract_output_matchers_from_out(raw_test.get("out", ""), params, regex=regex) + utils.extract_output_matchers_from_out( + raw_test.get("out", ""), params, regex=regex + ) + ) + additional_mypy_config = utils.render_template( + template=raw_test.get("mypy_config", ""), data=params ) - additional_mypy_config = utils.render_template(template=raw_test.get("mypy_config", ""), data=params) skip = self._eval_skip(str(raw_test.get("skip", "False"))) if not skip: @@ -162,11 +194,20 @@ def collect(self) -> Iterator["YamlTestItem"]: ) def _eval_skip(self, skip_if: str) -> bool: - return bool(eval(skip_if, {"sys": sys, "os": os, "pytest": pytest, "platform": platform})) + return bool( + eval( + skip_if, + {"sys": sys, "os": os, "pytest": pytest, "platform": platform}, + ) + ) -def pytest_collect_file(file_path: pathlib.Path, parent: Node) -> YamlTestFile | None: - if file_path.suffix in {".yaml", ".yml"} and file_path.name.startswith(("test-", "test_")): +def pytest_collect_file( + file_path: pathlib.Path, parent: Node +) -> YamlTestFile | None: + if file_path.suffix in {".yaml", ".yml"} and file_path.name.startswith( + ("test-", "test_") + ): return YamlTestFile.from_parent(parent, path=file_path) return None @@ -174,7 +215,10 @@ def pytest_collect_file(file_path: pathlib.Path, parent: Node) -> YamlTestFile | def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("mypy-tests") group.addoption( - "--mypy-testing-base", type=str, default=tempfile.gettempdir(), help="Base directory for tests to use" + "--mypy-testing-base", + type=str, + default=tempfile.gettempdir(), + help="Base directory for tests to use", ) group.addoption( "--mypy-pyproject-toml-file", diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index f14f6dc..01085e6 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -6,9 +6,15 @@ import tomlkit _TOML_TABLE_NAME: Final = "[tool.mypy]" +_MYPY_TOML_SECTION: Final = "mypy" +_TOOL_TOML_SECTION: Final = "tool" -def join_ini_configs(base_ini_fpath: str | None, additional_mypy_config: str, execution_path: Path) -> str | None: +def join_ini_configs( + base_ini_fpath: str | None, + additional_mypy_config: str, + execution_path: Path, +) -> str | None: mypy_ini_config = ConfigParser() if base_ini_fpath: mypy_ini_config.read(base_ini_fpath) @@ -19,46 +25,57 @@ def join_ini_configs(base_ini_fpath: str | None, additional_mypy_config: str, ex if mypy_ini_config.sections(): mypy_config_file_path = execution_path / "mypy.ini" - with mypy_config_file_path.open("w") as f: - mypy_ini_config.write(f) + with mypy_config_file_path.open("w") as mypy_file_config: + mypy_ini_config.write(mypy_file_config) return str(mypy_config_file_path) return None -def join_toml_configs(base_pyproject_toml_fpath: str, additional_mypy_config: str, execution_path: Path) -> str | None: +def join_toml_configs( + base_pyproject_toml_fpath: str, + additional_mypy_config: str, + execution_path: Path, +) -> str | None: if base_pyproject_toml_fpath: - with open(base_pyproject_toml_fpath) as f: - toml_config = tomlkit.parse(f.read()) + with open(base_pyproject_toml_fpath) as file_config: + toml_config = tomlkit.parse(file_config.read()) else: # Empty document with `[tool.mypy]` empty table, # useful for overrides further. toml_config = tomlkit.document() - if "tool" not in toml_config or "mypy" not in toml_config["tool"]: + if ( + _TOOL_TOML_SECTION not in toml_config + or _MYPY_TOML_SECTION not in toml_config[_TOOL_TOML_SECTION] + ): tool = tomlkit.table(is_super_table=True) - tool.append("mypy", tomlkit.table()) - toml_config.append("tool", tool) + tool.append(_MYPY_TOML_SECTION, tomlkit.table()) + toml_config.append(_TOOL_TOML_SECTION, tool) if additional_mypy_config: if _TOML_TABLE_NAME not in additional_mypy_config: - additional_mypy_config = f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}" + additional_mypy_config = ( + f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}" + ) additional_data = tomlkit.parse(additional_mypy_config) - toml_config["tool"]["mypy"].update( - additional_data["tool"]["mypy"].value.items(), + toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION].update( + additional_data[_TOOL_TOML_SECTION][ + _MYPY_TOML_SECTION + ].value.items(), ) mypy_config_file_path = execution_path / "pyproject.toml" - with mypy_config_file_path.open("w") as f: + with mypy_config_file_path.open("w") as file_config: # We don't want the whole config file, because it can contain # other sections like `[tool.isort]`, we only need `[tool.mypy]` part. - tool_mypy = toml_config["tool"]["mypy"] + tool_mypy = toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION] # construct toml output min_toml = tomlkit.document() min_tool = tomlkit.table(is_super_table=True) - min_toml.append("tool", min_tool) - min_tool.append("mypy", tool_mypy) + min_toml.append(_TOOL_TOML_SECTION, min_tool) + min_tool.append(_MYPY_TOML_SECTION, tool_mypy) - f.write(min_toml.as_string()) + file_config.write(min_toml.as_string()) return str(mypy_config_file_path) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 5e9a4e7..0563c91 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -20,7 +20,11 @@ from mypy import build from mypy.fscache import FileSystemCache from mypy.main import process_options -from mypy.metastore import FilesystemMetadataStore, MetadataStore, SqliteMetadataStore +from mypy.metastore import ( + FilesystemMetadataStore, + MetadataStore, + SqliteMetadataStore, +) from pytest_mypy_plugins import configs, utils from pytest_mypy_plugins.collect import File, YamlTestFile @@ -36,7 +40,9 @@ # to assert what version you have using static conditions, so it has to be # manually re-defined here. Once minimum supported pytest version is >= 8.3.0, # the following can be replaced with `from _pytest._code.code import TracebackStyle` - TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"] + TracebackStyle = Literal[ + "long", "short", "line", "no", "native", "value", "auto" + ] class TraceLastReprEntry(ReprEntry): @@ -87,7 +93,9 @@ class ReturnCodes(IntEnum): FATAL_ERROR = 2 -def run_mypy_typechecking(cmd_options: list[str], stdout: TextIO, stderr: TextIO) -> int: +def run_mypy_typechecking( + cmd_options: list[str], stdout: TextIO, stderr: TextIO +) -> int: fscache = FileSystemCache() sources, options = process_options(cmd_options, fscache=fscache) @@ -108,7 +116,14 @@ def flush_errors(*args: Any) -> None: sys.exit(ReturnCodes.FATAL_ERROR) try: - build.build(sources, options, flush_errors=flush_errors, fscache=fscache, stdout=stdout, stderr=stderr) + build.build( + sources, + options, + flush_errors=flush_errors, + fscache=fscache, + stdout=stdout, + stderr=stderr, + ) except SystemExit as sysexit: # The code to a SystemExit is optional @@ -148,14 +163,18 @@ def __init__( self.environment_variables = environment_variables self.modify_pythonpath = modify_pythonpath - def execute(self, mypy_cmd_options: list[str]) -> tuple[int, tuple[str, str]]: + def execute( + self, mypy_cmd_options: list[str] + ) -> tuple[int, tuple[str, str]]: # Returns (returncode, (stdout, stderr)) if self.same_process: return self._typecheck_in_same_process(mypy_cmd_options) else: return self._typecheck_in_new_subprocess(mypy_cmd_options) - def _typecheck_in_new_subprocess(self, mypy_cmd_options: list[Any]) -> tuple[int, tuple[str, str]]: + def _typecheck_in_new_subprocess( + self, mypy_cmd_options: list[Any] + ) -> tuple[int, tuple[str, str]]: if self.modify_pythonpath: # add current directory to path self._collect_python_path(self.rootdir) @@ -177,7 +196,9 @@ def _typecheck_in_new_subprocess(self, mypy_cmd_options: list[Any]) -> tuple[int captured_stderr = completed.stderr.decode() return completed.returncode, (captured_stdout, captured_stderr) - def _typecheck_in_same_process(self, mypy_cmd_options: list[Any]) -> tuple[int, tuple[str, str]]: + 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(): # add custom environment variables @@ -191,7 +212,9 @@ def _typecheck_in_same_process(self, mypy_cmd_options: list[Any]) -> tuple[int, stderr = io.StringIO() with stdout, stderr: - return_code = run_mypy_typechecking(mypy_cmd_options, stdout=stdout, stderr=stderr) + return_code = run_mypy_typechecking( + mypy_cmd_options, stdout=stdout, stderr=stderr + ) stdout_value = stdout.getvalue() stderr_value = stderr.getvalue() @@ -228,7 +251,12 @@ def _collect_mypy_path(self, rootdir: Path | None) -> None: class OutputChecker: - def __init__(self, expect_fail: bool, execution_path: Path, expected_output: list[OutputMatcher]) -> None: + def __init__( + self, + expect_fail: bool, + execution_path: Path, + expected_output: list[OutputMatcher], + ) -> None: self.expect_fail = expect_fail self.execution_path = execution_path self.expected_output = expected_output @@ -237,20 +265,28 @@ def check(self, ret_code: int, stdout: str, stderr: str) -> None: mypy_output = stdout + stderr if ret_code == ReturnCodes.FATAL_ERROR: print(mypy_output, file=sys.stderr) - raise TypecheckAssertionError(error_message="Critical error occurred") + raise TypecheckAssertionError( + error_message="Critical error occurred" + ) output_lines = [] for line in mypy_output.splitlines(): - output_line = replace_fpath_with_module_name(line, rootdir=self.execution_path) + output_line = replace_fpath_with_module_name( + line, rootdir=self.execution_path + ) output_lines.append(output_line) try: - assert_expected_matched_actual(expected=self.expected_output, actual=output_lines) + assert_expected_matched_actual( + expected=self.expected_output, actual=output_lines + ) except TypecheckAssertionError as e: if not self.expect_fail: raise e else: if self.expect_fail: - raise TypecheckAssertionError("Expected failure, but test passed") + raise TypecheckAssertionError( + "Expected failure, but test passed" + ) class Runner: @@ -286,7 +322,9 @@ def run(self) -> None: for file in self.files: self._make_test_file(file) - returncode, (stdout, stderr) = self.mypy_executor.execute(mypy_cmd_options) + returncode, (stdout, stderr) = self.mypy_executor.execute( + mypy_cmd_options + ) self.output_checker.check(returncode, stdout, stderr) def _make_test_file(self, file: File) -> None: @@ -343,29 +381,44 @@ def __init__( self.parsed_test_data = parsed_test_data self.same_process = self.config.option.mypy_same_process self.modify_pythonpath = self.config.option.mypy_modify_pythonpath - self.no_silence_site_packages = self.config.option.mypy_no_silence_site_packages + self.no_silence_site_packages = ( + self.config.option.mypy_no_silence_site_packages + ) # config parameters self.root_directory = self.config.option.mypy_testing_base # You cannot use both `.ini` and `pyproject.toml` files at the same time: - if self.config.option.mypy_ini_file and self.config.option.mypy_pyproject_toml_file: - raise ValueError("Cannot specify both `--mypy-ini-file` and `--mypy-pyproject-toml-file`") + if ( + self.config.option.mypy_ini_file + and self.config.option.mypy_pyproject_toml_file + ): + raise ValueError( + "Cannot specify both `--mypy-ini-file` and `--mypy-pyproject-toml-file`" + ) if self.config.option.mypy_ini_file: - self.base_ini_fpath = os.path.abspath(self.config.option.mypy_ini_file) + self.base_ini_fpath = os.path.abspath( + self.config.option.mypy_ini_file + ) else: self.base_ini_fpath = None if self.config.option.mypy_pyproject_toml_file: - self.base_pyproject_toml_fpath = os.path.abspath(self.config.option.mypy_pyproject_toml_file) + self.base_pyproject_toml_fpath = os.path.abspath( + self.config.option.mypy_pyproject_toml_file + ) else: self.base_pyproject_toml_fpath = None - self.incremental_cache_dir = os.path.join(self.root_directory, ".mypy_cache") + self.incremental_cache_dir = os.path.join( + self.root_directory, ".mypy_cache" + ) # The mypy cache is not safe for concurrent use; # give each `pytest-xdist` worker its own cache directory. if xdist_worker := os.environ.get("PYTEST_XDIST_WORKER"): - self.incremental_cache_dir = os.path.join(self.incremental_cache_dir, xdist_worker) + self.incremental_cache_dir = os.path.join( + self.incremental_cache_dir, xdist_worker + ) def remove_cache_files(self, fpath_no_suffix: Path) -> None: cache_dir = os.path.join( @@ -394,7 +447,9 @@ def remove_cache_files(self, fpath_no_suffix: Path) -> None: if ( i == 0 and part.endswith("-stubs") - and os.path.isdir(os.path.join(cache_dir, part.removesuffix("-stubs"))) + and os.path.isdir( + os.path.join(cache_dir, part.removesuffix("-stubs")) + ) ): part = part.removesuffix("-stubs") accumulated = f"{accumulated}/{part}" if accumulated else part @@ -408,7 +463,9 @@ def remove_cache_files(self, fpath_no_suffix: Path) -> None: if shard_dbs: try: # `num_shards` was added in mypy 2.0. - stores.append(SqliteMetadataStore(cache_dir, num_shards=len(shard_dbs))) + stores.append( + SqliteMetadataStore(cache_dir, num_shards=len(shard_dbs)) + ) except TypeError: # For backward compatibility with `SqliteMetadataStore` with no `num_shards` arg. stores.append(SqliteMetadataStore(cache_dir)) @@ -436,7 +493,9 @@ def execute_extension_hook(self) -> None: def runtest(self) -> None: try: - temp_dir = tempfile.TemporaryDirectory(prefix="pytest-mypy-", dir=self.root_directory) + temp_dir = tempfile.TemporaryDirectory( + prefix="pytest-mypy-", dir=self.root_directory + ) except (FileNotFoundError, PermissionError, NotADirectoryError) as e: raise TypecheckAssertionError( @@ -446,7 +505,9 @@ def runtest(self) -> None: try: mypy_executable = shutil.which("mypy") assert mypy_executable is not None, "mypy executable is not found" - rootdir = getattr(getattr(self.parent, "config", None), "rootdir", None) + rootdir = getattr( + getattr(self.parent, "config", None), "rootdir", None + ) # extension point for derived packages if ( @@ -467,7 +528,9 @@ def runtest(self) -> None: ) output_checker = OutputChecker( - expect_fail=self.expect_fail, execution_path=execution_path, expected_output=self.expected_output + expect_fail=self.expect_fail, + execution_path=execution_path, + expected_output=self.expected_output, ) Runner( @@ -497,17 +560,23 @@ def prepare_config_file(self, execution_path: Path) -> str | None: # into one file and copy to the typechecking folder: if self.base_pyproject_toml_fpath: return configs.join_toml_configs( - self.base_pyproject_toml_fpath, self.additional_mypy_config, execution_path + self.base_pyproject_toml_fpath, + self.additional_mypy_config, + execution_path, ) elif self.base_ini_fpath or self.additional_mypy_config: # We might have `self.base_ini_fpath` set as well. # Or this might be a legacy case: only `mypy_config:` is set in the `yaml` test case. # This means that no real file is provided. - return configs.join_ini_configs(self.base_ini_fpath, self.additional_mypy_config, execution_path) + return configs.join_ini_configs( + self.base_ini_fpath, self.additional_mypy_config, execution_path + ) return None def repr_failure( - self, excinfo: ExceptionInfo[BaseException], style: TracebackStyle | None = None + self, + excinfo: ExceptionInfo[BaseException], + style: TracebackStyle | None = None, ) -> str | TerminalRepr: if excinfo.errisinstance(SystemExit): # We assume that before doing exit() (which raises SystemExit) we've printed @@ -523,7 +592,11 @@ def repr_failure( path=self.fspath, lineno=self.starting_lineno + excinfo.value.lineno, message="" # type: ignore ) repr_tb_entry = ReprEntry( - exception_repr.reprtraceback.reprentries[-1].lines[1:], None, None, repr_file_location, "short" + exception_repr.reprtraceback.reprentries[-1].lines[1:], + None, + None, + repr_file_location, + "short", ) exception_repr.reprtraceback.reprentries = [repr_tb_entry] return exception_repr @@ -533,7 +606,9 @@ def repr_failure( def reportinfo(self) -> tuple[Path | str, int | None, str]: return self.path, None, self.name - def _remove_if_exists(self, store: MetadataStore, entries: list[str]) -> None: + def _remove_if_exists( + self, store: MetadataStore, entries: list[str] + ) -> None: for entry in entries: try: store.remove(entry) diff --git a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py index 7191d79..54d204b 100644 --- a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py +++ b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py @@ -54,7 +54,9 @@ def factory(filename: Optional[str], expected: str) -> None: ], ) def test_join_existing_config( - execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str + execution_path: Path, + assert_file_contents: _AssertFileContents, + additional_config: str, ) -> None: filepath = join_toml_configs(_PYPROJECT1, additional_config, execution_path) @@ -78,7 +80,9 @@ def test_join_existing_config( ], ) def test_join_missing_config( - execution_path: Path, assert_file_contents: _AssertFileContents, additional_config: str + execution_path: Path, + assert_file_contents: _AssertFileContents, + additional_config: str, ) -> None: filepath = join_toml_configs(_PYPROJECT2, additional_config, execution_path) @@ -93,7 +97,9 @@ def test_join_missing_config( ) -def test_join_missing_config1(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: +def test_join_missing_config1( + execution_path: Path, assert_file_contents: _AssertFileContents +) -> None: filepath = join_toml_configs(_PYPROJECT1, "", execution_path) assert_file_contents( @@ -107,7 +113,9 @@ def test_join_missing_config1(execution_path: Path, assert_file_contents: _Asser ) -def test_join_missing_config2(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: +def test_join_missing_config2( + execution_path: Path, assert_file_contents: _AssertFileContents +) -> None: filepath = join_toml_configs(_PYPROJECT2, "", execution_path) assert_file_contents( @@ -116,7 +124,9 @@ def test_join_missing_config2(execution_path: Path, assert_file_contents: _Asser ) -def test_join_missing_config3(execution_path: Path, assert_file_contents: _AssertFileContents) -> None: +def test_join_missing_config3( + execution_path: Path, assert_file_contents: _AssertFileContents +) -> None: filepath = join_toml_configs(_PYPROJECT3, "", execution_path) assert_file_contents( diff --git a/pytest_mypy_plugins/tests/test_explicit_configs.py b/pytest_mypy_plugins/tests/test_explicit_configs.py index 4e5cafd..46640a0 100644 --- a/pytest_mypy_plugins/tests/test_explicit_configs.py +++ b/pytest_mypy_plugins/tests/test_explicit_configs.py @@ -4,8 +4,12 @@ import pytest -_PYPROJECT1: Final = str(Path(__file__).parent / "test_configs" / "pyproject1.toml") -_PYPROJECT2: Final = str(Path(__file__).parent / "test_configs" / "pyproject2.toml") +_PYPROJECT1: Final = str( + Path(__file__).parent / "test_configs" / "pyproject1.toml" +) +_PYPROJECT2: Final = str( + Path(__file__).parent / "test_configs" / "pyproject2.toml" +) _MYPYINI1: Final = str(Path(__file__).parent / "test_configs" / "mypy1.ini") _MYPYINI2: Final = str(Path(__file__).parent / "test_configs" / "mypy2.ini") _SETUPCFG1: Final = str(Path(__file__).parent / "test_configs" / "setup1.cfg") diff --git a/pytest_mypy_plugins/tests/test_input_schema.py b/pytest_mypy_plugins/tests/test_input_schema.py index d2aa2d0..325b1d7 100644 --- a/pytest_mypy_plugins/tests/test_input_schema.py +++ b/pytest_mypy_plugins/tests/test_input_schema.py @@ -32,13 +32,17 @@ def test_mypy_config_is_not_an_object() -> None: { "case": "mypy_config_is_not_an_object", "main": "False", - "mypy_config": [{"force_uppercase_builtins": True}, {"force_union_syntax": True}], + "mypy_config": [ + {"force_uppercase_builtins": True}, + {"force_union_syntax": True}, + ], } ] ) assert ( - ex.value.message == "[{'force_uppercase_builtins': True}, {'force_union_syntax': True}] is not of type 'string'" + ex.value.message + == "[{'force_uppercase_builtins': True}, {'force_union_syntax': True}] is not of type 'string'" ) @@ -68,4 +72,7 @@ def test_closed_schema() -> None: is_closed=True, ) - assert ex.value.message == "Additional properties are not allowed ('extra_field' was unexpected)" + assert ( + ex.value.message + == "Additional properties are not allowed ('extra_field' was unexpected)" + ) diff --git a/pytest_mypy_plugins/tests/test_marks.py b/pytest_mypy_plugins/tests/test_marks.py index b2e1a87..5d5f7e9 100644 --- a/pytest_mypy_plugins/tests/test_marks.py +++ b/pytest_mypy_plugins/tests/test_marks.py @@ -26,10 +26,19 @@ def test_marks_filter_selects_only_marked_cases(tmp_path: Path) -> None: """, ) # Register the marker so no `PytestUnknownMarkWarning` is emitted. - (tmp_path / "pytest.ini").write_text("[pytest]\nmarkers =\n slow: slow tests\n") + (tmp_path / "pytest.ini").write_text( + "[pytest]\nmarkers =\n slow: slow tests\n" + ) res = subprocess.run( - ["pytest", "-m", "slow", "--co", "-q", f"--mypy-testing-base={tmp_path}"], + [ + "pytest", + "-m", + "slow", + "--co", + "-q", + f"--mypy-testing-base={tmp_path}", + ], cwd=tmp_path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -44,7 +53,9 @@ def test_marks_filter_selects_only_marked_cases(tmp_path: Path) -> None: assert "1 deselected" in res.stdout -def make_yaml_test_file(root_dir: Path, contents: str, /, *, file_base_name: str = "test-case") -> str: +def make_yaml_test_file( + root_dir: Path, contents: str, /, *, file_base_name: str = "test-case" +) -> str: path = root_dir / f"{file_base_name}.yml" path.write_text(contents) return str(path) diff --git a/pytest_mypy_plugins/tests/test_mypy_cache.py b/pytest_mypy_plugins/tests/test_mypy_cache.py index 3f1af5c..55e8599 100644 --- a/pytest_mypy_plugins/tests/test_mypy_cache.py +++ b/pytest_mypy_plugins/tests/test_mypy_cache.py @@ -7,7 +7,11 @@ from tempfile import TemporaryDirectory import pytest -from mypy.metastore import FilesystemMetadataStore, MetadataStore, SqliteMetadataStore +from mypy.metastore import ( + FilesystemMetadataStore, + MetadataStore, + SqliteMetadataStore, +) def test_non_test_case_files_remain(temp_dir: Path) -> None: @@ -17,7 +21,9 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: Assumes that cache files will be generated for typeshed's ``builtins.pyi`` when running any test case. """ - cache_dir = temp_dir / ".mypy_cache" / f"{version_info[0]}.{version_info[1]}" + cache_dir = ( + temp_dir / ".mypy_cache" / f"{version_info[0]}.{version_info[1]}" + ) # pytest and mypy hasn't been run yet, so no cache should exist assert not cache_dir.is_dir() @@ -29,7 +35,10 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: """, ) res = subprocess.run( - ["pytest", f"--mypy-testing-base={temp_dir}"], cwd=temp_dir, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ["pytest", f"--mypy-testing-base={temp_dir}"], + cwd=temp_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, ) # pytest was invoked correctly and there are no failures unrelated to tests @@ -61,7 +70,12 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: - path: my_pkg/my_subpkg/__init__.py - path: my_pkg/my_subpkg/my_mod.py """, - ("my_pkg/__init__", "my_pkg/my_mod", "my_pkg/my_subpkg/__init__", "my_pkg/my_subpkg/my_mod"), + ( + "my_pkg/__init__", + "my_pkg/my_mod", + "my_pkg/my_subpkg/__init__", + "my_pkg/my_subpkg/my_mod", + ), ), "namespace_package": ( """ @@ -73,7 +87,12 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: - path: my_nspkg/my_mod.py - path: my_nspkg/my_nssubpkg/my_mod.py """, - ("my_nspkg", "my_nspkg/my_mod", "my_nspkg/my_nssubpkg", "my_nspkg/my_nssubpkg/my_mod"), + ( + "my_nspkg", + "my_nspkg/my_mod", + "my_nspkg/my_nssubpkg", + "my_nspkg/my_nssubpkg/my_mod", + ), ), "standard_stub_package": ( """ @@ -87,7 +106,12 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: - path: my_pkg-stubs/my_subpkg/__init__.pyi - path: my_pkg-stubs/my_subpkg/my_mod.pyi """, - ("my_pkg/__init__", "my_pkg/my_mod", "my_pkg/my_subpkg/__init__", "my_pkg/my_subpkg/my_mod"), + ( + "my_pkg/__init__", + "my_pkg/my_mod", + "my_pkg/my_subpkg/__init__", + "my_pkg/my_subpkg/my_mod", + ), ), "namespace_stub_package": ( """ @@ -99,7 +123,12 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: - path: my_nspkg-stubs/my_mod.pyi - path: my_nspkg-stubs/my_nssubpkg/my_mod.pyi """, - ("my_nspkg", "my_nspkg/my_mod", "my_nspkg/my_nssubpkg", "my_nspkg/my_nssubpkg/my_mod"), + ( + "my_nspkg", + "my_nspkg/my_mod", + "my_nspkg/my_nssubpkg", + "my_nspkg/my_nssubpkg/my_mod", + ), ), "mixed_modules_and_packages": ( """ @@ -152,7 +181,9 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: pytest.param("", id="default"), pytest.param("sqlite_cache = False\n", id="no-sqlite"), pytest.param("fixed_format_cache = False\n", id="no-fixed-format"), - pytest.param("sqlite_cache = False\nfixed_format_cache = False\n", id="legacy"), + pytest.param( + "sqlite_cache = False\nfixed_format_cache = False\n", id="legacy" + ), ], ) @pytest.mark.parametrize( @@ -161,22 +192,32 @@ def test_non_test_case_files_remain(temp_dir: Path) -> None: ids=CACHE_FILES_REMOVED_TEST_DATA.keys(), ) def test_cache_files_removed( - temp_dir: Path, contents: str, module_fpaths_no_suffix: tuple[str, ...], mypy_cache_config: str + temp_dir: Path, + contents: str, + module_fpaths_no_suffix: tuple[str, ...], + mypy_cache_config: str, ) -> None: """Tests that cache files associated with ``files`` in a test case get removed""" - cache_dir = temp_dir / ".mypy_cache" / f"{version_info[0]}.{version_info[1]}" + cache_dir = ( + temp_dir / ".mypy_cache" / f"{version_info[0]}.{version_info[1]}" + ) # pytest and mypy hasn't been run yet, so no cache should exist assert not cache_dir.is_dir() if mypy_cache_config: config_lines = mypy_cache_config.strip().splitlines() indented = "\n".join(f" {line}" for line in config_lines) - contents = contents.replace(" main: |", f" mypy_config: |\n{indented}\n main: |") + contents = contents.replace( + " main: |", f" mypy_config: |\n{indented}\n main: |" + ) make_yaml_test_file(temp_dir, contents) res = subprocess.run( - ["pytest", f"--mypy-testing-base={temp_dir}"], cwd=temp_dir, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ["pytest", f"--mypy-testing-base={temp_dir}"], + cwd=temp_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, ) # pytest was invoked correctly and there are no failures unrelated to tests @@ -211,7 +252,9 @@ def make_yaml_test_file( return str(path) -def get_created_cache_files(cache_dir: Path, module_rel_paths_no_suffix: tuple[str, ...]) -> list[str]: +def get_created_cache_files( + cache_dir: Path, module_rel_paths_no_suffix: tuple[str, ...] +) -> list[str]: stores: list[MetadataStore] = [] cache_dir_str = str(cache_dir) # mypy >= 2.0 shards the sqlite cache across `cache.{i}.db` files instead of one `cache.db` file @@ -219,7 +262,9 @@ def get_created_cache_files(cache_dir: Path, module_rel_paths_no_suffix: tuple[s if shard_dbs: try: # `num_shards` was added in mypy 2.0. - stores.append(SqliteMetadataStore(cache_dir_str, num_shards=len(shard_dbs))) + stores.append( + SqliteMetadataStore(cache_dir_str, num_shards=len(shard_dbs)) + ) except TypeError: stores.append(SqliteMetadataStore(cache_dir_str)) elif (cache_dir / "cache.db").is_file(): @@ -231,7 +276,10 @@ def get_created_cache_files(cache_dir: Path, module_rel_paths_no_suffix: tuple[s for store in stores: try: for entry in store.list_all(): - if any(os.path.normpath(entry).startswith(rel_path + ".") for rel_path in module_rel_paths_no_suffix): + if any( + os.path.normpath(entry).startswith(rel_path + ".") + for rel_path in module_rel_paths_no_suffix + ): created.append(entry) finally: # See PR #188: mypy < 1.20 does not have MetadataStore.close diff --git a/pytest_mypy_plugins/tests/test_site_packages.py b/pytest_mypy_plugins/tests/test_site_packages.py index 817e32b..293d69a 100644 --- a/pytest_mypy_plugins/tests/test_site_packages.py +++ b/pytest_mypy_plugins/tests/test_site_packages.py @@ -156,7 +156,11 @@ def make_yaml_test_file( ) -> None: output_path = root_dir.joinpath(file_base_name).with_suffix(".yml") site_packages_path: Path | str = next( - (Path(p) for p in site.getsitepackages() if (Path(p) / "mypy").exists()), + ( + Path(p) + for p in site.getsitepackages() + if (Path(p) / "mypy").exists() + ), Path(site.getsitepackages()[0]), ) site_packages_path = os.path.relpath(site_packages_path, start=output_path) diff --git a/pytest_mypy_plugins/tests/test_utils.py b/pytest_mypy_plugins/tests/test_utils.py index 5ecfd69..0b48082 100644 --- a/pytest_mypy_plugins/tests/test_utils.py +++ b/pytest_mypy_plugins/tests/test_utils.py @@ -37,7 +37,10 @@ def test_render_template_with_None_value() -> None: '''reveal_type(42) # N: Revealed type is "Literal['foo']?"''', '''reveal_type("foo") # N: Revealed type is "Literal[42]?"''', ], - ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], + [ + '''main:1: note: Revealed type is "Literal[42]?"''', + '''main:2: note: Revealed type is "Literal['foo']?"''', + ], [ """Invalid output: """, """Actual:""", @@ -57,7 +60,10 @@ def test_render_template_with_None_value() -> None: """reveal_type(42)""", '''reveal_type("foo") # N: Revealed type is "Literal['foo']?"''', ], - ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], + [ + '''main:1: note: Revealed type is "Literal[42]?"''', + '''main:2: note: Revealed type is "Literal['foo']?"''', + ], [ """Invalid output: """, """Actual:""", @@ -72,8 +78,14 @@ def test_render_template_with_None_value() -> None: ], ), ExpectMatchedActualTestData( - ['''reveal_type(42) # N: Revealed type is "Literal[42]?"''', """reveal_type("foo")"""], - ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], + [ + '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', + """reveal_type("foo")""", + ], + [ + '''main:1: note: Revealed type is "Literal[42]?"''', + '''main:2: note: Revealed type is "Literal['foo']?"''', + ], [ """Invalid output: """, """Actual:""", @@ -84,7 +96,9 @@ def test_render_template_with_None_value() -> None: ), ExpectMatchedActualTestData( ['''42 + "foo"'''], - ["""main:1: error: Unsupported operand types for + ("int" and "str")"""], + [ + """main:1: error: Unsupported operand types for + ("int" and "str")""" + ], [ """Output is not expected: """, """Actual:""", @@ -109,7 +123,10 @@ def test_render_template_with_None_value() -> None: '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', '''reveal_type("foo") # N: Revealed type is "builtins.int"''', ], - ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], + [ + '''main:1: note: Revealed type is "Literal[42]?"''', + '''main:2: note: Revealed type is "Literal['foo']?"''', + ], [ """Invalid output: """, """Actual:""", @@ -129,7 +146,10 @@ def test_render_template_with_None_value() -> None: '''reveal_type(42) # N: Revealed type is "Literal[42]?"''', '''reveal_type("foo") # N: Revealed type is "builtins.int"''', ], - ['''main:1: note: Revealed type is "Literal[42]?"''', '''main:2: note: Revealed type is "Literal['foo']?"'''], + [ + '''main:1: note: Revealed type is "Literal[42]?"''', + '''main:2: note: Revealed type is "Literal['foo']?"''', + ], [ """Invalid output: """, """Actual:""", @@ -174,11 +194,18 @@ def test_render_template_with_None_value() -> None: ] -@pytest.mark.parametrize("source_lines,actual_lines,expected_message_lines", expect_matched_actual_data) +@pytest.mark.parametrize( + "source_lines,actual_lines,expected_message_lines", + expect_matched_actual_data, +) def test_assert_expected_matched_actual_failures( - source_lines: list[str], actual_lines: list[str], expected_message_lines: list[str] + source_lines: list[str], + actual_lines: list[str], + expected_message_lines: list[str], ) -> None: - expected: list[OutputMatcher] = extract_output_matchers_from_comments("main", source_lines, False) + expected: list[OutputMatcher] = extract_output_matchers_from_comments( + "main", source_lines, False + ) expected_error_message = "\n".join(expected_message_lines) with pytest.raises(TypecheckAssertionError) as e: diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index c941f31..17acad6 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -34,6 +34,8 @@ _MAX_LINE_SPLIT_PARTS: Final = 3 _ALIGNMENT_MARKER: Final = "^" _TRUNCATION_MARKER: Final = "..." +_TRUNCATION_STEP: Final = 10 +_TRUNCATION_WINDOW: Final = 30 @contextmanager @@ -98,7 +100,9 @@ def matches(self, actual: str) -> bool: 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.severity}: {self.message}" + ) return f"{self.file_name}:{self.lnum}:{self.col}: {self.severity}: {self.message}" def __format__(self, format_spec: str) -> str: @@ -109,7 +113,9 @@ def __len__(self) -> int: class TypecheckAssertionError(AssertionError): - def __init__(self, error_message: str | None = None, lineno: int = 0) -> None: + def __init__( + self, error_message: str | None = None, lineno: int = 0 + ) -> None: self.error_message = error_message or "" self.lineno = lineno @@ -158,9 +164,9 @@ def _add_aligned_message(word1: str, word2: str, error_message: str) -> str: assert word1 != word2 trunc = False - while word1[:30] == word2[:30]: - word1 = word1[10:] - word2 = word2[10:] + while word1[:_TRUNCATION_WINDOW] == word2[:_TRUNCATION_WINDOW]: + word1 = word1[_TRUNCATION_STEP:] + word2 = word2[_TRUNCATION_STEP:] trunc = True if trunc: @@ -176,8 +182,8 @@ def _add_aligned_message(word1: str, word2: str, error_message: str) -> str: # Write an indicator character under the different columns. error_message += " " # sys.stderr.write(' ') - for j in range(min(_MAX_ALIGNMENT_WIDTH, max_len)): - if word1[j : j + 1] != word2[j : j + 1]: + 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: @@ -204,7 +210,9 @@ def extract_parts_as_tuple(line: str) -> tuple[str, int]: return sorted(lines, key=extract_parts_as_tuple) -def assert_expected_matched_actual(expected: list[OutputMatcher], actual: list[str]) -> None: +def assert_expected_matched_actual( + expected: list[OutputMatcher], actual: list[str] +) -> None: """Assert that two string arrays are equal. Display any differences in a human-readable form. @@ -220,7 +228,9 @@ def format_error_lines(lines: list[str]) -> str: return "\n".join(lines) if lines else " (empty)" expected = sorted(expected, key=lambda om: (om.file_name, om.lnum)) - actual = remove_common_prefix(sorted_by_file_and_line(remove_empty_lines(actual))) + actual = remove_common_prefix( + sorted_by_file_and_line(remove_empty_lines(actual)) + ) diff_lines: dict[int, tuple[OutputMatcher, str]] = { i: (e, a) @@ -239,43 +249,62 @@ def format_error_lines(lines: list[str]) -> str: if i in diff_lines: expected_line, actual_line = diff_lines[i] if expected_line: - expected_message_lines.append(format_mismatched_line(str(expected_line))) + expected_message_lines.append( + format_mismatched_line(str(expected_line)) + ) if actual_line: - actual_message_lines.append(format_mismatched_line(actual_line)) + actual_message_lines.append( + format_mismatched_line(actual_line) + ) else: expected_line, actual_line = expected[i], actual[i] actual_message_lines.append(format_matched_line(actual_line)) - expected_message_lines.append(format_matched_line(str(expected_line))) + expected_message_lines.append( + format_matched_line(str(expected_line)) + ) first_diff_expected, first_diff_actual = diff_lines[first_diff_line] - failure_reason = "Output is not expected" if actual and not expected else "Invalid output" + failure_reason = ( + "Output is not expected" + if actual and not expected + else "Invalid output" + ) if actual_message_lines and expected_message_lines: if first_diff_line > 0: expected_message_lines.insert(0, " ...") actual_message_lines.insert(0, " ...") - if last_diff_line < len(actual) - 1 and last_diff_line < len(expected) - 1: + if ( + last_diff_line < len(actual) - 1 + and last_diff_line < len(expected) - 1 + ): expected_message_lines.append(" ...") actual_message_lines.append(" ...") error_message = "Actual:\n{}\nExpected:\n{}\n".format( - format_error_lines(actual_message_lines), format_error_lines(expected_message_lines) + format_error_lines(actual_message_lines), + format_error_lines(expected_message_lines), ) if expected_line and expected_line.regex: - error_message += "The actual output does not match the expected regex." + error_message += ( + "The actual output does not match the expected regex." + ) elif ( first_diff_actual is not None and first_diff_expected is not None and ( len(first_diff_actual) >= _MIN_LINE_LENGTH_FOR_ALIGNMENT - or len(str(first_diff_expected)) >= _MIN_LINE_LENGTH_FOR_ALIGNMENT + or len(str(first_diff_expected)) + >= _MIN_LINE_LENGTH_FOR_ALIGNMENT ) ): - error_message = _add_aligned_message(str(first_diff_expected), first_diff_actual, error_message) + error_message = _add_aligned_message( + str(first_diff_expected), first_diff_actual, error_message + ) raise TypecheckAssertionError( error_message=f"{failure_reason}: \n{error_message}", @@ -283,7 +312,9 @@ def format_error_lines(lines: list[str]) -> str: ) -def extract_output_matchers_from_comments(file_name: str, input_lines: list[str], regex: bool) -> list[OutputMatcher]: +def extract_output_matchers_from_comments( + file_name: str, input_lines: list[str], regex: bool +) -> list[OutputMatcher]: """Transform comments such as '# E: message' or '# E:3: message' in input. @@ -295,7 +326,8 @@ def extract_output_matchers_from_comments(file_name: str, input_lines: list[str] # The first in the split things isn't a comment for possible_err_comment in line.split(" # ")[1:]: match = re.search( - r"^([ENW])(?P[R])?:((?P\d+):)? (?P.*)$", possible_err_comment.strip() + r"^([ENW])(?P[R])?:((?P\d+):)? (?P.*)$", + possible_err_comment.strip(), ) if match: raw_severity = match.group(1) @@ -314,7 +346,9 @@ def extract_output_matchers_from_comments(file_name: str, input_lines: list[str] return matchers -def extract_output_matchers_from_out(out: str, params: Mapping[str, Any], regex: bool) -> list[OutputMatcher]: +def extract_output_matchers_from_out( + out: str, params: Mapping[str, Any], regex: bool +) -> list[OutputMatcher]: """Transform output lines such as 'function:9: E: message' The result is a list of output matchers @@ -323,7 +357,8 @@ def extract_output_matchers_from_out(out: str, params: Mapping[str, Any], regex: lines = render_template(out, params).split("\n") for line in lines: match = re.search( - r"^(?P.+):(?P\d+): (?P[A-Za-z]+):((?P\d+):)? (?P.*)$", line.strip() + r"^(?P.+):(?P\d+): (?P[A-Za-z]+):((?P\d+):)? (?P.*)$", + line.strip(), ) if match: raw_severity = match.group("severity") @@ -342,15 +377,22 @@ def extract_output_matchers_from_out(out: str, params: Mapping[str, Any], regex: return matchers -def render_template(template: str, data: Mapping[str, Any]) -> str: +def render_template(template: str, parametrized_data: Mapping[str, Any]) -> str: if _rendering_env.variable_start_string not in template: return template - t: jinja2.environment.Template = _rendering_env.from_string(template) - return t.render({k: v if v is not None else "None" for k, v in data.items()}) + template: jinja2.environment.Template = _rendering_env.from_string(template) + return template.render( + { + key: value_param if value_param is not None else "None" + for key, value_param in parametrized_data.items() + } + ) -def get_func_first_lnum(attr: Callable[..., None]) -> tuple[int, list[str]] | None: +def get_func_first_lnum( + attr: Callable[..., None], +) -> tuple[int, list[str]] | None: lines, _ = inspect.getsourcelines(attr) for lnum, line in enumerate(lines): no_space_line = line.strip() diff --git a/setup.py b/setup.py index 78c7d8a..78c1d71 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,15 @@ author_email="maxim.kurnikov@gmail.com", maintainer="Nikita Sobolev", maintainer_email="mail@sobolevn.me", - packages=["pytest_mypy_plugins", "pytest_mypy_plugins.tests", "pytest_mypy_plugins.tests.test_configs"], + packages=[ + "pytest_mypy_plugins", + "pytest_mypy_plugins.tests", + "pytest_mypy_plugins.tests.test_configs", + ], # the following makes a plugin available to pytest - entry_points={"pytest11": ["pytest-mypy-plugins = pytest_mypy_plugins.collect"]}, + entry_points={ + "pytest11": ["pytest-mypy-plugins = pytest_mypy_plugins.collect"] + }, install_requires=dependencies, python_requires=">=3.10", package_data={ From aaec48a5dd9989038769940b6f065a01ba1675e6 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:55:16 +0300 Subject: [PATCH 04/12] Replace long pattern --- pytest_mypy_plugins/collect.py | 5 +++-- pytest_mypy_plugins/tests/test_utils.py | 2 +- pytest_mypy_plugins/utils.py | 12 +++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index e026b56..7a387a5 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -144,7 +144,7 @@ def collect(self) -> Iterator["YamlTestItem"]: test_name = f"{test_name_prefix}{test_name_suffix}" main_content = utils.render_template( - template=raw_test["main"], data=params + template=raw_test["main"], parametrized_data=params ) main_file = File(path="main.py", content=main_content) test_files = [main_file] + parse_test_files( @@ -174,7 +174,8 @@ def collect(self) -> Iterator["YamlTestItem"]: ) ) additional_mypy_config = utils.render_template( - template=raw_test.get("mypy_config", ""), data=params + template=raw_test.get("mypy_config", ""), + parametrized_data=params, ) skip = self._eval_skip(str(raw_test.get("skip", "False"))) diff --git a/pytest_mypy_plugins/tests/test_utils.py b/pytest_mypy_plugins/tests/test_utils.py index 0b48082..58555f1 100644 --- a/pytest_mypy_plugins/tests/test_utils.py +++ b/pytest_mypy_plugins/tests/test_utils.py @@ -25,7 +25,7 @@ def test_render_template_with_None_value() -> None: data = {"a": None, "b": 99} # When - actual = utils.render_template(template=template, data=data) + actual = utils.render_template(template=template, parametrized_data=data) # Then assert actual == "None 99" diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index 17acad6..b3a6028 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -37,6 +37,10 @@ _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]: @@ -357,7 +361,7 @@ def extract_output_matchers_from_out( lines = render_template(out, params).split("\n") for line in lines: match = re.search( - r"^(?P.+):(?P\d+): (?P[A-Za-z]+):((?P\d+):)? (?P.*)$", + _MYPY_OUTPUT_LINE_RE, line.strip(), ) if match: @@ -381,8 +385,10 @@ def render_template(template: str, parametrized_data: Mapping[str, Any]) -> str: if _rendering_env.variable_start_string not in template: return template - template: jinja2.environment.Template = _rendering_env.from_string(template) - return template.render( + jinja_template: jinja2.environment.Template = _rendering_env.from_string( + template + ) + return jinja_template.render( { key: value_param if value_param is not None else "None" for key, value_param in parametrized_data.items() From ab536a9d64e12fbed888da5c1bf9dae54f58a9f0 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:19:28 +0300 Subject: [PATCH 05/12] Add small improvements --- pytest_mypy_plugins/collect.py | 15 ++++++------- pytest_mypy_plugins/item.py | 40 +++++++++++++++++++--------------- setup.py | 1 + 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index 7a387a5..666391b 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -6,10 +6,7 @@ import tempfile from collections.abc import Hashable, Iterator, Mapping from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, -) +from typing import TYPE_CHECKING, Any, Final import jsonschema import pytest @@ -22,6 +19,8 @@ if TYPE_CHECKING: from pytest_mypy_plugins.item import YamlTestItem +_STORE_TRUE_ACTION: Final = "store_true" + @dataclass class File: @@ -233,7 +232,7 @@ def pytest_addoption(parser: Parser) -> None: ) group.addoption( "--mypy-same-process", - action="store_true", + action=_STORE_TRUE_ACTION, help="Run in the same process. Useful for debugging, will create problems with import cache", ) group.addoption( @@ -244,16 +243,16 @@ def pytest_addoption(parser: Parser) -> None: ) group.addoption( "--mypy-no-silence-site-packages", - action="store_true", + action=_STORE_TRUE_ACTION, help="mypy will not silence errors from site-packages", ) group.addoption( "--mypy-modify-pythonpath", - action="store_true", + action=_STORE_TRUE_ACTION, help="When running mypy in a subprocess, modify `PYTHONPATH` to force packages to be treated as site-packages. Incompatible with `--mypy-same-process`", ) group.addoption( "--mypy-closed-schema", - action="store_true", + action=_STORE_TRUE_ACTION, help="Use closed schema to validate YAML test cases, which won't allow any extra keys (does not work well with `--mypy-extension-hook`)", ) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 0563c91..2369635 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -10,7 +10,7 @@ import tempfile from enum import IntEnum, unique from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TextIO, final +from typing import TYPE_CHECKING, Any, Final, Literal, TextIO, final import pytest from _pytest._code import ExceptionInfo @@ -44,6 +44,8 @@ "long", "short", "line", "no", "native", "value", "auto" ] +_COLON: Final = ":" + class TraceLastReprEntry(ReprEntry): def toterminal(self, tw: TerminalWriter) -> None: @@ -54,7 +56,6 @@ def toterminal(self, tw: TerminalWriter) -> None: for line in self.lines: red = line.startswith("E ") tw.line(line, bold=True, red=red) - return def make_files(rootdir: Path, files_to_create: dict[str, str]) -> list[str]: @@ -71,11 +72,11 @@ def make_files(rootdir: Path, files_to_create: dict[str, str]) -> list[str]: def replace_fpath_with_module_name(line: str, rootdir: Path) -> str: - if ":" not in line: + if _COLON not in line: return line - out_fpath, res_line = line.split(":", 1) - line = os.path.relpath(out_fpath, start=rootdir) + ":" + res_line - return line.strip().replace(".py:", ":") + out_fpath, res_line = line.split(_COLON, 1) + line = os.path.relpath(out_fpath, start=rootdir) + _COLON + res_line + return line.strip().replace(".py:", _COLON) def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str: @@ -183,7 +184,8 @@ def _typecheck_in_new_subprocess( self._collect_mypy_path(self.rootdir) # Windows requires this to be set, otherwise the interpreter crashes - if "SYSTEMROOT" in os.environ: + systemroot_in_env = os.environ.get("SYSTEMROOT") + if systemroot_in_env is not None: self.environment_variables["SYSTEMROOT"] = os.environ["SYSTEMROOT"] completed = subprocess.run( @@ -232,7 +234,9 @@ def _collect_python_path(self, rootdir: Path | None) -> None: python_path_parts.append(maybe_to_abspath(python_path_key, rootdir)) python_path_parts.append(python_path_key) - self.environment_variables["PYTHONPATH"] = ":".join(python_path_parts) + self.environment_variables["PYTHONPATH"] = _COLON.join( + python_path_parts + ) def _collect_mypy_path(self, rootdir: Path | None) -> None: mypy_path_parts = [] @@ -247,7 +251,7 @@ def _collect_mypy_path(self, rootdir: Path | None) -> None: if rootdir: mypy_path_parts.append(str(rootdir)) - self.environment_variables["MYPYPATH"] = ":".join(mypy_path_parts) + self.environment_variables["MYPYPATH"] = _COLON.join(mypy_path_parts) class OutputChecker: @@ -279,9 +283,9 @@ def check(self, ret_code: int, stdout: str, stderr: str) -> None: assert_expected_matched_actual( expected=self.expected_output, actual=output_lines ) - except TypecheckAssertionError as e: + except TypecheckAssertionError: if not self.expect_fail: - raise e + raise TypecheckAssertionError else: if self.expect_fail: raise TypecheckAssertionError( @@ -319,19 +323,19 @@ def run(self) -> None: mypy_cmd_options.append(str(self.main_file)) # make files - for file in self.files: - self._make_test_file(file) + for test_file in self.files: + self._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, file: File) -> None: + def _make_test_file(self, test_file: File) -> None: current_directory = Path.cwd() - fpath = current_directory / file.path + fpath = current_directory / test_file.path fpath.parent.mkdir(parents=True, exist_ok=True) - fpath.write_text(file.content) + fpath.write_text(test_file.content) def _prepare_mypy_cmd_options(self) -> list[str]: mypy_cmd_options = [ @@ -548,8 +552,8 @@ def runtest(self) -> None: temp_dir.cleanup() # remove created modules from the shared cache if not self.disable_cache: - for file in self.files: - path = Path(file.path) + for test_file in self.files: + path = Path(test_file.path) self.remove_cache_files(path.with_suffix("")) assert not os.path.exists(temp_dir.name) diff --git a/setup.py b/setup.py index 78c1d71..e8cdedd 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ "pytest>=7.0.0", "pyyaml", "regex", + # TODO: should we use tomli? "tomlkit>=0.11", ] From 7e698a87fd471d97fe0d4aa9d688674706b5185d Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:18:34 +0300 Subject: [PATCH 06/12] Simpify project structure --- pytest_mypy_plugins/collect.py | 11 ++------ pytest_mypy_plugins/constants.py | 3 +++ pytest_mypy_plugins/item.py | 39 +++++++++------------------ pytest_mypy_plugins/logic/__init__.py | 0 pytest_mypy_plugins/logic/files.py | 27 +++++++++++++++++++ pytest_mypy_plugins/logic/text.py | 14 ++++++++++ pytest_mypy_plugins/utils.py | 31 ++++++++------------- setup.py | 4 +-- 8 files changed, 72 insertions(+), 57 deletions(-) create mode 100644 pytest_mypy_plugins/constants.py create mode 100644 pytest_mypy_plugins/logic/__init__.py create mode 100644 pytest_mypy_plugins/logic/files.py create mode 100644 pytest_mypy_plugins/logic/text.py diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index 666391b..1ce0340 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -15,6 +15,7 @@ from _pytest.nodes import Node from pytest_mypy_plugins import utils +from pytest_mypy_plugins.logic import files if TYPE_CHECKING: from pytest_mypy_plugins.item import YamlTestItem @@ -56,14 +57,6 @@ def parse_test_files(test_files: list[dict[str, Any]]) -> list[File]: return files -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 - return parsed_vars - - def parse_parametrized( params: list[Mapping[str, Any]], ) -> list[Mapping[str, Any]]: @@ -163,7 +156,7 @@ def collect(self) -> Iterator["YamlTestItem"]: starting_lineno = raw_test["__line__"] marks = raw_test.get("marks", []) - extra_environment_variables = parse_environment_variables( + extra_environment_variables = files.parse_environment_variables( raw_test.get("env", []) ) disable_cache = raw_test.get("disable_cache", False) diff --git a/pytest_mypy_plugins/constants.py b/pytest_mypy_plugins/constants.py new file mode 100644 index 0000000..30db767 --- /dev/null +++ b/pytest_mypy_plugins/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +COLON: Final = ":" diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 2369635..0485dd5 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -10,7 +10,7 @@ import tempfile from enum import IntEnum, unique from pathlib import Path -from typing import TYPE_CHECKING, Any, Final, Literal, TextIO, final +from typing import TYPE_CHECKING, Any, Literal, TextIO, final import pytest from _pytest._code import ExceptionInfo @@ -28,6 +28,8 @@ from pytest_mypy_plugins import configs, utils from pytest_mypy_plugins.collect import File, YamlTestFile +from pytest_mypy_plugins.constants import COLON +from pytest_mypy_plugins.logic import files from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, @@ -44,8 +46,6 @@ "long", "short", "line", "no", "native", "value", "auto" ] -_COLON: Final = ":" - class TraceLastReprEntry(ReprEntry): def toterminal(self, tw: TerminalWriter) -> None: @@ -71,21 +71,6 @@ def make_files(rootdir: Path, files_to_create: dict[str, str]) -> list[str]: return created_modules -def replace_fpath_with_module_name(line: str, rootdir: Path) -> str: - if _COLON not in line: - return line - out_fpath, res_line = line.split(_COLON, 1) - line = os.path.relpath(out_fpath, start=rootdir) + _COLON + res_line - return line.strip().replace(".py:", _COLON) - - -def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str: - rel_or_abs = os.path.expandvars(rel_or_abs) - if rootdir is None or os.path.isabs(rel_or_abs): - return rel_or_abs - return str(rootdir / rel_or_abs) - - @final @unique class ReturnCodes(IntEnum): @@ -231,12 +216,12 @@ def _collect_python_path(self, rootdir: Path | None) -> None: python_path_parts.append(str(self.execution_path)) python_path_key = self.environment_variables.get("PYTHONPATH") if python_path_key: - python_path_parts.append(maybe_to_abspath(python_path_key, rootdir)) + python_path_parts.append( + files.maybe_to_abspath(python_path_key, rootdir) + ) python_path_parts.append(python_path_key) - self.environment_variables["PYTHONPATH"] = _COLON.join( - python_path_parts - ) + self.environment_variables["PYTHONPATH"] = COLON.join(python_path_parts) def _collect_mypy_path(self, rootdir: Path | None) -> None: mypy_path_parts = [] @@ -246,12 +231,14 @@ def _collect_mypy_path(self, rootdir: Path | None) -> None: mypy_path_parts.append(existing_mypy_path) mypy_path_key = self.environment_variables.get("MYPYPATH") if mypy_path_key: - mypy_path_parts.append(maybe_to_abspath(mypy_path_key, rootdir)) + mypy_path_parts.append( + files.maybe_to_abspath(mypy_path_key, rootdir) + ) mypy_path_parts.append(mypy_path_key) if rootdir: mypy_path_parts.append(str(rootdir)) - self.environment_variables["MYPYPATH"] = _COLON.join(mypy_path_parts) + self.environment_variables["MYPYPATH"] = COLON.join(mypy_path_parts) class OutputChecker: @@ -275,7 +262,7 @@ def check(self, ret_code: int, stdout: str, stderr: str) -> None: output_lines = [] for line in mypy_output.splitlines(): - output_line = replace_fpath_with_module_name( + output_line = files.replace_fpath_with_module_name( line, rootdir=self.execution_path ) output_lines.append(output_line) @@ -285,7 +272,7 @@ def check(self, ret_code: int, stdout: str, stderr: str) -> None: ) except TypecheckAssertionError: if not self.expect_fail: - raise TypecheckAssertionError + raise else: if self.expect_fail: raise TypecheckAssertionError( diff --git a/pytest_mypy_plugins/logic/__init__.py b/pytest_mypy_plugins/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_mypy_plugins/logic/files.py b/pytest_mypy_plugins/logic/files.py new file mode 100644 index 0000000..e844cd6 --- /dev/null +++ b/pytest_mypy_plugins/logic/files.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +from pytest_mypy_plugins.constants import COLON + + +def replace_fpath_with_module_name(line: str, rootdir: Path) -> str: + if COLON not in line: + return line + out_fpath, res_line = line.split(COLON, 1) + line = os.path.relpath(out_fpath, start=rootdir) + COLON + res_line + return line.strip().replace(".py:", COLON) + + +def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str: + rel_or_abs = os.path.expandvars(rel_or_abs) + if rootdir is None or os.path.isabs(rel_or_abs): + return rel_or_abs + return str(rootdir / rel_or_abs) + + +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 + return parsed_vars diff --git a/pytest_mypy_plugins/logic/text.py b/pytest_mypy_plugins/logic/text.py new file mode 100644 index 0000000..6a1bd72 --- /dev/null +++ b/pytest_mypy_plugins/logic/text.py @@ -0,0 +1,14 @@ +def format_mismatched_line(line: str) -> str: + return f" {str(line):<45} (diff)" + + +def format_matched_line(line: str, width: int = 100) -> str: + return f" {line[:width]}..." if len(line) > width else f" {line}" + + +def format_error_lines(lines: list[str]) -> str: + return "\n".join(lines) if lines else " (empty)" + + +def remove_empty_lines(lines: list[str]) -> list[str]: + return list(filter(None, lines)) diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index b3a6028..9bc2fc2 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -17,6 +17,8 @@ import regex from decorator import contextmanager +from pytest_mypy_plugins.logic import text + _rendering_env = jinja2.Environment() _SEVERITY_MAP: Final = { @@ -196,10 +198,6 @@ def _add_aligned_message(word1: str, word2: str, error_message: str) -> str: return error_message -def remove_empty_lines(lines: list[str]) -> list[str]: - return list(filter(None, lines)) - - 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: @@ -222,18 +220,9 @@ def assert_expected_matched_actual( Display any differences in a human-readable form. """ - def format_mismatched_line(line: str) -> str: - return f" {str(line):<45} (diff)" - - def format_matched_line(line: str, width: int = 100) -> str: - return f" {line[:width]}..." if len(line) > width else f" {line}" - - def format_error_lines(lines: list[str]) -> str: - return "\n".join(lines) if lines else " (empty)" - expected = sorted(expected, key=lambda om: (om.file_name, om.lnum)) actual = remove_common_prefix( - sorted_by_file_and_line(remove_empty_lines(actual)) + sorted_by_file_and_line(text.remove_empty_lines(actual)) ) diff_lines: dict[int, tuple[OutputMatcher, str]] = { @@ -254,18 +243,20 @@ def format_error_lines(lines: list[str]) -> str: expected_line, actual_line = diff_lines[i] if expected_line: expected_message_lines.append( - format_mismatched_line(str(expected_line)) + text.format_mismatched_line(str(expected_line)) ) if actual_line: actual_message_lines.append( - format_mismatched_line(actual_line) + text.format_mismatched_line(actual_line) ) else: expected_line, actual_line = expected[i], actual[i] - actual_message_lines.append(format_matched_line(actual_line)) + actual_message_lines.append( + text.format_matched_line(actual_line) + ) expected_message_lines.append( - format_matched_line(str(expected_line)) + text.format_matched_line(str(expected_line)) ) first_diff_expected, first_diff_actual = diff_lines[first_diff_line] @@ -289,8 +280,8 @@ def format_error_lines(lines: list[str]) -> str: actual_message_lines.append(" ...") error_message = "Actual:\n{}\nExpected:\n{}\n".format( - format_error_lines(actual_message_lines), - format_error_lines(expected_message_lines), + text.format_error_lines(actual_message_lines), + text.format_error_lines(expected_message_lines), ) if expected_line and expected_line.regex: diff --git a/setup.py b/setup.py index e8cdedd..813ae71 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup -with open("README.md") as f: - readme = f.read() +with open("README.md") as readme_file: + readme = readme_file.read() dependencies = [ "Jinja2", From a1508b25300e84465f8264d4c33253eac041349c Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:41:24 +0300 Subject: [PATCH 07/12] Create parser.py --- pytest_mypy_plugins/collect.py | 48 +++-------------------------- pytest_mypy_plugins/item.py | 3 +- pytest_mypy_plugins/logic/parser.py | 36 ++++++++++++++++++++++ pytest_mypy_plugins/types.py | 7 +++++ 4 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 pytest_mypy_plugins/logic/parser.py create mode 100644 pytest_mypy_plugins/types.py diff --git a/pytest_mypy_plugins/collect.py b/pytest_mypy_plugins/collect.py index 1ce0340..cd0a176 100644 --- a/pytest_mypy_plugins/collect.py +++ b/pytest_mypy_plugins/collect.py @@ -4,8 +4,7 @@ import platform import sys import tempfile -from collections.abc import Hashable, Iterator, Mapping -from dataclasses import dataclass +from collections.abc import Hashable, Iterator from typing import TYPE_CHECKING, Any, Final import jsonschema @@ -15,7 +14,8 @@ from _pytest.nodes import Node from pytest_mypy_plugins import utils -from pytest_mypy_plugins.logic import files +from pytest_mypy_plugins.logic import files, parser +from pytest_mypy_plugins.types import File if TYPE_CHECKING: from pytest_mypy_plugins.item import YamlTestItem @@ -23,12 +23,6 @@ _STORE_TRUE_ACTION: Final = "store_true" -@dataclass -class File: - path: str - content: str - - def validate_schema(data: Any, *, is_closed: bool = False) -> None: """Validate the schema of the file-under-test.""" # Unfortunately, yaml.safe_load() returns Any, @@ -48,38 +42,6 @@ def validate_schema(data: Any, *, is_closed: bool = False) -> None: jsonschema.validate(instance=data, schema=schema) -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_parametrized( - params: list[Mapping[str, Any]], -) -> list[Mapping[str, Any]]: - if not params: - 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("__")} - ) - - return parsed_params - - class SafeLineLoader(yaml.SafeLoader): def construct_mapping( self, node: yaml.MappingNode, deep: bool = False @@ -121,7 +83,7 @@ def collect(self) -> Iterator["YamlTestItem"]: f"Invalid test name {test_name_prefix!r}, only '[a-zA-Z0-9_]' is allowed." ) else: - parametrized = parse_parametrized( + parametrized = parser.parse_parametrized( raw_test.get("parametrized", []) ) @@ -139,7 +101,7 @@ def collect(self) -> Iterator["YamlTestItem"]: template=raw_test["main"], parametrized_data=params ) main_file = File(path="main.py", content=main_content) - test_files = [main_file] + parse_test_files( + test_files = [main_file] + parser.parse_test_files( raw_test.get("files", []) ) expect_fail = raw_test.get("expect_fail", False) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index 0485dd5..afade2d 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -27,9 +27,10 @@ ) from pytest_mypy_plugins import configs, utils -from pytest_mypy_plugins.collect import File, YamlTestFile +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 from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, diff --git a/pytest_mypy_plugins/logic/parser.py b/pytest_mypy_plugins/logic/parser.py new file mode 100644 index 0000000..3677386 --- /dev/null +++ b/pytest_mypy_plugins/logic/parser.py @@ -0,0 +1,36 @@ +from collections.abc import Mapping +from typing import Any + +from pytest_mypy_plugins.types import File + + +def parse_parametrized( + params: list[Mapping[str, Any]], +) -> list[Mapping[str, Any]]: + if not params: + 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("__")} + ) + + return parsed_params + + +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 diff --git a/pytest_mypy_plugins/types.py b/pytest_mypy_plugins/types.py new file mode 100644 index 0000000..2e5be65 --- /dev/null +++ b/pytest_mypy_plugins/types.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class File: + path: str + content: str From f36da882c092822828981aa515b7e8cd14cba825 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:08:07 +0300 Subject: [PATCH 08/12] Final commit --- pytest_mypy_plugins/configs.py | 29 +++++++++---------- pytest_mypy_plugins/item.py | 16 ++-------- pytest_mypy_plugins/logic/files.py | 8 +++++ .../test_configs/test_join_toml_configs.py | 8 ++--- pytest_mypy_plugins/types.py | 10 +++++++ pytest_mypy_plugins/utils.py | 8 ----- setup.py | 4 +-- 7 files changed, 40 insertions(+), 43 deletions(-) diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index 01085e6..d57bd6b 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -3,7 +3,8 @@ from textwrap import dedent from typing import Final -import tomlkit +import tomli +import tomli_w _TOML_TABLE_NAME: Final = "[tool.mypy]" _MYPY_TOML_SECTION: Final = "mypy" @@ -38,19 +39,19 @@ def join_toml_configs( ) -> str | None: if base_pyproject_toml_fpath: with open(base_pyproject_toml_fpath) as file_config: - toml_config = tomlkit.parse(file_config.read()) + toml_config = tomli.loads(file_config.read()) else: # Empty document with `[tool.mypy]` empty table, # useful for overrides further. - toml_config = tomlkit.document() + toml_config = {} if ( _TOOL_TOML_SECTION not in toml_config or _MYPY_TOML_SECTION not in toml_config[_TOOL_TOML_SECTION] ): - tool = tomlkit.table(is_super_table=True) - tool.append(_MYPY_TOML_SECTION, tomlkit.table()) - toml_config.append(_TOOL_TOML_SECTION, tool) + tool = {} + tool[_MYPY_TOML_SECTION] = {} + toml_config[_TOOL_TOML_SECTION] = tool if additional_mypy_config: if _TOML_TABLE_NAME not in additional_mypy_config: @@ -58,11 +59,9 @@ def join_toml_configs( f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}" ) - additional_data = tomlkit.parse(additional_mypy_config) + additional_data = tomli.loads(additional_mypy_config) toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION].update( - additional_data[_TOOL_TOML_SECTION][ - _MYPY_TOML_SECTION - ].value.items(), + additional_data[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION].items(), ) mypy_config_file_path = execution_path / "pyproject.toml" @@ -72,10 +71,10 @@ def join_toml_configs( tool_mypy = toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION] # construct toml output - min_toml = tomlkit.document() - min_tool = tomlkit.table(is_super_table=True) - min_toml.append(_TOOL_TOML_SECTION, min_tool) - min_tool.append(_MYPY_TOML_SECTION, tool_mypy) + min_toml = {} + min_tool = {} + min_toml[_TOOL_TOML_SECTION] = min_tool + min_tool[_MYPY_TOML_SECTION] = tool_mypy - file_config.write(min_toml.as_string()) + file_config.write(tomli_w.dumps(min_toml)) return str(mypy_config_file_path) diff --git a/pytest_mypy_plugins/item.py b/pytest_mypy_plugins/item.py index afade2d..1e3046a 100644 --- a/pytest_mypy_plugins/item.py +++ b/pytest_mypy_plugins/item.py @@ -8,9 +8,8 @@ import subprocess import sys import tempfile -from enum import IntEnum, unique from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TextIO, final +from typing import TYPE_CHECKING, Any, Literal, TextIO import pytest from _pytest._code import ExceptionInfo @@ -30,12 +29,11 @@ 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 +from pytest_mypy_plugins.types import File, ReturnCodes from pytest_mypy_plugins.utils import ( OutputMatcher, TypecheckAssertionError, assert_expected_matched_actual, - fname_to_module, ) if TYPE_CHECKING: @@ -66,20 +64,12 @@ def make_files(rootdir: Path, files_to_create: dict[str, str]) -> list[str]: fpath.parent.mkdir(parents=True, exist_ok=True) fpath.write_text(file_contents) - created_module = fname_to_module(fpath, root_path=rootdir) + created_module = files.fname_to_module(fpath, root_path=rootdir) if created_module: created_modules.append(created_module) return created_modules -@final -@unique -class ReturnCodes(IntEnum): - SUCCESS = 0 - FAIL = 1 - FATAL_ERROR = 2 - - def run_mypy_typechecking( cmd_options: list[str], stdout: TextIO, stderr: TextIO ) -> int: diff --git a/pytest_mypy_plugins/logic/files.py b/pytest_mypy_plugins/logic/files.py index e844cd6..7635275 100644 --- a/pytest_mypy_plugins/logic/files.py +++ b/pytest_mypy_plugins/logic/files.py @@ -25,3 +25,11 @@ def parse_environment_variables(env_vars: list[str]) -> dict[str, str]: name, _, value = env_var.partition("=") parsed_vars[name] = value return parsed_vars + + +def fname_to_module(fpath: Path, root_path: Path) -> str | None: + try: + relpath = fpath.relative_to(root_path).with_suffix("") + return str(relpath).replace(os.sep, ".") + except ValueError: + return None diff --git a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py index 54d204b..4f2e880 100644 --- a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py +++ b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py @@ -136,10 +136,8 @@ def test_join_missing_config3( warn_unused_ignores = true pretty = true show_error_codes = true - - [[tool.mypy.overrides]] - # This section should be copied - module = "mymodule" - ignore_missing_imports = true + overrides = [ + { module = "mymodule", ignore_missing_imports = true }, + ] """, ) diff --git a/pytest_mypy_plugins/types.py b/pytest_mypy_plugins/types.py index 2e5be65..5514ffd 100644 --- a/pytest_mypy_plugins/types.py +++ b/pytest_mypy_plugins/types.py @@ -1,7 +1,17 @@ from dataclasses import dataclass +from enum import IntEnum, unique +from typing import final @dataclass class File: path: str content: str + + +@final +@unique +class ReturnCodes(IntEnum): + SUCCESS = 0 + FAIL = 1 + FATAL_ERROR = 2 diff --git a/pytest_mypy_plugins/utils.py b/pytest_mypy_plugins/utils.py index 9bc2fc2..bb2de38 100644 --- a/pytest_mypy_plugins/utils.py +++ b/pytest_mypy_plugins/utils.py @@ -74,14 +74,6 @@ def temp_sys_modules() -> Iterator[None]: sys.modules = sys_modules.copy() -def fname_to_module(fpath: Path, root_path: Path) -> str | None: - try: - relpath = fpath.relative_to(root_path).with_suffix("") - return str(relpath).replace(os.sep, ".") - except ValueError: - return None - - @dataclass class OutputMatcher: file_name: str diff --git a/setup.py b/setup.py index 813ae71..ca10bb3 100644 --- a/setup.py +++ b/setup.py @@ -12,8 +12,8 @@ "pytest>=7.0.0", "pyyaml", "regex", - # TODO: should we use tomli? - "tomlkit>=0.11", + "tomli", + "tomli_w" ] setup( From ffcae721efc5eb73274cb3ac9b5ae6819e1bc8fd Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:09:14 +0300 Subject: [PATCH 09/12] use black --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ca10bb3..de28af0 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ "pyyaml", "regex", "tomli", - "tomli_w" + "tomli_w", ] setup( From 0e861df5412a161b88c916d2ca2e06f3e6e20a13 Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:44:37 +0300 Subject: [PATCH 10/12] fix mypy --- pytest_mypy_plugins/configs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index d57bd6b..4168466 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -1,7 +1,7 @@ from configparser import ConfigParser from pathlib import Path from textwrap import dedent -from typing import Final +from typing import Final, Any import tomli import tomli_w @@ -49,7 +49,7 @@ def join_toml_configs( _TOOL_TOML_SECTION not in toml_config or _MYPY_TOML_SECTION not in toml_config[_TOOL_TOML_SECTION] ): - tool = {} + tool: dict[str, Any] = {} tool[_MYPY_TOML_SECTION] = {} toml_config[_TOOL_TOML_SECTION] = tool @@ -72,7 +72,7 @@ def join_toml_configs( # construct toml output min_toml = {} - min_tool = {} + min_tool: dict[str, Any] = {} min_toml[_TOOL_TOML_SECTION] = min_tool min_tool[_MYPY_TOML_SECTION] = tool_mypy From fe9a324f1d7663cfbe781e488ab8f5086b01b6dd Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:46:31 +0300 Subject: [PATCH 11/12] use isort --- pytest_mypy_plugins/configs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index 4168466..bca98ae 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -1,7 +1,7 @@ from configparser import ConfigParser from pathlib import Path from textwrap import dedent -from typing import Final, Any +from typing import Any, Final import tomli import tomli_w From f89cd5c69618c3099a5c18f5bb0f1a8473fe261c Mon Sep 17 00:00:00 2001 From: Khabib <136262760+Khabib73@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:10:29 +0300 Subject: [PATCH 12/12] revert changes --- pytest_mypy_plugins/configs.py | 31 ++++++++++--------- .../test_configs/test_join_toml_configs.py | 8 +++-- setup.py | 3 +- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/pytest_mypy_plugins/configs.py b/pytest_mypy_plugins/configs.py index bca98ae..01085e6 100644 --- a/pytest_mypy_plugins/configs.py +++ b/pytest_mypy_plugins/configs.py @@ -1,10 +1,9 @@ from configparser import ConfigParser from pathlib import Path from textwrap import dedent -from typing import Any, Final +from typing import Final -import tomli -import tomli_w +import tomlkit _TOML_TABLE_NAME: Final = "[tool.mypy]" _MYPY_TOML_SECTION: Final = "mypy" @@ -39,19 +38,19 @@ def join_toml_configs( ) -> str | None: if base_pyproject_toml_fpath: with open(base_pyproject_toml_fpath) as file_config: - toml_config = tomli.loads(file_config.read()) + toml_config = tomlkit.parse(file_config.read()) else: # Empty document with `[tool.mypy]` empty table, # useful for overrides further. - toml_config = {} + toml_config = tomlkit.document() if ( _TOOL_TOML_SECTION not in toml_config or _MYPY_TOML_SECTION not in toml_config[_TOOL_TOML_SECTION] ): - tool: dict[str, Any] = {} - tool[_MYPY_TOML_SECTION] = {} - toml_config[_TOOL_TOML_SECTION] = tool + tool = tomlkit.table(is_super_table=True) + tool.append(_MYPY_TOML_SECTION, tomlkit.table()) + toml_config.append(_TOOL_TOML_SECTION, tool) if additional_mypy_config: if _TOML_TABLE_NAME not in additional_mypy_config: @@ -59,9 +58,11 @@ def join_toml_configs( f"{_TOML_TABLE_NAME}\n{dedent(additional_mypy_config)}" ) - additional_data = tomli.loads(additional_mypy_config) + additional_data = tomlkit.parse(additional_mypy_config) toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION].update( - additional_data[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION].items(), + additional_data[_TOOL_TOML_SECTION][ + _MYPY_TOML_SECTION + ].value.items(), ) mypy_config_file_path = execution_path / "pyproject.toml" @@ -71,10 +72,10 @@ def join_toml_configs( tool_mypy = toml_config[_TOOL_TOML_SECTION][_MYPY_TOML_SECTION] # construct toml output - min_toml = {} - min_tool: dict[str, Any] = {} - min_toml[_TOOL_TOML_SECTION] = min_tool - min_tool[_MYPY_TOML_SECTION] = tool_mypy + min_toml = tomlkit.document() + min_tool = tomlkit.table(is_super_table=True) + min_toml.append(_TOOL_TOML_SECTION, min_tool) + min_tool.append(_MYPY_TOML_SECTION, tool_mypy) - file_config.write(tomli_w.dumps(min_toml)) + file_config.write(min_toml.as_string()) return str(mypy_config_file_path) diff --git a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py index 4f2e880..54d204b 100644 --- a/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py +++ b/pytest_mypy_plugins/tests/test_configs/test_join_toml_configs.py @@ -136,8 +136,10 @@ def test_join_missing_config3( warn_unused_ignores = true pretty = true show_error_codes = true - overrides = [ - { module = "mymodule", ignore_missing_imports = true }, - ] + + [[tool.mypy.overrides]] + # This section should be copied + module = "mymodule" + ignore_missing_imports = true """, ) diff --git a/setup.py b/setup.py index de28af0..57fd21f 100644 --- a/setup.py +++ b/setup.py @@ -12,8 +12,7 @@ "pytest>=7.0.0", "pyyaml", "regex", - "tomli", - "tomli_w", + "tomlkit>=0.11", ] setup(