Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions pytest_mypy_plugins/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@
_STORE_TRUE_ACTION: Final = "store_true"


def validate_schema(data: Any, *, is_closed: bool = False) -> None:
def validate_schema(yml_data: Any, *, is_closed: bool = False) -> None:
"""Validate the schema of the file-under-test."""
# Unfortunately, yaml.safe_load() returns Any,
# so we make our intention explicit here.
if not isinstance(data, list):
raise TypeError(f"Test file has to be YAML list, got {type(data)!r}.")
if not isinstance(yml_data, list):
raise TypeError(
f"Test file has to be YAML list, got {type(yml_data)!r}."
)

schema = json.loads(
(pathlib.Path(__file__).parent / "schema.json").read_text("utf8")
Expand All @@ -39,7 +41,7 @@ def validate_schema(data: Any, *, is_closed: bool = False) -> None:
}
schema["items"]["additionalProperties"] = not is_closed

jsonschema.validate(instance=data, schema=schema)
jsonschema.validate(instance=yml_data, schema=schema)


class SafeLineLoader(yaml.SafeLoader):
Expand Down
51 changes: 51 additions & 0 deletions pytest_mypy_plugins/isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import os
import sys
from collections.abc import Iterator
from pathlib import Path

from decorator import contextmanager


@contextmanager
def temp_environ() -> Iterator[None]:
"""Allow the ability to set ``os.environ`` temporarily."""
environ = dict(os.environ)
try:
yield
finally:
os.environ.clear()
os.environ.update(environ)


@contextmanager
def temp_path() -> Iterator[None]:
"""Allow the ability to set ``sys.path`` temporarily."""
path = sys.path.copy()
try:
yield
finally:
sys.path = path


@contextmanager
def temp_sys_modules() -> Iterator[None]:
sys_modules = sys.modules.copy()
try:
yield
finally:
sys.modules = sys_modules.copy()


@contextmanager
def cd(path: str | Path) -> Iterator[None]:
"""Context manager to temporarily change working directories."""
if not path:
return
prev_cwd = Path.cwd().as_posix()
if isinstance(path, Path):
path = path.as_posix()
os.chdir(str(path))
try:
yield
finally:
os.chdir(prev_cwd)
25 changes: 13 additions & 12 deletions pytest_mypy_plugins/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@
SqliteMetadataStore,
)

