Skip to content

Commit

Permalink
Merge pull request #171 from dmtucker/pytest7
Browse files Browse the repository at this point in the history
Require Pytest 7+
  • Loading branch information
dmtucker authored Aug 11, 2024
2 parents 6daa0e1 + 0ac85af commit 02795e2
Show file tree
Hide file tree
Showing 4 changed files with 43 additions and 104 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"attrs>=19.0",
"filelock>=3.0",
"mypy>=1.0",
"pytest>=4.6",
"pytest>=7.0",
]

[project.entry-points.pytest11]
Expand Down
54 changes: 10 additions & 44 deletions src/pytest_mypy.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand 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)),
)


Expand Down Expand Up @@ -258,24 +225,23 @@ 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 = []
for line in stdout.split("\n"):
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:
Expand Down Expand Up @@ -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)
],
Expand Down Expand Up @@ -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()
61 changes: 20 additions & 41 deletions tests/test_pytest_mypy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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]
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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"])
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
30 changes: 12 additions & 18 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 02795e2

Please sign in to comment.