Skip to content

Commit 18fb2b8

Browse files
authored
Merge pull request #109 from dmtucker/looponfail
Stop injecting MypyStatusItem in pytest_collection_modifyitems to fix --looponfail
2 parents 4be684b + 09e4746 commit 18fb2b8

File tree

3 files changed

+99
-84
lines changed

3 files changed

+99
-84
lines changed

src/pytest_mypy.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,16 @@ def from_parent(cls, *args, **kwargs):
125125
def collect(self):
126126
"""Create a MypyFileItem for the File."""
127127
yield MypyFileItem.from_parent(parent=self, name=nodeid_name)
128-
129-
130-
@pytest.hookimpl(hookwrapper=True, trylast=True)
131-
def pytest_collection_modifyitems(session, config, items):
132-
"""
133-
Add a MypyStatusItem if any MypyFileItems were collected.
134-
135-
Since mypy might check files that were not collected,
136-
pytest could pass even though mypy failed!
137-
To prevent that, add an explicit check for the mypy exit status.
138-
139-
This should execute as late as possible to avoid missing any
140-
MypyFileItems injected by other pytest_collection_modifyitems
141-
implementations.
142-
"""
143-
yield
144-
if any(isinstance(item, MypyFileItem) for item in items):
145-
items.append(
146-
MypyStatusItem.from_parent(parent=session, name=nodeid_name),
147-
)
128+
# Since mypy might check files that were not collected,
129+
# pytest could pass even though mypy failed!
130+
# To prevent that, add an explicit check for the mypy exit status.
131+
if not any(
132+
isinstance(item, MypyStatusItem) for item in self.session.items
133+
):
134+
yield MypyStatusItem.from_parent(
135+
parent=self,
136+
name=nodeid_name + "-status",
137+
)
148138

149139

150140
class MypyItem(pytest.Item):

tests/test_pytest_mypy.py

Lines changed: 87 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import signal
2+
import textwrap
3+
14
import pytest
25

36

@@ -214,38 +217,6 @@ def pytest_configure(config):
214217
assert result.ret == 0
215218

216219

