Skip to content

Commit a79913c

Browse files
committed
Add support for pytest-xdist
1 parent b84c5cd commit a79913c

File tree

4 files changed

+180
-65
lines changed

4 files changed

+180
-65
lines changed

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def read(fname):
3131
],
3232
python_requires='~=3.4',
3333
install_requires=[
34+
'filelock>=3.0',
3435
'pytest>=2.8,<4.7; python_version<"3.5"',
3536
'pytest>=2.8; python_version>="3.5"',
3637
'mypy>=0.500,<0.700; python_version<"3.5"',

src/pytest_mypy.py

Lines changed: 118 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Mypy static type checker plugin for Pytest"""
22

3+
import json
34
import os
5+
from tempfile import NamedTemporaryFile
46

7+
from filelock import FileLock
58
import pytest
69
import mypy.api
710

@@ -20,11 +23,44 @@ def pytest_addoption(parser):
2023
help="suppresses error messages about imports that cannot be resolved")
2124

2225

26+
def _is_master(config):
27+
"""
28+
True if the code running the given pytest.config object is running in
29+
an xdist master node or not running xdist at all.
30+
"""
31+
return not hasattr(config, 'slaveinput')
32+
33+
2334
def pytest_configure(config):
2435
"""
25-
Register a custom marker for MypyItems,
36+
Initialize the path used to cache mypy results,
37+
register a custom marker for MypyItems,
2638
and configure the plugin based on the CLI.
2739
"""
40+
if _is_master(config):
41+
42+
# Get the path to a temporary file and delete it.
43+
# The first MypyItem to run will see the file does not exist,
44+
# and it will run and parse mypy results to create it.
45+
# Subsequent MypyItems will see the file exists,
46+
# and they will read the parsed results.
47+
with NamedTemporaryFile(delete=True) as tmp_f:
48+
config._mypy_results_path = tmp_f.name
49+
50+
# If xdist is enabled, then the results path should be exposed to
51+
# the slaves so that they know where to read parsed results from.
52+
if config.pluginmanager.getplugin('xdist'):
53+
class _MypyXdistPlugin:
54+
def pytest_configure_node(self, node): # xdist hook
55+
"""Pass config._mypy_results_path to workers."""
56+
node.slaveinput['_mypy_results_path'] = \
57+
node.config._mypy_results_path
58+
config.pluginmanager.register(_MypyXdistPlugin())
59+
60+
# pytest_terminal_summary cannot accept config before pytest 4.2.
61+
global _pytest_terminal_summary_config
62+
_pytest_terminal_summary_config = config
63+
2864
config.addinivalue_line(
2965
'markers',
3066
'{marker}: mark tests to be checked by mypy.'.format(
@@ -45,46 +81,6 @@ def pytest_collect_file(path, parent):
4581
return None
4682

4783

48-
def pytest_runtestloop(session):
49-
"""Run mypy on collected MypyItems, then sort the output."""
50-
mypy_items = {
51-
os.path.abspath(str(item.fspath)): item
52-
for item in session.items
53-
if isinstance(item, MypyItem)
54-
}
55-
if mypy_items:
56-
57-
terminal = session.config.pluginmanager.getplugin('terminalreporter')
58-
terminal.write(
59-
'\nRunning {command} on {file_count} files... '.format(
60-
command=' '.join(['mypy'] + mypy_argv),
61-
file_count=len(mypy_items),
62-
),
63-
)
64-
stdout, stderr, status = mypy.api.run(
65-
mypy_argv + [str(item.fspath) for item in mypy_items.values()],
66-
)
67-
terminal.write('done with status {status}\n'.format(status=status))
68-
69-
unmatched_lines = []
70-
for line in stdout.split('\n'):
71-
if not line:
72-
continue
73-
mypy_path, _, error = line.partition(':')
74-
try:
75-
item = mypy_items[os.path.abspath(mypy_path)]
76-
except KeyError:
77-
unmatched_lines.append(line)
78-
else:
79-
item.mypy_errors.append(error)
80-
if any(unmatched_lines):
81-
color = {"red": True} if status != 0 else {"green": True}
82-
terminal.write_line('\n'.join(unmatched_lines), **color)
83-
84-
if stderr:
85-
terminal.write_line(stderr, red=True)
86-
87-
8884
class MypyItem(pytest.Item, pytest.File):
8985

9086
"""A File that Mypy Runs On."""
@@ -94,12 +90,28 @@ class MypyItem(pytest.Item, pytest.File):
9490
def __init__(self, *args, **kwargs):
9591
super().__init__(*args, **kwargs)
9692
self.add_marker(self.MARKER)
97-
self.mypy_errors = []
9893

9994
def runtest(self):
10095
"""Raise an exception if mypy found errors for this item."""
101-
if self.mypy_errors:
102-
raise MypyError('\n'.join(self.mypy_errors))
96+
results = _cached_json_results(
97+
results_path=(
98+
self.config._mypy_results_path
99+
if _is_master(self.config) else
100+
self.config.slaveinput['_mypy_results_path']
101+
),
102+
results_factory=lambda:
103+
_mypy_results_factory(
104+
abspaths=[
105+
os.path.abspath(str(item.fspath))
106+
for item in self.session.items
107+
if isinstance(item, MypyItem)
108+
],
109+
)
110+
)
111+
abspath = os.path.abspath(str(self.fspath))
112+
errors = results['abspath_errors'].get(abspath)
113+
if errors:
114+
raise MypyError('\n'.join(errors))
103115

104116
def reportinfo(self):
105117
"""Produce a heading for the test report."""
@@ -119,8 +131,70 @@ def repr_failure(self, excinfo):
119131
return super().repr_failure(excinfo)
120132

121133

134+
def _cached_json_results(results_path, results_factory=None):
135+
"""
136+
Read results from results_path if it exists;
137+
otherwise, produce them with results_factory,
138+
and write them to results_path.
139+
"""
140+
with FileLock(results_path + '.lock'):
141+
try:
142+
with open(results_path, mode='r') as results_f:
143+
results = json.load(results_f)
144+
except FileNotFoundError:
145+
if not results_factory:
146+
raise
147+
results = results_factory()
148+
with open(results_path, mode='w') as results_f:
149+
json.dump(results, results_f)
150+
return results
151+
152+
153+
def _mypy_results_factory(abspaths):
154+
"""Run mypy on abspaths and return the results as a JSON-able dict."""
155+
156+
stdout, stderr, status = mypy.api.run(mypy_argv + abspaths)
157+
158+
abspath_errors, unmatched_lines = {}, []
159+
for line in stdout.split('\n'):
160+
if not line:
161+
continue
162+
path, _, error = line.partition(':')
163+
abspath = os.path.abspath(path)
164+
if abspath in abspaths:
165+
abspath_errors[abspath] = abspath_errors.get(abspath, []) + [error]
166+
else:
167+
unmatched_lines.append(line)
168+
169+
return {
170+
'stdout': stdout,
171+
'stderr': stderr,
172+
'status': status,
173+
'abspath_errors': abspath_errors,
174+
'unmatched_stdout': '\n'.join(unmatched_lines),
175+
}
176+
177+
122178
class MypyError(Exception):
123179
"""
124180
An error caught by mypy, e.g a type checker violation
125181
or a syntax error.
126182
"""
183+
184+
185+
def pytest_terminal_summary(terminalreporter):
186+
"""Report stderr and unrecognized lines from stdout."""
187+
config = _pytest_terminal_summary_config
188+
try:
189+
results = _cached_json_results(config._mypy_results_path)
190+
except FileNotFoundError:
191+
# No MypyItems executed.
192+
return
193+
if results['unmatched_stdout'] or results['stderr']:
194+
terminalreporter.section('mypy')
195+
if results['unmatched_stdout']:
196+
color = {'red': True} if results['status'] else {'green': True}
197+
terminalreporter.write_line(results['unmatched_stdout'], **color)
198+
if results['stderr']:
199+
terminalreporter.write_line(results['stderr'], yellow=True)
200+
os.remove(config._mypy_results_path)

tests/test_pytest_mypy.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
import pytest
22

33

4+
@pytest.fixture(
5+
params=[
6+
True, # xdist enabled, active
7+
False, # xdist enabled, inactive
8+
None, # xdist disabled
9+
],
10+
)
11+
def xdist_args(request):
12+
if request.param is None:
13+
return ['-p', 'no:xdist']
14+
return ['-n', 'auto'] if request.param else []
15+
16+
417
@pytest.mark.parametrize('test_count', [1, 2])
5-
def test_mypy_success(testdir, test_count):
18+
def test_mypy_success(testdir, test_count, xdist_args):
619
"""Verify that running on a module with no type errors passes."""
720
testdir.makepyfile(
821
**{
@@ -13,63 +26,66 @@ def myfunc(x: int) -> int:
1326
for test_i in range(test_count)
1427
}
1528
)
16-
result = testdir.runpytest_subprocess()
29+
result = testdir.runpytest_subprocess(*xdist_args)
1730
result.assert_outcomes()
18-
result = testdir.runpytest_subprocess('--mypy')
31+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
1932
result.assert_outcomes(passed=test_count)
2033
assert result.ret == 0
2134

2235

23-
def test_mypy_error(testdir):
36+
def test_mypy_error(testdir, xdist_args):
2437
"""Verify that running on a module with type errors fails."""
2538
testdir.makepyfile('''
2639
def myfunc(x: int) -> str:
2740
return x * 2
2841
''')
29-
result = testdir.runpytest_subprocess()
42+
result = testdir.runpytest_subprocess(*xdist_args)
3043
result.assert_outcomes()
31-
result = testdir.runpytest_subprocess('--mypy')
44+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
3245
result.assert_outcomes(failed=1)
3346
result.stdout.fnmatch_lines([
3447
'2: error: Incompatible return value*',
3548
])
3649
assert result.ret != 0
3750

3851

39-
def test_mypy_ignore_missings_imports(testdir):
52+
def test_mypy_ignore_missings_imports(testdir, xdist_args):
4053
"""
4154
Verify that --mypy-ignore-missing-imports
4255
causes mypy to ignore missing imports.
4356
"""
4457
testdir.makepyfile('''
4558
import pytest_mypy
4659
''')
47-
result = testdir.runpytest_subprocess('--mypy')
60+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
4861
result.assert_outcomes(failed=1)
4962
result.stdout.fnmatch_lines([
5063
"1: error: Cannot find *module named 'pytest_mypy'",
5164
])
5265
assert result.ret != 0
53-
result = testdir.runpytest_subprocess('--mypy-ignore-missing-imports')
66+
result = testdir.runpytest_subprocess(
67+
'--mypy-ignore-missing-imports',
68+
*xdist_args
69+
)
5470
result.assert_outcomes(passed=1)
5571
assert result.ret == 0
5672

5773

58-
def test_mypy_marker(testdir):
74+
def test_mypy_marker(testdir, xdist_args):
5975
"""Verify that -m mypy only runs the mypy tests."""
6076
testdir.makepyfile('''
6177
def test_fails():
6278
assert False
6379
''')
64-
result = testdir.runpytest_subprocess('--mypy')
80+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
6581
result.assert_outcomes(failed=1, passed=1)
6682
assert result.ret != 0
67-
result = testdir.runpytest_subprocess('--mypy', '-m', 'mypy')
83+
result = testdir.runpytest_subprocess('--mypy', '-m', 'mypy', *xdist_args)
6884
result.assert_outcomes(passed=1)
6985
assert result.ret == 0
7086

7187

72-
def test_non_mypy_error(testdir):
88+
def test_non_mypy_error(testdir, xdist_args):
7389
"""Verify that non-MypyError exceptions are passed through the plugin."""
7490
message = 'This is not a MypyError.'
7591
testdir.makepyfile('''
@@ -80,15 +96,15 @@ def _patched_runtest(*args, **kwargs):
8096
8197
pytest_mypy.MypyItem.runtest = _patched_runtest
8298
'''.format(message=message))
83-
result = testdir.runpytest_subprocess()
99+
result = testdir.runpytest_subprocess(*xdist_args)
84100
result.assert_outcomes()
85-
result = testdir.runpytest_subprocess('--mypy')
101+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
86102
result.assert_outcomes(failed=1)
87103
result.stdout.fnmatch_lines(['*' + message])
88104
assert result.ret != 0
89105

90106

91-
def test_mypy_stderr(testdir):
107+
def test_mypy_stderr(testdir, xdist_args):
92108
"""Verify that stderr from mypy is printed."""
93109
stderr = 'This is stderr from mypy.'
94110
testdir.makepyfile(conftest='''
@@ -99,11 +115,11 @@ def _patched_run(*args, **kwargs):
99115
100116
mypy.api.run = _patched_run
101117
'''.format(stderr=stderr))
102-
result = testdir.runpytest_subprocess('--mypy')
118+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
103119
result.stdout.fnmatch_lines([stderr])
104120

105121

106-
def test_mypy_unmatched_stdout(testdir):
122+
def test_mypy_unmatched_stdout(testdir, xdist_args):
107123
"""Verify that unexpected output on stdout from mypy is printed."""
108124
stdout = 'This is unexpected output on stdout from mypy.'
109125
testdir.makepyfile(conftest='''
@@ -114,16 +130,16 @@ def _patched_run(*args, **kwargs):
114130
115131
mypy.api.run = _patched_run
116132
'''.format(stdout=stdout))
117-
result = testdir.runpytest_subprocess('--mypy')
133+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
118134
result.stdout.fnmatch_lines([stdout])
119135

120136

121-
def test_api_mypy_argv(testdir):
137+
def test_api_mypy_argv(testdir, xdist_args):
122138
"""Ensure that the plugin can be configured in a conftest.py."""
123139
testdir.makepyfile(conftest='''
124140
def pytest_configure(config):
125141
plugin = config.pluginmanager.getplugin('mypy')
126142
plugin.mypy_argv.append('--version')
127143
''')
128-
result = testdir.runpytest_subprocess('--mypy')
144+
result = testdir.runpytest_subprocess('--mypy', *xdist_args)
129145
assert result.ret == 0

0 commit comments

Comments
 (0)