diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index 7d8e1cb..08b26e2 100644 --- a/src/pytest_mypy.py +++ b/src/pytest_mypy.py @@ -16,6 +16,7 @@ PYTEST_MAJOR_VERSION = int(pytest.__version__.partition(".")[0]) mypy_argv = [] nodeid_name = "mypy" +terminal_summary_title = "mypy" def default_file_error_formatter(item, results, errors): @@ -59,10 +60,10 @@ def _get_xdist_workerinput(config_node): return workerinput -def _is_master(config): +def _is_xdist_controller(config): """ True if the code running the given pytest.config object is running in - an xdist master node or not running xdist at all. + an xdist controller node or not running xdist at all. """ return _get_xdist_workerinput(config) is None @@ -73,7 +74,7 @@ def pytest_configure(config): register a custom marker for MypyItems, and configure the plugin based on the CLI. """ - if _is_master(config): + if _is_xdist_controller(config): # Get the path to a temporary file and delete it. # The first MypyItem to run will see the file does not exist, @@ -205,8 +206,7 @@ def runtest(self): for error in errors ): raise MypyError(file_error_formatter(self, results, errors)) - # This line cannot be easily covered on mypy < 0.990: - warnings.warn("\n" + "\n".join(errors), MypyWarning) # pragma: no cover + warnings.warn("\n" + "\n".join(errors), MypyWarning) def reportinfo(self): """Produce a heading for the test report.""" @@ -258,7 +258,9 @@ def from_mypy( ) -> "MypyResults": """Generate results from mypy.""" - if opts is None: + # This is covered by test_mypy_results_from_mypy_with_opts; + # however, coverage is not recognized on py38-pytest4.6: + if opts is None: # pragma: no cover opts = mypy_argv[:] abspath_errors = { os.path.abspath(str(item.fspath)): [] for item in items @@ -293,7 +295,7 @@ def from_session(cls, session) -> "MypyResults": """Load (or generate) cached mypy results for a pytest session.""" results_path = ( session.config._mypy_results_path - if _is_master(session.config) + if _is_xdist_controller(session.config) else _get_xdist_workerinput(session.config)["_mypy_results_path"] ) with FileLock(results_path + ".lock"): @@ -322,18 +324,20 @@ class MypyWarning(pytest.PytestWarning): def pytest_terminal_summary(terminalreporter, config): """Report stderr and unrecognized lines from stdout.""" - if _is_master(config): - try: - with open(config._mypy_results_path, mode="r") as results_f: - results = MypyResults.load(results_f) - except FileNotFoundError: - # No MypyItems executed. - return - if results.unmatched_stdout or results.stderr: - terminalreporter.section("mypy") - 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) - os.remove(config._mypy_results_path) + if not _is_xdist_controller(config): + # This isn't hit in pytest 5.0 for some reason. + return # pragma: no cover + try: + with open(config._mypy_results_path, mode="r") as results_f: + results = MypyResults.load(results_f) + except FileNotFoundError: + # No MypyItems executed. + return + if results.unmatched_stdout or results.stderr: + terminalreporter.section(terminal_summary_title) + 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) + os.remove(config._mypy_results_path) diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index bf88847..03d2b24 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -1,4 +1,5 @@ import signal +import sys import textwrap import mypy.version @@ -6,9 +7,21 @@ import pexpect import pytest +import pytest_mypy + MYPY_VERSION = Version(mypy.version.__version__) PYTEST_VERSION = Version(pytest.__version__) +PYTHON_VERSION = Version( + ".".join( + str(token) + for token in [ + sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro, + ] + ) +) @pytest.fixture( @@ -100,7 +113,7 @@ def pyfunc(x: int) -> str: assert "_mypy_results_path" not in result.stderr.str() -def test_mypy_annotation_unchecked(testdir, xdist_args): +def test_mypy_annotation_unchecked(testdir, xdist_args, tmp_path, monkeypatch): """Verify that annotation-unchecked warnings do not manifest as an error.""" testdir.makepyfile( """ @@ -109,6 +122,29 @@ def pyfunc(x): return x * y """, ) + min_mypy_version = Version("0.990") + if MYPY_VERSION < min_mypy_version: + # mypy doesn't emit annotation-unchecked warnings until 0.990: + fake_mypy_path = tmp_path / "mypy" + fake_mypy_path.mkdir() + (fake_mypy_path / "__init__.py").touch() + (fake_mypy_path / "api.py").write_text( + textwrap.dedent( + """ + def run(*args, **kwargs): + return ( + "test_mypy_annotation_unchecked.py:2:" + " note: By default the bodies of untyped functions" + " are not checked, consider using --check-untyped-defs" + " [annotation-unchecked]\\nSuccess: no issues found in" + " 1 source file\\n", + "", + 0, + ) + """ + ) + ) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() result = testdir.runpytest_subprocess("--mypy", *xdist_args) @@ -552,3 +588,68 @@ def test_mypy_item_collect(request): mypy_status_check = 1 result.assert_outcomes(passed=test_count + mypy_file_checks + mypy_status_check) assert result.ret == 0 + + +@pytest.mark.xfail( + MYPY_VERSION < Version("0.750"), + raises=AssertionError, + reason="https://github.com/python/mypy/issues/7800", +) +def test_mypy_results_from_mypy_with_opts(): + """MypyResults.from_mypy respects passed options.""" + mypy_results = pytest_mypy.MypyResults.from_mypy([], opts=["--version"]) + assert mypy_results.status == 0 + assert mypy_results.abspath_errors == {} + assert str(MYPY_VERSION) in mypy_results.stdout + + +@pytest.mark.xfail( + Version("3.7") < PYTHON_VERSION < Version("3.9") + and Version("0.710") <= MYPY_VERSION < Version("0.720"), + raises=AssertionError, + reason="Mypy crashes for some reason.", +) +def test_mypy_no_output(testdir, xdist_args): + """No terminal summary is shown if there is no output from mypy.""" + type_ignore = ( + "# type: ignore" + if ( + PYTEST_VERSION + < Version("6.0") # Pytest didn't add type annotations until 6.0. + or MYPY_VERSION < Version("0.710") + ) + else "" + ) + testdir.makepyfile( + # Mypy prints a success message to stderr by default: + # "Success: no issues found in 1 source file" + # Clear stderr and unmatched_stdout to simulate mypy having no output: + conftest=f""" + import pytest {type_ignore} + + @pytest.hookimpl(hookwrapper=True) + def pytest_terminal_summary(config): + mypy_results_path = getattr(config, "_mypy_results_path", None) + if not mypy_results_path: + # xdist worker + return + pytest_mypy = config.pluginmanager.getplugin("mypy") + with open(mypy_results_path, mode="w") as results_f: + pytest_mypy.MypyResults( + opts=[], + stdout="", + stderr="", + status=0, + abspath_errors={{}}, + unmatched_stdout="", + ).dump(results_f) + yield + """, + ) + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + mypy_file_checks = 1 + mypy_status_check = 1 + mypy_checks = mypy_file_checks + mypy_status_check + result.assert_outcomes(passed=mypy_checks) + assert result.ret == 0 + assert f"= {pytest_mypy.terminal_summary_title} =" not in str(result.stdout) diff --git a/tox.ini b/tox.ini index ae6f422..090085e 100644 --- a/tox.ini +++ b/tox.ini @@ -44,11 +44,11 @@ deps = packaging ~= 21.3 pexpect ~= 4.8.0 - pytest-cov ~= 2.10 + pytest-cov ~= 4.1.0 pytest-randomly ~= 3.4 pytest-xdist ~= 1.34 -commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-fail-under 100 --cov-report term-missing -n auto} +commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-branch --cov-fail-under 100 --cov-report term-missing -n auto} [pytest] testpaths = tests