217-
def test_pytest_collection_modifyitems(testdir, xdist_args):
218-
"""
219-
Verify that collected files which are removed in a
220-
pytest_collection_modifyitems implementation are not
221-
checked by mypy.
222-
223-
This would also fail if a MypyStatusItem were injected
224-
despite there being no MypyFileItems.
225-
"""
226-
testdir.makepyfile(conftest='''
227-
def pytest_collection_modifyitems(session, config, items):
228-
plugin = config.pluginmanager.getplugin('mypy')
229-
for mypy_item_i in reversed([
230-
i
231-
for i, item in enumerate(items)
232-
if isinstance(item, plugin.MypyFileItem)
233-
]):
234-
items.pop(mypy_item_i)
235-
''')
236-
testdir.makepyfile('''
237-
def pyfunc(x: int) -> str:
238-
return x * 2
239-
240-
def test_pass():
241-
pass
242-
''')
243-
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
244-
test_count = 1
245-
result.assert_outcomes(passed=test_count)
246-
assert result.ret == 0
247-
248-
249220
def test_mypy_indirect(testdir, xdist_args):
250221
"""Verify that uncollected files checked by mypy cause a failure."""
251222
testdir.makepyfile(bad='''
@@ -259,38 +230,6 @@ def pyfunc(x: int) -> str:
259230
assert result.ret != 0
260231

261232

262-
def test_mypy_indirect_inject(testdir, xdist_args):
263-
"""
264-
Verify that uncollected files checked by mypy because of a MypyFileItem
265-
injected in pytest_collection_modifyitems cause a failure.
266-
"""
267-
testdir.makepyfile(bad='''
268-
def pyfunc(x: int) -> str:
269-
return x * 2
270-
''')
271-
testdir.makepyfile(good='''
272-
import bad
273-
''')
274-
testdir.makepyfile(conftest='''
275-
import py
276-
import pytest
277-
278-
@pytest.hookimpl(trylast=True) # Inject as late as possible.
279-
def pytest_collection_modifyitems(session, config, items):
280-
plugin = config.pluginmanager.getplugin('mypy')
281-
items.append(
282-
plugin.MypyFileItem.from_parent(
283-
parent=session,
284-
name=str(py.path.local('good.py')),
285-
),
286-
)
287-
''')
288-
name = 'empty'
289-
testdir.mkdir(name)
290-
result = testdir.runpytest_subprocess('--mypy', *xdist_args, name)
291-
assert result.ret != 0
292-
293-
294233
def test_api_error_formatter(testdir, xdist_args):
295234
"""Ensure that the plugin can be configured in a conftest.py."""
296235
testdir.makepyfile(bad='''
@@ -333,3 +272,87 @@ def pyfunc(x):
333272
'1: error: Function is missing a type annotation',
334273
])
335274
assert result.ret != 0
275+
276+
277+
def test_looponfail(testdir):
278+
"""Ensure that the plugin works with --looponfail."""
279+
280+
pass_source = textwrap.dedent(
281+
"""\
282+
def pyfunc(x: int) -> int:
283+
return x * 2
284+
""",
285+
)
286+
fail_source = textwrap.dedent(
287+
"""\
288+
def pyfunc(x: int) -> str:
289+
return x * 2
290+
""",
291+
)
292+
pyfile = testdir.makepyfile(fail_source)
293+
looponfailroot = testdir.mkdir("looponfailroot")
294+
looponfailroot_pyfile = looponfailroot.join(pyfile.basename)
295+
pyfile.move(looponfailroot_pyfile)
296+
pyfile = looponfailroot_pyfile
297+
testdir.makeini(
298+
textwrap.dedent(
299+
"""\
300+
[pytest]
301+
looponfailroots = {looponfailroots}
302+
""".format(
303+
looponfailroots=looponfailroot,
304+
),
305+
),
306+
)
307+
308+
child = testdir.spawn_pytest(
309+
"--mypy --looponfail " + str(pyfile),
310+
expect_timeout=30.0,
311+
)
312+
313+
def _expect_session():
314+
child.expect("==== test session starts ====")
315+
316+
def _expect_failure():
317+
_expect_session()
318+
child.expect("==== FAILURES ====")
319+
child.expect(pyfile.basename + " ____")
320+
child.expect("2: error: Incompatible return value")
321+
# These only show with mypy>=0.730:
322+
# child.expect("==== mypy ====")
323+
# child.expect("Found 1 error in 1 file (checked 1 source file)")
324+
child.expect("2 failed")
325+
child.expect("#### LOOPONFAILING ####")
326+
_expect_waiting()
327+
328+
def _expect_waiting():
329+
child.expect("#### waiting for changes ####")
330+
child.expect("Watching")
331+
332+
def _fix():
333+
pyfile.write(pass_source)
334+
_expect_changed()
335+
_expect_success()
336+
337+
def _expect_changed():
338+
child.expect("MODIFIED " + str(pyfile))
339+
340+
def _expect_success():
341+
for _ in range(2):
342+
_expect_session()
343+
# These only show with mypy>=0.730:
344+
# child.expect("==== mypy ====")
345+
# child.expect("Success: no issues found in 1 source file")
346+
child.expect("2 passed")
347+
_expect_waiting()
348+
349+
def _break():
350+
pyfile.write(fail_source)
351+
_expect_changed()
352+
_expect_failure()
353+
354+
_expect_failure()
355+
_fix()
356+
_break()
357+
_fix()
358+
child.kill(signal.SIGTERM)

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ deps =
132132
mypy0.79: mypy >= 0.790, < 0.800
133133
mypy0.7x: mypy >= 0.700, < 0.800
134134

135+
pexpect ~= 4.8.0
136+
135137
commands = py.test -p no:mypy --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs:-n auto} tests
136138

137139
[testenv:publish]

0 commit comments

Comments
 (0)