diff --git a/setup.py b/setup.py index 8877556..b951c18 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ def read(fname): ], python_requires='~=3.4', install_requires=[ + 'filelock>=3.0', 'pytest>=2.8,<4.7; python_version<"3.5"', 'pytest>=2.8; python_version>="3.5"', 'mypy>=0.500,<0.700; python_version<"3.5"', diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index 3bff367..017a1b6 100644 --- a/src/pytest_mypy.py +++ b/src/pytest_mypy.py @@ -1,7 +1,10 @@ """Mypy static type checker plugin for Pytest""" +import json import os +from tempfile import NamedTemporaryFile +from filelock import FileLock import pytest import mypy.api @@ -20,11 +23,44 @@ def pytest_addoption(parser): help="suppresses error messages about imports that cannot be resolved") +def _is_master(config): + """ + True if the code running the given pytest.config object is running in + an xdist master node or not running xdist at all. + """ + return not hasattr(config, 'slaveinput') + + def pytest_configure(config): """ - Register a custom marker for MypyItems, + Initialize the path used to cache mypy results, + register a custom marker for MypyItems, and configure the plugin based on the CLI. """ + if _is_master(config): + + # Get the path to a temporary file and delete it. + # The first MypyItem to run will see the file does not exist, + # and it will run and parse mypy results to create it. + # Subsequent MypyItems will see the file exists, + # and they will read the parsed results. + with NamedTemporaryFile(delete=True) as tmp_f: + config._mypy_results_path = tmp_f.name + + # If xdist is enabled, then the results path should be exposed to + # the slaves so that they know where to read parsed results from. + if config.pluginmanager.getplugin('xdist'): + class _MypyXdistPlugin: + def pytest_configure_node(self, node): # xdist hook + """Pass config._mypy_results_path to workers.""" + node.slaveinput['_mypy_results_path'] = \ + node.config._mypy_results_path + config.pluginmanager.register(_MypyXdistPlugin()) + + # pytest_terminal_summary cannot accept config before pytest 4.2. + global _pytest_terminal_summary_config + _pytest_terminal_summary_config = config + config.addinivalue_line( 'markers', '{marker}: mark tests to be checked by mypy.'.format( @@ -45,46 +81,6 @@ def pytest_collect_file(path, parent): return None -def pytest_runtestloop(session): - """Run mypy on collected MypyItems, then sort the output.""" - mypy_items = { - os.path.abspath(str(item.fspath)): item - for item in session.items - if isinstance(item, MypyItem) - } - if mypy_items: - - terminal = session.config.pluginmanager.getplugin('terminalreporter') - terminal.write( - '\nRunning {command} on {file_count} files... '.format( - command=' '.join(['mypy'] + mypy_argv), - file_count=len(mypy_items), - ), - ) - stdout, stderr, status = mypy.api.run( - mypy_argv + [str(item.fspath) for item in mypy_items.values()], - ) - terminal.write('done with status {status}\n'.format(status=status)) - - unmatched_lines = [] - for line in stdout.split('\n'): - if not line: - continue - mypy_path, _, error = line.partition(':') - try: - item = mypy_items[os.path.abspath(mypy_path)] - except KeyError: - unmatched_lines.append(line) - else: - item.mypy_errors.append(error) - if any(unmatched_lines): - color = {"red": True} if status != 0 else {"green": True} - terminal.write_line('\n'.join(unmatched_lines), **color) - - if stderr: - terminal.write_line(stderr, red=True) - - class MypyItem(pytest.Item, pytest.File): """A File that Mypy Runs On.""" @@ -94,12 +90,28 @@ class MypyItem(pytest.Item, pytest.File): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_marker(self.MARKER) - self.mypy_errors = [] def runtest(self): """Raise an exception if mypy found errors for this item.""" - if self.mypy_errors: - raise MypyError('\n'.join(self.mypy_errors)) + results = _cached_json_results( + results_path=( + self.config._mypy_results_path + if _is_master(self.config) else + self.config.slaveinput['_mypy_results_path'] + ), + results_factory=lambda: + _mypy_results_factory( + abspaths=[ + os.path.abspath(str(item.fspath)) + for item in self.session.items + if isinstance(item, MypyItem) + ], + ) + ) + abspath = os.path.abspath(str(self.fspath)) + errors = results['abspath_errors'].get(abspath) + if errors: + raise MypyError('\n'.join(errors)) def reportinfo(self): """Produce a heading for the test report.""" @@ -119,8 +131,70 @@ def repr_failure(self, excinfo): return super().repr_failure(excinfo) +def _cached_json_results(results_path, results_factory=None): + """ + Read results from results_path if it exists; + otherwise, produce them with results_factory, + and write them to results_path. + """ + with FileLock(results_path + '.lock'): + try: + with open(results_path, mode='r') as results_f: + results = json.load(results_f) + except FileNotFoundError: + if not results_factory: + raise + results = results_factory() + with open(results_path, mode='w') as results_f: + json.dump(results, results_f) + return results + + +def _mypy_results_factory(abspaths): + """Run mypy on abspaths and return the results as a JSON-able dict.""" + + stdout, stderr, status = mypy.api.run(mypy_argv + abspaths) + + abspath_errors, unmatched_lines = {}, [] + for line in stdout.split('\n'): + if not line: + continue + path, _, error = line.partition(':') + abspath = os.path.abspath(path) + if abspath in abspaths: + abspath_errors[abspath] = abspath_errors.get(abspath, []) + [error] + else: + unmatched_lines.append(line) + + return { + 'stdout': stdout, + 'stderr': stderr, + 'status': status, + 'abspath_errors': abspath_errors, + 'unmatched_stdout': '\n'.join(unmatched_lines), + } + + class MypyError(Exception): """ An error caught by mypy, e.g a type checker violation or a syntax error. """ + + +def pytest_terminal_summary(terminalreporter): + """Report stderr and unrecognized lines from stdout.""" + config = _pytest_terminal_summary_config + try: + results = _cached_json_results(config._mypy_results_path) + except FileNotFoundError: + # No MypyItems executed. + return + if results['unmatched_stdout'] or results['stderr']: + terminalreporter.section('mypy') + if results['unmatched_stdout']: + color = {'red': True} if results['status'] else {'green': True} + terminalreporter.write_line(results['unmatched_stdout'], **color) + if results['stderr']: + terminalreporter.write_line(results['stderr'], yellow=True) + os.remove(config._mypy_results_path) diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index 35378ec..1fd4de4 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -1,25 +1,47 @@ -def test_mypy_success(testdir): +import pytest + + +@pytest.fixture( + params=[ + True, # xdist enabled, active + False, # xdist enabled, inactive + None, # xdist disabled + ], +) +def xdist_args(request): + if request.param is None: + return ['-p', 'no:xdist'] + return ['-n', 'auto'] if request.param else [] + + +@pytest.mark.parametrize('test_count', [1, 2]) +def test_mypy_success(testdir, test_count, xdist_args): """Verify that running on a module with no type errors passes.""" - testdir.makepyfile(''' - def myfunc(x: int) -> int: - return x * 2 - ''') - result = testdir.runpytest_subprocess() + testdir.makepyfile( + **{ + 'test_' + str(test_i): ''' + def myfunc(x: int) -> int: + return x * 2 + ''' + for test_i in range(test_count) + } + ) + result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() - result = testdir.runpytest_subprocess('--mypy') - result.assert_outcomes(passed=1) + result = testdir.runpytest_subprocess('--mypy', *xdist_args) + result.assert_outcomes(passed=test_count) assert result.ret == 0 -def test_mypy_error(testdir): +def test_mypy_error(testdir, xdist_args): """Verify that running on a module with type errors fails.""" testdir.makepyfile(''' def myfunc(x: int) -> str: return x * 2 ''') - result = testdir.runpytest_subprocess() + result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.assert_outcomes(failed=1) result.stdout.fnmatch_lines([ '2: error: Incompatible return value*', @@ -27,7 +49,7 @@ def myfunc(x: int) -> str: assert result.ret != 0 -def test_mypy_ignore_missings_imports(testdir): +def test_mypy_ignore_missings_imports(testdir, xdist_args): """ Verify that --mypy-ignore-missing-imports causes mypy to ignore missing imports. @@ -35,32 +57,35 @@ def test_mypy_ignore_missings_imports(testdir): testdir.makepyfile(''' import pytest_mypy ''') - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.assert_outcomes(failed=1) result.stdout.fnmatch_lines([ "1: error: Cannot find *module named 'pytest_mypy'", ]) assert result.ret != 0 - result = testdir.runpytest_subprocess('--mypy-ignore-missing-imports') + result = testdir.runpytest_subprocess( + '--mypy-ignore-missing-imports', + *xdist_args + ) result.assert_outcomes(passed=1) assert result.ret == 0 -def test_mypy_marker(testdir): +def test_mypy_marker(testdir, xdist_args): """Verify that -m mypy only runs the mypy tests.""" testdir.makepyfile(''' def test_fails(): assert False ''') - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.assert_outcomes(failed=1, passed=1) assert result.ret != 0 - result = testdir.runpytest_subprocess('--mypy', '-m', 'mypy') + result = testdir.runpytest_subprocess('--mypy', '-m', 'mypy', *xdist_args) result.assert_outcomes(passed=1) assert result.ret == 0 -def test_non_mypy_error(testdir): +def test_non_mypy_error(testdir, xdist_args): """Verify that non-MypyError exceptions are passed through the plugin.""" message = 'This is not a MypyError.' testdir.makepyfile(''' @@ -71,15 +96,15 @@ def _patched_runtest(*args, **kwargs): pytest_mypy.MypyItem.runtest = _patched_runtest '''.format(message=message)) - result = testdir.runpytest_subprocess() + result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.assert_outcomes(failed=1) result.stdout.fnmatch_lines(['*' + message]) assert result.ret != 0 -def test_mypy_stderr(testdir): +def test_mypy_stderr(testdir, xdist_args): """Verify that stderr from mypy is printed.""" stderr = 'This is stderr from mypy.' testdir.makepyfile(conftest=''' @@ -90,11 +115,11 @@ def _patched_run(*args, **kwargs): mypy.api.run = _patched_run '''.format(stderr=stderr)) - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.stdout.fnmatch_lines([stderr]) -def test_mypy_unmatched_stdout(testdir): +def test_mypy_unmatched_stdout(testdir, xdist_args): """Verify that unexpected output on stdout from mypy is printed.""" stdout = 'This is unexpected output on stdout from mypy.' testdir.makepyfile(conftest=''' @@ -105,16 +130,39 @@ def _patched_run(*args, **kwargs): mypy.api.run = _patched_run '''.format(stdout=stdout)) - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) result.stdout.fnmatch_lines([stdout]) -def test_api_mypy_argv(testdir): +def test_api_mypy_argv(testdir, xdist_args): """Ensure that the plugin can be configured in a conftest.py.""" testdir.makepyfile(conftest=''' def pytest_configure(config): plugin = config.pluginmanager.getplugin('mypy') plugin.mypy_argv.append('--version') ''') - result = testdir.runpytest_subprocess('--mypy') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) + assert result.ret == 0 + + +def test_pytest_collection_modifyitems(testdir, xdist_args): + testdir.makepyfile(conftest=''' + def pytest_collection_modifyitems(session, config, items): + plugin = config.pluginmanager.getplugin('mypy') + for mypy_item_i in reversed([ + i + for i, item in enumerate(items) + if isinstance(item, plugin.MypyItem) + ]): + items.pop(mypy_item_i) + ''') + testdir.makepyfile(''' + def myfunc(x: int) -> str: + return x * 2 + + def test_pass(): + pass + ''') + result = testdir.runpytest_subprocess('--mypy', *xdist_args) + result.assert_outcomes(passed=1) assert result.ret == 0 diff --git a/tox.ini b/tox.ini index 1e39cde..c053b37 100644 --- a/tox.ini +++ b/tox.ini @@ -13,34 +13,58 @@ envlist = [testenv] deps = pytest2.8: pytest ~= 2.8.0 + pytest2.8: pytest-xdist < 1.18.0 pytest2.x: pytest ~= 2.8 + pytest2.x: pytest-xdist < 1.18.0 pytest3.0: pytest ~= 3.0.0 + pytest3.0: pytest-xdist < 1.19.0 pytest3.1: pytest ~= 3.1.0 + pytest3.1: pytest-xdist < 1.19.0 pytest3.2: pytest ~= 3.2.0 + pytest3.2: pytest-xdist < 1.19.0 pytest3.3: attrs < 19.2.0 # https://github.com/pytest-dev/pytest/issues/3223 pytest3.3: pytest ~= 3.3.0 + pytest3.3: pytest-xdist < 1.19.0 pytest3.4: attrs < 19.2.0 # https://github.com/pytest-dev/pytest/issues/3223 pytest3.4: pytest ~= 3.4.0 + pytest3.4: pytest-xdist < 1.19.0 pytest3.5: pytest ~= 3.5.0 + pytest3.5: pytest-xdist < 1.19.0 pytest3.6: pytest ~= 3.6.0 + pytest3.6: pytest-xdist < 1.28.0 pytest3.7: pytest ~= 3.7.0 + pytest3.7: pytest-xdist < 1.28.0 pytest3.8: pytest ~= 3.8.0 + pytest3.8: pytest-xdist < 1.28.0 pytest3.9: pytest ~= 3.9.0 + pytest3.9: pytest-xdist < 1.28.0 pytest3.10: pytest ~= 3.10.0 + pytest3.10: pytest-xdist < 1.28.0 pytest3.x: pytest ~= 3.0 + pytest3.x: pytest-xdist < 1.28.0 pytest4.0: attrs < 19.2.0 # https://github.com/pytest-dev/pytest/issues/5900 pytest4.0: pytest ~= 4.0.0 + pytest4.0: pytest-xdist < 1.28.0 pytest4.1: attrs < 19.2.0 # https://github.com/pytest-dev/pytest/issues/5900 pytest4.1: pytest ~= 4.1.0 + pytest4.1: pytest-xdist < 1.28.0 pytest4.2: attrs < 19.2.0 # https://github.com/pytest-dev/pytest/issues/5900 pytest4.2: pytest ~= 4.2.0 + pytest4.2: pytest-xdist < 1.28.0 pytest4.3: pytest ~= 4.3.0 + pytest4.3: pytest-xdist < 1.28.0 pytest4.4: pytest ~= 4.4.0 + pytest4.4: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 pytest4.5: pytest ~= 4.5.0 + pytest4.5: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 pytest4.6: pytest ~= 4.6.0 + pytest4.6: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 pytest4.x: pytest ~= 4.0 + pytest4.x: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 pytest5.0: pytest ~= 5.0.0 + pytest5.0: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 pytest5.x: pytest ~= 5.0 + pytest5.x: pytest-xdist ~= 1.0, < 1.30.0 # https://github.com/pytest-dev/pytest-xdist/issues/472 mypy0.50: mypy >= 0.500, < 0.510 mypy0.51: mypy >= 0.510, < 0.520 mypy0.52: mypy >= 0.520, < 0.530 @@ -72,7 +96,7 @@ deps = pytest-cov ~= 2.5.1 pytest-randomly ~= 2.1.1 -commands = py.test --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs} +commands = py.test -p no:mypy -n auto --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs} [testenv:flake8] skip_install = true