Skip to content

Require Pytest 7+ #171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading