Skip to content

Commit 86aa890

Browse files
committed
Make MypyResults line-based
1 parent fc441ba commit 86aa890

File tree

2 files changed

+57
-39
lines changed

2 files changed

+57
-39
lines changed

src/pytest_mypy/__init__.py

+48-34
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,10 @@ def repr_failure(
235235
return super().repr_failure(excinfo)
236236

237237

238-
def _error_severity(error: str) -> str:
239-
components = [component.strip() for component in error.split(":")]
238+
def _error_severity(line: str) -> Optional[str]:
239+
components = [component.strip() for component in line.split(":", 3)]
240+
if len(components) < 2:
241+
return None
240242
# The second component is either the line or the severity:
241243
# demo/note.py:2: note: By default the bodies of untyped functions are not checked
242244
# demo/sub/conftest.py: error: Duplicate module named "conftest"
@@ -249,20 +251,22 @@ class MypyFileItem(MypyItem):
249251
def runtest(self) -> None:
250252
"""Raise an exception if mypy found errors for this item."""
251253
results = MypyResults.from_session(self.session)
252-
abspath = str(self.path.resolve())
253-
errors = [
254-
error.partition(":")[2].strip()
255-
for error in results.abspath_errors.get(abspath, [])
256-
]
257-
if errors and not all(_error_severity(error) == "note" for error in errors):
254+
lines = results.path_lines.get(self.path.resolve(), [])
255+
if lines and not all(_error_severity(line) == "note" for line in lines):
258256
if self.session.config.option.mypy_xfail:
259257
self.add_marker(
260258
pytest.mark.xfail(
261259
raises=MypyError,
262260
reason="mypy errors are expected by --mypy-xfail.",
263261
)
264262
)
265-
raise MypyError(file_error_formatter(self, results, errors))
263+
raise MypyError(
264+
file_error_formatter(
265+
self,
266+
results,
267+
errors=[line.partition(":")[2].strip() for line in lines],
268+
)
269+
)
266270

267271
def reportinfo(self) -> Tuple[str, None, str]:
268272
"""Produce a heading for the test report."""
@@ -296,24 +300,32 @@ def runtest(self) -> None:
296300
class MypyResults:
297301
"""Parsed results from Mypy."""
298302

299-
_abspath_errors_type = typing.Dict[str, typing.List[str]]
300303
_encoding = "utf-8"
301304

302305
opts: List[str]
306+
args: List[str]
303307
stdout: str
304308
stderr: str
305309
status: int
306-
abspath_errors: _abspath_errors_type
307-
unmatched_stdout: str
310+
path_lines: Dict[Optional[Path], List[str]]
308311

309312
def dump(self, results_f: IO[bytes]) -> None:
310313
"""Cache results in a format that can be parsed by load()."""
311-
results_f.write(json.dumps(vars(self)).encode(self._encoding))
314+
prepared = vars(self).copy()
315+
prepared["path_lines"] = {
316+
str(path or ""): lines for path, lines in prepared["path_lines"].items()
317+
}
318+
results_f.write(json.dumps(prepared).encode(self._encoding))
312319

313320
@classmethod
314321
def load(cls, results_f: IO[bytes]) -> MypyResults:
315322
"""Get results cached by dump()."""
316-
return cls(**json.loads(results_f.read().decode(cls._encoding)))
323+
prepared = json.loads(results_f.read().decode(cls._encoding))
324+
prepared["path_lines"] = {
325+
Path(path) if path else None: lines
326+
for path, lines in prepared["path_lines"].items()
327+
}
328+
return cls(**prepared)
317329

318330
@classmethod
319331
def from_mypy(
@@ -326,33 +338,31 @@ def from_mypy(
326338

327339
if opts is None:
328340
opts = mypy_argv[:]
329-
abspath_errors = {
330-
str(path.resolve()): [] for path in paths
331-
} # type: MypyResults._abspath_errors_type
341+
args = [str(path) for path in paths]
332342

333-
cwd = Path.cwd()
334-
stdout, stderr, status = mypy.api.run(
335-
opts + [str(Path(key).relative_to(cwd)) for key in abspath_errors.keys()]
336-
)
343+
stdout, stderr, status = mypy.api.run(opts + args)
337344

338-
unmatched_lines = []
345+
path_lines: Dict[Optional[Path], List[str]] = {
346+
path.resolve(): [] for path in paths
347+
}
348+
path_lines[None] = []
339349
for line in stdout.split("\n"):
340350
if not line:
341351
continue
342-
path, _, error = line.partition(":")
343-
abspath = str(Path(path).resolve())
352+
path = Path(line.partition(":")[0]).resolve()
344353
try:
345-
abspath_errors[abspath].append(line)
354+
lines = path_lines[path]
346355
except KeyError:
347-
unmatched_lines.append(line)
356+
lines = path_lines[None]
357+
lines.append(line)
348358

349359
return cls(
350360
opts=opts,
361+
args=args,
351362
stdout=stdout,
352363
stderr=stderr,
353364
status=status,
354-
abspath_errors=abspath_errors,
355-
unmatched_stdout="\n".join(unmatched_lines),
365+
path_lines=path_lines,
356366
)
357367

358368
@classmethod
@@ -364,9 +374,10 @@ def from_session(cls, session: pytest.Session) -> MypyResults:
364374
with open(mypy_results_path, mode="rb") as results_f:
365375
results = cls.load(results_f)
366376
except FileNotFoundError:
377+
cwd = Path.cwd()
367378
results = cls.from_mypy(
368379
[
369-
item.path
380+
item.path.relative_to(cwd)
370381
for item in session.items
371382
if isinstance(item, MypyFileItem)
372383
],
@@ -408,14 +419,17 @@ def pytest_terminal_summary(
408419
else:
409420
for note in (
410421
unreported_note
411-
for errors in results.abspath_errors.values()
412-
if all(_error_severity(error) == "note" for error in errors)
413-
for unreported_note in errors
422+
for path, lines in results.path_lines.items()
423+
if path is not None
424+
if all(_error_severity(line) == "note" for line in lines)
425+
for unreported_note in lines
414426
):
415427
terminalreporter.write_line(note)
416-
if results.unmatched_stdout:
428+
if results.path_lines.get(None):
417429
color = {"red": True} if results.status else {"green": True}
418-
terminalreporter.write_line(results.unmatched_stdout, **color)
430+
terminalreporter.write_line(
431+
"\n".join(results.path_lines[None]), **color
432+
)
419433
if results.stderr:
420434
terminalreporter.write_line(results.stderr, yellow=True)
421435

tests/test_pytest_mypy.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,6 @@ def test_mypy_results_from_mypy_with_opts():
532532
"""MypyResults.from_mypy respects passed options."""
533533
mypy_results = pytest_mypy.MypyResults.from_mypy([], opts=["--version"])
534534
assert mypy_results.status == 0
535-
assert mypy_results.abspath_errors == {}
536535
assert str(MYPY_VERSION) in mypy_results.stdout
537536

538537

@@ -552,11 +551,11 @@ def pytest_configure(config):
552551
with open(mypy_config_stash.mypy_results_path, mode="wb") as results_f:
553552
pytest_mypy.MypyResults(
554553
opts=[],
554+
args=[],
555555
stdout="",
556556
stderr="",
557557
status=0,
558-
abspath_errors={},
559-
unmatched_stdout="",
558+
path_lines={},
560559
).dump(results_f)
561560
""",
562561
)
@@ -630,11 +629,11 @@ def pytest_configure(config):
630629
with open(mypy_config_stash.mypy_results_path, mode="wb") as results_f:
631630
pytest_mypy.MypyResults(
632631
opts=[],
632+
args=[],
633633
stdout="{stdout}",
634634
stderr="",
635635
status=0,
636-
abspath_errors={{}},
637-
unmatched_stdout="",
636+
path_lines={{}},
638637
).dump(results_f)
639638
""",
640639
)
@@ -644,3 +643,8 @@ def pytest_configure(config):
644643
result = testdir.runpytest_subprocess("--mypy-xfail", *xdist_args)
645644
assert result.ret == pytest.ExitCode.OK
646645
assert stdout in result.stdout.str()
646+
647+
648+
def test_error_severity():
649+
"""Verify that non-error lines produce no severity."""
650+
assert pytest_mypy._error_severity("arbitrary line with no error") is None

0 commit comments

Comments
 (0)