from pytest_mypy_plugins import configs, utils
from pytest_mypy_plugins import configs, isolation
from pytest_mypy_plugins.collect import YamlTestFile
from pytest_mypy_plugins.constants import COLON
from pytest_mypy_plugins.logic import files
from pytest_mypy_plugins.types import File, ReturnCodes
from pytest_mypy_plugins.utils import (
from pytest_mypy_plugins.types import (
File,
OutputMatcher,
ReturnCodes,
TypecheckAssertionError,
)
from pytest_mypy_plugins.utils import (
assert_expected_matched_actual,
)

Expand Down Expand Up @@ -178,7 +181,11 @@ def _typecheck_in_same_process(
self, mypy_cmd_options: list[Any]
) -> tuple[int, tuple[str, str]]:
return_code = -1
with utils.temp_environ(), utils.temp_path(), utils.temp_sys_modules():
with (
isolation.temp_environ(),
isolation.temp_path(),
isolation.temp_sys_modules(),
):
# add custom environment variables
for key, val in self.environment_variables.items():
os.environ[key] = val
Expand Down Expand Up @@ -302,19 +309,13 @@ def run(self) -> None:

# make files
for test_file in self.files:
self._make_test_file(test_file)
files.make_test_file(test_file)

returncode, (stdout, stderr) = self.mypy_executor.execute(
mypy_cmd_options
)
self.output_checker.check(returncode, stdout, stderr)

def _make_test_file(self, test_file: File) -> None:
current_directory = Path.cwd()
fpath = current_directory / test_file.path
fpath.parent.mkdir(parents=True, exist_ok=True)
fpath.write_text(test_file.content)

def _prepare_mypy_cmd_options(self) -> list[str]:
mypy_cmd_options = [
"--show-traceback",
Expand Down Expand Up @@ -499,7 +500,7 @@ def runtest(self) -> None:
self.execute_extension_hook()

execution_path = Path(temp_dir.name).absolute()
with utils.cd(execution_path):
with isolation.cd(execution_path):
mypy_executor = MypyExecutor(
same_process=self.same_process,
execution_path=execution_path,
Expand Down
29 changes: 27 additions & 2 deletions pytest_mypy_plugins/logic/files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import os
from pathlib import Path
from typing import Final

from pytest_mypy_plugins.constants import COLON
from pytest_mypy_plugins.types import File

_MAX_LINE_SPLIT_PARTS: Final = 3


def replace_fpath_with_module_name(line: str, rootdir: Path) -> str:
Expand All @@ -22,8 +26,8 @@ def maybe_to_abspath(rel_or_abs: str, rootdir: Path | None) -> str:
def parse_environment_variables(env_vars: list[str]) -> dict[str, str]:
parsed_vars: dict[str, str] = {}
for env_var in env_vars:
name, _, value = env_var.partition("=")
parsed_vars[name] = value
name, _, value_env = env_var.partition("=")
parsed_vars[name] = value_env
return parsed_vars


Expand All @@ -33,3 +37,24 @@ def fname_to_module(fpath: Path, root_path: Path) -> str | None:
return str(relpath).replace(os.sep, ".")
except ValueError:
return None


def make_test_file(test_file: File) -> None:
current_directory = Path.cwd()
fpath = current_directory / test_file.path
fpath.parent.mkdir(parents=True, exist_ok=True)
fpath.write_text(test_file.content)


def sorted_by_file_and_line(lines: list[str]) -> list[str]:
def extract_parts_as_tuple(line: str) -> tuple[str, int]: # noqa: WPS430
if len(line.split(":", maxsplit=2)) < _MAX_LINE_SPLIT_PARTS:
return "", 0

file_name, line_number, _ = line.split(":", maxsplit=2)
try:
return file_name, int(line_number)
except ValueError:
return "", 0

return sorted(lines, key=extract_parts_as_tuple)
55 changes: 32 additions & 23 deletions pytest_mypy_plugins/logic/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,41 @@


def parse_parametrized(
params: list[Mapping[str, Any]],
parameters_case: list[Mapping[str, Any]],
) -> list[Mapping[str, Any]]:
if not params:
if not parameters_case:
return [{}]

parsed_params: list[Mapping[str, Any]] = []
expected_keys: set[str] = set(sorted(params[0].keys()))
for idx, param in enumerate(params):
param_keys = set(sorted(param.keys()))
if expected_keys.intersection(param_keys) != expected_keys:
raise ValueError(
"All parametrized entries must have same keys."
f'First entry is {", ".join(expected_keys)} but {", ".join(param_keys)} '
f"was spotted at {idx} position",
)
parsed_params.append(
{k: v for k, v in param.items() if not k.startswith("__")}
)
expected_keys: set[str] = set(sorted(parameters_case[0].keys()))
return [
_parse_param_case(param_case, idx, expected_keys)
for idx, param_case in enumerate(parameters_case)
]


return parsed_params
def _parse_param_case(
param_case: Mapping[str, Any], idx: int, expected_keys: set[str]
) -> Mapping[str, Any]:
param_keys = set(sorted(param_case.keys()))
if expected_keys.intersection(param_keys) != expected_keys:
raise ValueError(
"All parametrized entries must have same keys." # noqa: WPS237
f'First entry is {", ".join(expected_keys)} but {", ".join(param_keys)} '
f"was spotted at {idx} position",
)
return {
param_key: param_value
for param_key, param_value in param_case.items()
if not param_key.startswith("__")
}


def parse_test_files(test_files: list[dict[str, Any]]) -> list[File]:
files: list[File] = []
for test_file in test_files:
path = test_file.get("path", "main.py")
file = File(path=path, content=test_file.get("content", ""))
files.append(file)
return files
def parse_test_files(file_specs: list[dict[str, Any]]) -> list[File]:
"""Convert raw YAML file entries into ``File`` objects."""
return [
File(
path=file_info.get("path", "main.py"),
content=file_info.get("content", ""),
)
for file_info in file_specs
]
88 changes: 87 additions & 1 deletion pytest_mypy_plugins/logic/text.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import re
from typing import Final

_ALIGNMENT_MARKER: Final = "^"
_TRUNCATION_MARKER: Final = "..."
_TRUNCATION_STEP: Final = 10
_TRUNCATION_WINDOW: Final = 30
_MIN_ALIGNMENT_STRING_LENGTH: Final = 4
_MAX_ALIGNMENT_WIDTH: Final = 72


def format_mismatched_line(line: str) -> str:
return f" {str(line):<45} (diff)"


def format_matched_line(line: str, width: int = 100) -> str:
return f" {line[:width]}..." if len(line) > width else f" {line}"
line = line[:width]
return f" {line}..." if len(line) > width else f" {line}"


def format_error_lines(lines: list[str]) -> str:
Expand All @@ -12,3 +24,77 @@ def format_error_lines(lines: list[str]) -> str:

def remove_empty_lines(lines: list[str]) -> list[str]:
return list(filter(None, lines))


def add_aligned_message(word1: str, word2: str, error_message: str) -> str:
"""Align word1 and word2 so that the their first difference is highlighted.

For example, if word1 is 'foobar' and word2 is 'fobar', display the
following lines:

E: foobar
A: fobar
^

If word1 and word2 are long, only display a fragment of the strings around the
first difference. If word1 is very short, do nothing.
"""

# Seeing what went wrong is trivial even without alignment if the expected
# string is very short. In this case do nothing to simplify output.
if len(word1) < _MIN_ALIGNMENT_STRING_LENGTH:
return error_message

error_message += "Alignment of first line difference:\n" # noqa: WPS336

assert word1 != word2

trunc = False
while word1[:_TRUNCATION_WINDOW] == word2[:_TRUNCATION_WINDOW]:
word1 = word1[_TRUNCATION_STEP:]
word2 = word2[_TRUNCATION_STEP:]
trunc = True

if trunc:
word1 = _TRUNCATION_MARKER + word1
word2 = _TRUNCATION_MARKER + word2

max_len = max(len(word1), len(word2))
extra = _TRUNCATION_MARKER if max_len > _MAX_ALIGNMENT_WIDTH else ""
word1_format = word1[:_MAX_ALIGNMENT_WIDTH] + extra
word2_format = word2[:_MAX_ALIGNMENT_WIDTH] + extra

# Write a chunk of both lines, aligned.
error_message += f" E: {word1_format}\n" # noqa: WPS336
error_message += f" A: {word2_format}\n" # noqa: WPS336
# Write an indicator character under the different columns.
error_message += _format_alignment_pointer(word1, word2, max_len)
return error_message


def remove_common_prefix(lines: list[str]) -> list[str]:
"""Remove common directory prefix from all strings in a.

This uses a naive string replace; it seems to work well enough. Also
remove trailing carriage returns.
"""
cleaned_lines = []
for line in lines:
# Ignore spaces at end of line.
line = re.sub(" +$", "", line)
cleaned_lines.append(re.sub("\\r$", "", line))
return cleaned_lines


def _format_alignment_pointer(word1: str, word2: str, max_len: int) -> str:
error_message = [" "]
for pos in range(min(_MAX_ALIGNMENT_WIDTH, max_len)):
word1_pos = word1[pos : pos + 1]
word2_pos = word2[pos : pos + 1]
if word1_pos == word2_pos:
error_message.append(" ")
else:
error_message.append(_ALIGNMENT_MARKER)
break
error_message.append("\n")
return "".join(error_message)
5 changes: 2 additions & 3 deletions pytest_mypy_plugins/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
import pytest

from pytest_mypy_plugins import utils
from pytest_mypy_plugins.logic.files import sorted_by_file_and_line
from pytest_mypy_plugins.types import OutputMatcher, TypecheckAssertionError
from pytest_mypy_plugins.utils import (
OutputMatcher,
TypecheckAssertionError,
assert_expected_matched_actual,
extract_output_matchers_from_comments,
sorted_by_file_and_line,
)


Expand Down
Loading
Loading