diff --git a/src/pytest_mypy/__init__.py b/src/pytest_mypy/__init__.py index 699a900..d6a0172 100644 --- a/src/pytest_mypy/__init__.py +++ b/src/pytest_mypy/__init__.py @@ -7,7 +7,6 @@ from pathlib import Path from tempfile import NamedTemporaryFile import typing -import warnings from filelock import FileLock import mypy.api @@ -227,28 +226,34 @@ def repr_failure( return super().repr_failure(excinfo) +def _error_severity(error: str) -> str: + components = [component.strip() for component in error.split(":")] + # The second component is either the line or the severity: + # demo/note.py:2: note: By default the bodies of untyped functions are not checked + # demo/sub/conftest.py: error: Duplicate module named "conftest" + return components[2] if components[1].isdigit() else components[1] + + class MypyFileItem(MypyItem): """A check for Mypy errors in a File.""" def runtest(self) -> None: """Raise an exception if mypy found errors for this item.""" results = MypyResults.from_session(self.session) - abspath = str(self.path.absolute()) - errors = results.abspath_errors.get(abspath) - if errors: - if not all( - error.partition(":")[2].partition(":")[0].strip() == "note" - for error in errors - ): - if self.session.config.option.mypy_xfail: - self.add_marker( - pytest.mark.xfail( - raises=MypyError, - reason="mypy errors are expected by --mypy-xfail.", - ) + abspath = str(self.path.resolve()) + errors = [ + error.partition(":")[2].strip() + for error in results.abspath_errors.get(abspath, []) + ] + if errors and not all(_error_severity(error) == "note" for error in errors): + if self.session.config.option.mypy_xfail: + self.add_marker( + pytest.mark.xfail( + raises=MypyError, + reason="mypy errors are expected by --mypy-xfail.", ) - raise MypyError(file_error_formatter(self, results, errors)) - warnings.warn("\n" + "\n".join(errors), MypyWarning) + ) + raise MypyError(file_error_formatter(self, results, errors)) def reportinfo(self) -> Tuple[str, None, str]: """Produce a heading for the test report.""" @@ -312,7 +317,7 @@ def from_mypy( if opts is None: opts = mypy_argv[:] abspath_errors = { - str(path.absolute()): [] for path in paths + str(path.resolve()): [] for path in paths } # type: MypyResults._abspath_errors_type cwd = Path.cwd() @@ -325,9 +330,9 @@ def from_mypy( if not line: continue path, _, error = line.partition(":") - abspath = str(Path(path).absolute()) + abspath = str(Path(path).resolve()) try: - abspath_errors[abspath].append(error) + abspath_errors[abspath].append(line) except KeyError: unmatched_lines.append(line) @@ -368,10 +373,6 @@ class MypyError(Exception): """ -class MypyWarning(pytest.PytestWarning): - """A non-failure message regarding the mypy run.""" - - class MypyControllerPlugin: """A plugin that is not registered on xdist worker processes.""" @@ -388,15 +389,25 @@ def pytest_terminal_summary( except FileNotFoundError: # No MypyItems executed. return - if config.option.mypy_xfail or results.unmatched_stdout or results.stderr: - terminalreporter.section(terminal_summary_title) + if not results.stdout and not results.stderr: + return + terminalreporter.section(terminal_summary_title) + if results.stdout: if config.option.mypy_xfail: terminalreporter.write(results.stdout) - elif results.unmatched_stdout: - color = {"red": True} if results.status else {"green": True} - terminalreporter.write_line(results.unmatched_stdout, **color) - if results.stderr: - terminalreporter.write_line(results.stderr, yellow=True) + else: + for note in ( + unreported_note + for errors in results.abspath_errors.values() + if all(_error_severity(error) == "note" for error in errors) + for unreported_note in errors + ): + terminalreporter.write_line(note) + if results.unmatched_stdout: + color = {"red": True} if results.status else {"green": True} + terminalreporter.write_line(results.unmatched_stdout, **color) + if results.stderr: + terminalreporter.write_line(results.stderr, yellow=True) def pytest_unconfigure(self, config: pytest.Config) -> None: """Clean up the mypy results path.""" diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index 6ee40a0..ed909c7 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -130,7 +130,9 @@ def pyfunc(x): mypy_checks = mypy_file_checks + mypy_status_check outcomes = {"passed": mypy_checks} result.assert_outcomes(**outcomes) - result.stdout.fnmatch_lines(["*MypyWarning*"]) + result.stdout.fnmatch_lines( + ["*:2: note: By default the bodies of untyped functions are not checked*"] + ) assert result.ret == pytest.ExitCode.OK @@ -552,7 +554,7 @@ def test_py_typed(testdir): def test_mypy_no_status_check(testdir, xdist_args): """Verify that --mypy-no-status-check disables MypyStatusItem collection.""" - testdir.makepyfile(thon="one: int = 1") + testdir.makepyfile("one: int = 1") result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 mypy_status_check = 1 @@ -565,7 +567,7 @@ def test_mypy_no_status_check(testdir, xdist_args): def test_mypy_xfail_passes(testdir, xdist_args): """Verify that --mypy-xfail passes passes.""" - testdir.makepyfile(thon="one: int = 1") + testdir.makepyfile("one: int = 1") result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 mypy_status_check = 1 @@ -578,7 +580,7 @@ def test_mypy_xfail_passes(testdir, xdist_args): def test_mypy_xfail_xfails(testdir, xdist_args): """Verify that --mypy-xfail xfails failures.""" - testdir.makepyfile(thon="one: str = 1") + testdir.makepyfile("one: str = 1") result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 mypy_status_check = 1