Skip to content

Commit 26c4cbf

Browse files
authored
Merge pull request #79 from dmtucker/status
Inject a test that checks the mypy exit status
2 parents 436ae92 + 0b66e30 commit 26c4cbf

File tree

3 files changed

+175
-53
lines changed

3 files changed

+175
-53
lines changed

src/pytest_mypy.py

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Mypy static type checker plugin for Pytest"""
22

3+
import functools
34
import json
45
import os
56
from tempfile import NamedTemporaryFile
@@ -73,14 +74,14 @@ def pytest_configure_node(self, node): # xdist hook
7374

7475

7576
def pytest_collect_file(path, parent):
76-
"""Create a MypyItem for every file mypy should run on."""
77+
"""Create a MypyFileItem for every file mypy should run on."""
7778
if path.ext == '.py' and any([
7879
parent.config.option.mypy,
7980
parent.config.option.mypy_ignore_missing_imports,
8081
]):
81-
item = MypyItem(path, parent)
82+
item = MypyFileItem(path, parent)
8283
if nodeid_name:
83-
item = MypyItem(
84+
item = MypyFileItem(
8485
path,
8586
parent,
8687
nodeid='::'.join([item.nodeid, nodeid_name]),
@@ -89,33 +90,51 @@ def pytest_collect_file(path, parent):
8990
return None
9091

9192

92-
class MypyItem(pytest.Item, pytest.File):
93+
@pytest.hookimpl(hookwrapper=True, trylast=True)
94+
def pytest_collection_modifyitems(session, config, items):
95+
"""
96+
Add a MypyStatusItem if any MypyFileItems were collected.
97+
98+
Since mypy might check files that were not collected,
99+
pytest could pass even though mypy failed!
100+
To prevent that, add an explicit check for the mypy exit status.
101+
102+
This should execute as late as possible to avoid missing any
103+
MypyFileItems injected by other pytest_collection_modifyitems
104+
implementations.
105+
"""
106+
yield
107+
if any(isinstance(item, MypyFileItem) for item in items):
108+
items.append(MypyStatusItem(nodeid_name, session, config, session))
93109

94-
"""A File that Mypy Runs On."""
110+
111+
class MypyItem(pytest.Item):
112+
113+
"""A Mypy-related test Item."""
95114

96115
MARKER = 'mypy'
97116

98117
def __init__(self, *args, **kwargs):
99118
super().__init__(*args, **kwargs)
100119
self.add_marker(self.MARKER)
101120

121+
def repr_failure(self, excinfo):
122+
"""
123+
Unwrap mypy errors so we get a clean error message without the
124+
full exception repr.
125+
"""
126+
if excinfo.errisinstance(MypyError):
127+
return excinfo.value.args[0]
128+
return super().repr_failure(excinfo)
129+
130+
131+
class MypyFileItem(MypyItem, pytest.File):
132+
133+
"""A File that Mypy Runs On."""
134+
102135
def runtest(self):
103136
"""Raise an exception if mypy found errors for this item."""
104-
results = _cached_json_results(
105-
results_path=(
106-
self.config._mypy_results_path
107-
if _is_master(self.config) else
108-
self.config.slaveinput['_mypy_results_path']
109-
),
110-
results_factory=lambda:
111-
_mypy_results_factory(
112-
abspaths=[
113-
os.path.abspath(str(item.fspath))
114-
for item in self.session.items
115-
if isinstance(item, MypyItem)
116-
],
117-
)
118-
)
137+
results = _mypy_results(self.session)
119138
abspath = os.path.abspath(str(self.fspath))
120139
errors = results['abspath_errors'].get(abspath)
121140
if errors:
@@ -129,14 +148,39 @@ def reportinfo(self):
129148
self.config.invocation_dir.bestrelpath(self.fspath),
130149
)
131150

132-
def repr_failure(self, excinfo):
133-
"""
134-
Unwrap mypy errors so we get a clean error message without the
135-
full exception repr.
136-
"""
137-
if excinfo.errisinstance(MypyError):
138-
return excinfo.value.args[0]
139-
return super().repr_failure(excinfo)
151+
152+
class MypyStatusItem(MypyItem):
153+
154+
"""A check for a non-zero mypy exit status."""
155+
156+
def runtest(self):
157+
"""Raise a MypyError if mypy exited with a non-zero status."""
158+
results = _mypy_results(self.session)
159+
if results['status']:
160+
raise MypyError(
161+
'mypy exited with status {status}.'.format(
162+
status=results['status'],
163+
),
164+
)
165+
166+
167+
def _mypy_results(session):
168+
"""Get the cached mypy results for the session, or generate them."""
169+
return _cached_json_results(
170+
results_path=(
171+
session.config._mypy_results_path
172+
if _is_master(session.config) else
173+
session.config.slaveinput['_mypy_results_path']
174+
),
175+
results_factory=functools.partial(
176+
_mypy_results_factory,
177+
abspaths=[
178+
os.path.abspath(str(item.fspath))
179+
for item in session.items
180+
if isinstance(item, MypyFileItem)
181+
],
182+
)
183+
)
140184

141185

142186
def _cached_json_results(results_path, results_factory=None):

tests/test_pytest_mypy.py

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,41 @@ def xdist_args(request):
1414
return ['-n', 'auto'] if request.param else []
1515

1616

17-
@pytest.mark.parametrize('test_count', [1, 2])
18-
def test_mypy_success(testdir, test_count, xdist_args):
17+
@pytest.mark.parametrize('pyfile_count', [1, 2])
18+
def test_mypy_success(testdir, pyfile_count, xdist_args):
1919
"""Verify that running on a module with no type errors passes."""
2020
testdir.makepyfile(
2121
**{
22-
'test_' + str(test_i): '''
23-
def myfunc(x: int) -> int:
22+
'pyfile_' + str(pyfile_i): '''
23+
def pyfunc(x: int) -> int:
2424
return x * 2
2525
'''
26-
for test_i in range(test_count)
26+
for pyfile_i in range(pyfile_count)
2727
}
2828
)
2929
result = testdir.runpytest_subprocess(*xdist_args)
3030
result.assert_outcomes()
3131
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
32-
result.assert_outcomes(passed=test_count)
32+
mypy_file_checks = pyfile_count
33+
mypy_status_check = 1
34+
mypy_checks = mypy_file_checks + mypy_status_check
35+
result.assert_outcomes(passed=mypy_checks)
3336
assert result.ret == 0
3437

3538

3639
def test_mypy_error(testdir, xdist_args):
3740
"""Verify that running on a module with type errors fails."""
3841
testdir.makepyfile('''
39-
def myfunc(x: int) -> str:
42+
def pyfunc(x: int) -> str:
4043
return x * 2
4144
''')
4245
result = testdir.runpytest_subprocess(*xdist_args)
4346
result.assert_outcomes()
4447
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
45-
result.assert_outcomes(failed=1)
48+
mypy_file_checks = 1
49+
mypy_status_check = 1
50+
mypy_checks = mypy_file_checks + mypy_status_check
51+
result.assert_outcomes(failed=mypy_checks)
4652
result.stdout.fnmatch_lines([
4753
'2: error: Incompatible return value*',
4854
])
@@ -54,20 +60,29 @@ def test_mypy_ignore_missings_imports(testdir, xdist_args):
5460
Verify that --mypy-ignore-missing-imports
5561
causes mypy to ignore missing imports.
5662
"""
63+
module_name = 'is_always_missing'
5764
testdir.makepyfile('''
58-
import pytest_mypy
59-
''')
65+
try:
66+
import {module_name}
67+
except ImportError:
68+
pass
69+
'''.format(module_name=module_name))
6070
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
61-
result.assert_outcomes(failed=1)
71+
mypy_file_checks = 1
72+
mypy_status_check = 1
73+
mypy_checks = mypy_file_checks + mypy_status_check
74+
result.assert_outcomes(failed=mypy_checks)
6275
result.stdout.fnmatch_lines([
63-
"1: error: Cannot find *module named 'pytest_mypy'",
76+
"2: error: Cannot find *module named '{module_name}'".format(
77+
module_name=module_name,
78+
),
6479
])
6580
assert result.ret != 0
6681
result = testdir.runpytest_subprocess(
6782
'--mypy-ignore-missing-imports',
6883
*xdist_args
6984
)
70-
result.assert_outcomes(passed=1)
85+
result.assert_outcomes(passed=mypy_checks)
7186
assert result.ret == 0
7287

7388

@@ -78,28 +93,39 @@ def test_fails():
7893
assert False
7994
''')
8095
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
81-
result.assert_outcomes(failed=1, passed=1)
96+
test_count = 1
97+
mypy_file_checks = 1
98+
mypy_status_check = 1
99+
mypy_checks = mypy_file_checks + mypy_status_check
100+
result.assert_outcomes(failed=test_count, passed=mypy_checks)
82101
assert result.ret != 0
83102
result = testdir.runpytest_subprocess('--mypy', '-m', 'mypy', *xdist_args)
84-
result.assert_outcomes(passed=1)
103+
result.assert_outcomes(passed=mypy_checks)
85104
assert result.ret == 0
86105

