Skip to content

Commit 930086c

Browse files
authored
Merge pull request #159 from dmtucker/branch-cov
Enable branch code coverage
2 parents a266479 + 5c63cb8 commit 930086c

File tree

3 files changed

+130
-25
lines changed

3 files changed

+130
-25
lines changed

src/pytest_mypy.py

+26-22
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
PYTEST_MAJOR_VERSION = int(pytest.__version__.partition(".")[0])
1717
mypy_argv = []
1818
nodeid_name = "mypy"
19+
terminal_summary_title = "mypy"
1920

2021

2122
def default_file_error_formatter(item, results, errors):
@@ -59,10 +60,10 @@ def _get_xdist_workerinput(config_node):
5960
return workerinput
6061

6162

62-
def _is_master(config):
63+
def _is_xdist_controller(config):
6364
"""
6465
True if the code running the given pytest.config object is running in
65-
an xdist master node or not running xdist at all.
66+
an xdist controller node or not running xdist at all.
6667
"""
6768
return _get_xdist_workerinput(config) is None
6869

@@ -73,7 +74,7 @@ def pytest_configure(config):
7374
register a custom marker for MypyItems,
7475
and configure the plugin based on the CLI.
7576
"""
76-
if _is_master(config):
77+
if _is_xdist_controller(config):
7778

7879
# Get the path to a temporary file and delete it.
7980
# The first MypyItem to run will see the file does not exist,
@@ -205,8 +206,7 @@ def runtest(self):
205206
for error in errors
206207
):
207208
raise MypyError(file_error_formatter(self, results, errors))
208-
# This line cannot be easily covered on mypy < 0.990:
209-
warnings.warn("\n" + "\n".join(errors), MypyWarning) # pragma: no cover
209+
warnings.warn("\n" + "\n".join(errors), MypyWarning)
210210

211211
def reportinfo(self):
212212
"""Produce a heading for the test report."""
@@ -258,7 +258,9 @@ def from_mypy(
258258
) -> "MypyResults":
259259
"""Generate results from mypy."""
260260

261-
if opts is None:
261+
# This is covered by test_mypy_results_from_mypy_with_opts;
262+
# however, coverage is not recognized on py38-pytest4.6:
263+
if opts is None: # pragma: no cover
262264
opts = mypy_argv[:]
263265
abspath_errors = {
264266
os.path.abspath(str(item.fspath)): [] for item in items
@@ -293,7 +295,7 @@ def from_session(cls, session) -> "MypyResults":
293295
"""Load (or generate) cached mypy results for a pytest session."""
294296
results_path = (
295297
session.config._mypy_results_path
296-
if _is_master(session.config)
298+
if _is_xdist_controller(session.config)
297299
else _get_xdist_workerinput(session.config)["_mypy_results_path"]
298300
)
299301
with FileLock(results_path + ".lock"):
@@ -322,18 +324,20 @@ class MypyWarning(pytest.PytestWarning):
322324

323325
def pytest_terminal_summary(terminalreporter, config):
324326
"""Report stderr and unrecognized lines from stdout."""
325-
if _is_master(config):
326-
try:
327-
with open(config._mypy_results_path, mode="r") as results_f:
328-
results = MypyResults.load(results_f)
329-
except FileNotFoundError:
330-
# No MypyItems executed.
331-
return
332-
if results.unmatched_stdout or results.stderr:
333-
terminalreporter.section("mypy")
334-
if results.unmatched_stdout:
335-
color = {"red": True} if results.status else {"green": True}
336-
terminalreporter.write_line(results.unmatched_stdout, **color)
337-
if results.stderr:
338-
terminalreporter.write_line(results.stderr, yellow=True)
339-
os.remove(config._mypy_results_path)
327+
if not _is_xdist_controller(config):
328+
# This isn't hit in pytest 5.0 for some reason.
329+
return # pragma: no cover
330+
try:
331+
with open(config._mypy_results_path, mode="r") as results_f:
332+
results = MypyResults.load(results_f)
333+
except FileNotFoundError:
334+
# No MypyItems executed.
335+
return
336+
if results.unmatched_stdout or results.stderr:
337+
terminalreporter.section(terminal_summary_title)
338+
if results.unmatched_stdout:
339+
color = {"red": True} if results.status else {"green": True}
340+
terminalreporter.write_line(results.unmatched_stdout, **color)
341+
if results.stderr:
342+
terminalreporter.write_line(results.stderr, yellow=True)
343+
os.remove(config._mypy_results_path)

tests/test_pytest_mypy.py

+102-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
import signal
2+
import sys
23
import textwrap
34

45
import mypy.version
56
from packaging.version import Version
67
import pexpect
78
import pytest
89

10+
import pytest_mypy
11+
912

1013
MYPY_VERSION = Version(mypy.version.__version__)
1114
PYTEST_VERSION = Version(pytest.__version__)
15+
PYTHON_VERSION = Version(
16+
".".join(
17+
str(token)
18+
for token in [
19+
sys.version_info.major,
20+
sys.version_info.minor,
21+
sys.version_info.micro,
22+
]
23+
)
24+
)
1225

1326

1427
@pytest.fixture(
@@ -100,7 +113,7 @@ def pyfunc(x: int) -> str:
100113
assert "_mypy_results_path" not in result.stderr.str()
101114

102115

103-
def test_mypy_annotation_unchecked(testdir, xdist_args):
116+
def test_mypy_annotation_unchecked(testdir, xdist_args, tmp_path, monkeypatch):
104117
"""Verify that annotation-unchecked warnings do not manifest as an error."""
105118
testdir.makepyfile(
106119
"""
@@ -109,6 +122,29 @@ def pyfunc(x):
109122
return x * y
110123
""",
111124
)
125+
min_mypy_version = Version("0.990")
126+
if MYPY_VERSION < min_mypy_version:
127+
# mypy doesn't emit annotation-unchecked warnings until 0.990:
128+
fake_mypy_path = tmp_path / "mypy"
129+
fake_mypy_path.mkdir()
130+
(fake_mypy_path / "__init__.py").touch()
131+
(fake_mypy_path / "api.py").write_text(
132+
textwrap.dedent(
133+
"""
134+
def run(*args, **kwargs):
135+
return (
136+
"test_mypy_annotation_unchecked.py:2:"
137+
" note: By default the bodies of untyped functions"
138+
" are not checked, consider using --check-untyped-defs"
139+
" [annotation-unchecked]\\nSuccess: no issues found in"
140+
" 1 source file\\n",
141+
"",
142+
0,
143+
)
144+
"""
145+
)
146+
)
147+
monkeypatch.setenv("PYTHONPATH", str(tmp_path))
112148
result = testdir.runpytest_subprocess(*xdist_args)
113149
result.assert_outcomes()
114150
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
@@ -552,3 +588,68 @@ def test_mypy_item_collect(request):
552588
mypy_status_check = 1
553589
result.assert_outcomes(passed=test_count + mypy_file_checks + mypy_status_check)
554590
assert result.ret == 0
591+
592+
593+
@pytest.mark.xfail(
594+
MYPY_VERSION < Version("0.750"),
595+
raises=AssertionError,
596+
reason="https://github.com/python/mypy/issues/7800",
597+
)
598+
def test_mypy_results_from_mypy_with_opts():
599+
"""MypyResults.from_mypy respects passed options."""
600+
mypy_results = pytest_mypy.MypyResults.from_mypy([], opts=["--version"])
601+
assert mypy_results.status == 0
602+
assert mypy_results.abspath_errors == {}
603+
assert str(MYPY_VERSION) in mypy_results.stdout
604+
605+
606+
@pytest.mark.xfail(
607+
Version("3.7") < PYTHON_VERSION < Version("3.9")
608+
and Version("0.710") <= MYPY_VERSION < Version("0.720"),
609+
raises=AssertionError,
610+
reason="Mypy crashes for some reason.",
611+
)
612+
def test_mypy_no_output(testdir, xdist_args):
613+
"""No terminal summary is shown if there is no output from mypy."""
614+
type_ignore = (
615+
"# type: ignore"
616+
if (
617+
PYTEST_VERSION
618+
< Version("6.0") # Pytest didn't add type annotations until 6.0.
619+
or MYPY_VERSION < Version("0.710")
620+
)
621+
else ""
622+
)
623+
testdir.makepyfile(
624+
# Mypy prints a success message to stderr by default:
625+
# "Success: no issues found in 1 source file"
626+
# Clear stderr and unmatched_stdout to simulate mypy having no output:
627+
conftest=f"""
628+
import pytest {type_ignore}
629+
630+
@pytest.hookimpl(hookwrapper=True)
631+
def pytest_terminal_summary(config):
632+
mypy_results_path = getattr(config, "_mypy_results_path", None)
633+
if not mypy_results_path:
634+
# xdist worker
635+
return
636+
pytest_mypy = config.pluginmanager.getplugin("mypy")
637+
with open(mypy_results_path, mode="w") as results_f:
638+
pytest_mypy.MypyResults(
639+
opts=[],
640+
stdout="",
641+
stderr="",
642+
status=0,
643+
abspath_errors={{}},
644+
unmatched_stdout="",
645+
).dump(results_f)
646+
yield
647+
""",
648+
)
649+
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
650+
mypy_file_checks = 1
651+
mypy_status_check = 1
652+
mypy_checks = mypy_file_checks + mypy_status_check
653+
result.assert_outcomes(passed=mypy_checks)
654+
assert result.ret == 0
655+
assert f"= {pytest_mypy.terminal_summary_title} =" not in str(result.stdout)

tox.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ deps =
4444

4545
packaging ~= 21.3
4646
pexpect ~= 4.8.0
47-
pytest-cov ~= 2.10
47+
pytest-cov ~= 4.1.0
4848
pytest-randomly ~= 3.4
4949
pytest-xdist ~= 1.34
5050

51-
commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-fail-under 100 --cov-report term-missing -n auto}
51+
commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-branch --cov-fail-under 100 --cov-report term-missing -n auto}
5252

5353
[pytest]
5454
testpaths = tests

0 commit comments

Comments
 (0)