diff --git a/pyproject.toml b/pyproject.toml index eb58fef..7483076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "attrs>=19.0", "filelock>=3.0", "mypy>=1.0", - "pytest>=4.6", + "pytest>=7.0", ] [project.entry-points.pytest11] diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index 4a272ff..76eb0af 100644 --- a/src/pytest_mypy.py +++ b/src/pytest_mypy.py @@ -1,7 +1,6 @@ """Mypy static type checker plugin for Pytest""" import json -import os from pathlib import Path from tempfile import NamedTemporaryFile from typing import Dict, List, Optional, TextIO @@ -10,10 +9,9 @@ import attr from filelock import FileLock # type: ignore import mypy.api -import pytest # type: ignore +import pytest -PYTEST_MAJOR_VERSION = int(pytest.__version__.partition(".")[0]) mypy_argv = [] nodeid_name = "mypy" terminal_summary_title = "mypy" @@ -126,27 +124,9 @@ def pytest_collect_file(file_path, parent): return None -if PYTEST_MAJOR_VERSION < 7: # pragma: no cover - _pytest_collect_file = pytest_collect_file - - def pytest_collect_file(path, parent): # type: ignore - try: - # https://docs.pytest.org/en/7.0.x/deprecations.html#py-path-local-arguments-for-hooks-replaced-with-pathlib-path - return _pytest_collect_file(Path(str(path)), parent) - except TypeError: - # https://docs.pytest.org/en/7.0.x/deprecations.html#fspath-argument-for-node-constructors-replaced-with-pathlib-path - return MypyFile.from_parent(parent=parent, fspath=path) - - class MypyFile(pytest.File): """A File that Mypy will run on.""" - @classmethod - def from_parent(cls, *args, **kwargs): - """Override from_parent for compatibility.""" - # pytest.File.from_parent did not exist before pytest 5.4. - return getattr(super(), "from_parent", cls)(*args, **kwargs) - def collect(self): """Create a MypyFileItem for the File.""" yield MypyFileItem.from_parent(parent=self, name=nodeid_name) @@ -169,19 +149,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_marker(self.MARKER) - def collect(self): - """ - Partially work around https://github.com/pytest-dev/pytest/issues/8016 - for pytest < 6.0 with --looponfail. - """ - yield self - - @classmethod - def from_parent(cls, *args, **kwargs): - """Override from_parent for compatibility.""" - # pytest.Item.from_parent did not exist before pytest 5.4. - return getattr(super(), "from_parent", cls)(*args, **kwargs) - def repr_failure(self, excinfo): """ Unwrap mypy errors so we get a clean error message without the @@ -198,7 +165,7 @@ class MypyFileItem(MypyItem): def runtest(self): """Raise an exception if mypy found errors for this item.""" results = MypyResults.from_session(self.session) - abspath = os.path.abspath(str(self.fspath)) + abspath = str(self.path.absolute()) errors = results.abspath_errors.get(abspath) if errors: if not all( @@ -211,9 +178,9 @@ def runtest(self): def reportinfo(self): """Produce a heading for the test report.""" return ( - self.fspath, + self.path, None, - self.config.invocation_dir.bestrelpath(self.fspath), + str(self.path.relative_to(self.config.invocation_params.dir)), ) @@ -258,16 +225,15 @@ def from_mypy( ) -> "MypyResults": """Generate results from mypy.""" - # 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 + if opts is None: opts = mypy_argv[:] abspath_errors = { str(path.absolute()): [] for path in paths } # type: MypyResults._abspath_errors_type + cwd = Path.cwd() stdout, stderr, status = mypy.api.run( - opts + [os.path.relpath(key) for key in abspath_errors.keys()] + opts + [str(Path(key).relative_to(cwd)) for key in abspath_errors.keys()] ) unmatched_lines = [] @@ -275,7 +241,7 @@ def from_mypy( if not line: continue path, _, error = line.partition(":") - abspath = os.path.abspath(path) + abspath = str(Path(path).absolute()) try: abspath_errors[abspath].append(error) except KeyError: @@ -305,7 +271,7 @@ def from_session(cls, session) -> "MypyResults": except FileNotFoundError: results = cls.from_mypy( [ - Path(item.fspath) + item.path for item in session.items if isinstance(item, MypyFileItem) ], @@ -344,4 +310,4 @@ def pytest_terminal_summary(terminalreporter, config): terminalreporter.write_line(results.unmatched_stdout, **color) if results.stderr: terminalreporter.write_line(results.stderr, yellow=True) - os.remove(config._mypy_results_path) + Path(config._mypy_results_path).unlink() diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index e5b9670..1192600 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -53,12 +53,13 @@ def pyfunc(x: int) -> int: ) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = pyfile_count mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_pyi(testdir, xdist_args): @@ -89,7 +90,7 @@ def pyfunc(x: int) -> int: ... mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_error(testdir, xdist_args): @@ -103,14 +104,15 @@ def pyfunc(x: int) -> str: result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() assert "_mypy_results_path" not in result.stderr.str() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED 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(failed=mypy_checks) result.stdout.fnmatch_lines(["2: error: Incompatible return value*"]) - assert result.ret != 0 assert "_mypy_results_path" not in result.stderr.str() + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_mypy_annotation_unchecked(testdir, xdist_args, tmp_path, monkeypatch): @@ -131,7 +133,7 @@ def pyfunc(x): outcomes = {"passed": mypy_checks} result.assert_outcomes(**outcomes) result.stdout.fnmatch_lines(["*MypyWarning*"]) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_ignore_missings_imports(testdir, xdist_args): @@ -162,10 +164,10 @@ def test_mypy_ignore_missings_imports(testdir, xdist_args): ), ], ) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED result = testdir.runpytest_subprocess("--mypy-ignore-missing-imports", *xdist_args) result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_config_file(testdir, xdist_args): @@ -181,7 +183,7 @@ def pyfunc(x): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK mypy_config_file = testdir.makeini( """ [mypy] @@ -210,10 +212,10 @@ def test_fails(): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(failed=test_count, passed=mypy_checks) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED result = testdir.runpytest_subprocess("--mypy", "-m", "mypy", *xdist_args) result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_non_mypy_error(testdir, xdist_args): @@ -235,6 +237,7 @@ def runtest(self): ) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 # conftest.py mypy_status_check = 1 @@ -243,7 +246,7 @@ def runtest(self): passed=mypy_status_check, # conftest.py has no type errors. ) result.stdout.fnmatch_lines(["*" + message]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_mypy_stderr(testdir, xdist_args): @@ -294,7 +297,7 @@ def pytest_configure(config): """, ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_api_nodeid_name(testdir, xdist_args): @@ -311,7 +314,7 @@ def pytest_configure(config): ) result = testdir.runpytest_subprocess("--mypy", "--verbose", *xdist_args) result.stdout.fnmatch_lines(["*conftest.py::" + nodeid_name + "*"]) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK @pytest.mark.xfail( @@ -352,7 +355,7 @@ def pyfunc(x: int) -> str: mypy_file_checks = 1 mypy_status_check = 1 result.assert_outcomes(passed=mypy_file_checks, failed=mypy_status_check) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_api_error_formatter(testdir, xdist_args): @@ -381,7 +384,7 @@ def pytest_configure(config): ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) result.stdout.fnmatch_lines(["*/bad.py:2: error: Incompatible return value*"]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_pyproject_toml(testdir, xdist_args): @@ -401,7 +404,7 @@ def pyfunc(x): ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) result.stdout.fnmatch_lines(["1: error: Function is missing a type annotation*"]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_setup_cfg(testdir, xdist_args): @@ -421,7 +424,7 @@ def pyfunc(x): ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) result.stdout.fnmatch_lines(["1: error: Function is missing a type annotation*"]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED @pytest.mark.parametrize("module_name", ["__init__", "test_demo"]) @@ -537,30 +540,6 @@ def _break(): child.kill(signal.SIGTERM) -def test_mypy_item_collect(testdir, xdist_args): - """Ensure coverage for a 3.10<=pytest<6.0 workaround.""" - testdir.makepyfile( - """ - def test_mypy_item_collect(request): - plugin = request.config.pluginmanager.getplugin("mypy") - mypy_items = [ - item - for item in request.session.items - if isinstance(item, plugin.MypyItem) - ] - assert mypy_items - for mypy_item in mypy_items: - assert all(item is mypy_item for item in mypy_item.collect()) - """, - ) - result = testdir.runpytest_subprocess("--mypy", *xdist_args) - test_count = 1 - mypy_file_checks = 1 - mypy_status_check = 1 - result.assert_outcomes(passed=test_count + mypy_file_checks + mypy_status_check) - assert result.ret == 0 - - def test_mypy_results_from_mypy_with_opts(): """MypyResults.from_mypy respects passed options.""" mypy_results = pytest_mypy.MypyResults.from_mypy([], opts=["--version"]) @@ -610,5 +589,5 @@ def pytest_terminal_summary(config): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK assert f"= {pytest_mypy.terminal_summary_title} =" not in str(result.stdout) diff --git a/tox.ini b/tox.ini index f8ebf12..0a6adcc 100644 --- a/tox.ini +++ b/tox.ini @@ -3,33 +3,27 @@ minversion = 4.4 isolated_build = true envlist = - py37-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{1.0, 1.x} - py38-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - py39-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - py310-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - py311-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - py312-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + py37-pytest{7.0, 7.x}-mypy{1.0, 1.x} + py38-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + py39-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + py310-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + py311-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + py312-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} publish static [gh-actions] python = - 3.7: py37-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{1.0, 1.x} - 3.8: py38-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}, publish, static - 3.9: py39-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - 3.10: py310-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - 3.11: py311-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} - 3.12: py312-pytest{6.2, 6.x, 7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + 3.7: py37-pytest{7.0, 7.x}-mypy{1.0, 1.x} + 3.8: py38-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}, publish, static + 3.9: py39-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + 3.10: py310-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + 3.11: py311-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} + 3.12: py312-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x} [testenv] constrain_package_deps = true deps = - pytest4.6: pytest ~= 4.6.0 - pytest5.0: pytest ~= 5.0.0 - pytest5.x: pytest ~= 5.0 - pytest6.0: pytest ~= 6.0.0 - pytest6.2: pytest ~= 6.2.0 - pytest6.x: pytest ~= 6.0 pytest7.0: pytest ~= 7.0.0 pytest7.x: pytest ~= 7.0 pytest8.0: pytest ~= 8.0.0