87106

88107
def test_non_mypy_error(testdir, xdist_args):
89108
"""Verify that non-MypyError exceptions are passed through the plugin."""
90109
message = 'This is not a MypyError.'
91-
testdir.makepyfile('''
92-
import pytest_mypy
110+
testdir.makepyfile(conftest='''
111+
def pytest_configure(config):
112+
plugin = config.pluginmanager.getplugin('mypy')
93113
94-
def _patched_runtest(*args, **kwargs):
95-
raise Exception('{message}')
114+
class PatchedMypyFileItem(plugin.MypyFileItem):
115+
def runtest(self):
116+
raise Exception('{message}')
96117
97-
pytest_mypy.MypyItem.runtest = _patched_runtest
118+
plugin.MypyFileItem = PatchedMypyFileItem
98119
'''.format(message=message))
99120
result = testdir.runpytest_subprocess(*xdist_args)
100121
result.assert_outcomes()
101122
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
102-
result.assert_outcomes(failed=1)
123+
mypy_file_checks = 1 # conftest.py
124+
mypy_status_check = 1
125+
result.assert_outcomes(
126+
failed=mypy_file_checks, # patched to raise an Exception
127+
passed=mypy_status_check, # conftest.py has no type errors.
128+
)
103129
result.stdout.fnmatch_lines(['*' + message])
104130
assert result.ret != 0
105131

@@ -159,23 +185,75 @@ def pytest_configure(config):
159185

160186

161187
def test_pytest_collection_modifyitems(testdir, xdist_args):
188+
"""
189+
Verify that collected files which are removed in a
190+
pytest_collection_modifyitems implementation are not
191+
checked by mypy.
192+
193+
This would also fail if a MypyStatusItem were injected
194+
despite there being no MypyFileItems.
195+
"""
162196
testdir.makepyfile(conftest='''
163197
def pytest_collection_modifyitems(session, config, items):
164198
plugin = config.pluginmanager.getplugin('mypy')
165199
for mypy_item_i in reversed([
166200
i
167201
for i, item in enumerate(items)
168-
if isinstance(item, plugin.MypyItem)
202+
if isinstance(item, plugin.MypyFileItem)
169203
]):
170204
items.pop(mypy_item_i)
171205
''')
172206
testdir.makepyfile('''
173-
def myfunc(x: int) -> str:
207+
def pyfunc(x: int) -> str:
174208
return x * 2
175209
176210
def test_pass():
177211
pass
178212
''')
179213
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
180-
result.assert_outcomes(passed=1)
214+
test_count = 1
215+
result.assert_outcomes(passed=test_count)
181216
assert result.ret == 0
217+
218+
219+
def test_mypy_indirect(testdir, xdist_args):
220+
"""Verify that uncollected files checked by mypy cause a failure."""
221+
testdir.makepyfile(bad='''
222+
def pyfunc(x: int) -> str:
223+
return x * 2
224+
''')
225+
testdir.makepyfile(good='''
226+
import bad
227+
''')
228+
xdist_args.append('good.py') # Nothing may come after xdist_args in py34.
229+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
230+
assert result.ret != 0
231+
232+
233+
def test_mypy_indirect_inject(testdir, xdist_args):
234+
"""
235+
Verify that uncollected files checked by mypy because of a MypyFileItem
236+
injected in pytest_collection_modifyitems cause a failure.
237+
"""
238+
testdir.makepyfile(bad='''
239+
def pyfunc(x: int) -> str:
240+
return x * 2
241+
''')
242+
testdir.makepyfile(good='''
243+
import bad
244+
''')
245+
testdir.makepyfile(conftest='''
246+
import py
247+
import pytest
248+
249+
@pytest.hookimpl(trylast=True) # Inject as late as possible.
250+
def pytest_collection_modifyitems(session, config, items):
251+
plugin = config.pluginmanager.getplugin('mypy')
252+
items.append(
253+
plugin.MypyFileItem(py.path.local('good.py'), session),
254+
)
255+
''')
256+
testdir.mkdir('empty')
257+
xdist_args.append('empty') # Nothing may come after xdist_args in py34.
258+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
259+
assert result.ret != 0

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ deps =
8080

8181
pytest-cov ~= 2.5.1
8282
pytest-randomly ~= 2.1.1
83-
commands = py.test -p no:mypy -n auto --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs}
83+
commands = py.test -p no:mypy --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs:-n auto} tests
8484

8585
[testenv:static]
8686
deps =

0 commit comments

Comments
 (0)