diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index f94974e..18b5ff4 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/changelog.md b/changelog.md index 5fdb8b7..337131a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## [0.8.0](https://github.com/dbader/pytest-mypy/milestone/15) +* Add support for Python 3.9. +* Stop injecting `MypyStatusItem` in `pytest_collection_modifyitems` to fix `--looponfail`. + ## [0.7.0](https://github.com/dbader/pytest-mypy/milestone/13) * Remove the upper bound on `python_requires`. * Require Python 3.5 or greater. diff --git a/setup.py b/setup.py index 7c5c163..e19434e 100644 --- a/setup.py +++ b/setup.py @@ -35,10 +35,12 @@ def read(fname): 'setuptools-scm>=3.5', ], install_requires=[ + 'attrs>=19.0', 'filelock>=3.0', 'pytest>=3.5', 'mypy>=0.500; python_version<"3.8"', - 'mypy>=0.700; python_version>="3.8"', + 'mypy>=0.700; python_version>="3.8" and python_version<"3.9"', + 'mypy>=0.780; python_version>="3.9"', ], classifiers=[ 'Development Status :: 4 - Beta', @@ -51,6 +53,7 @@ def read(fname): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: CPython', 'Operating System :: OS Independent', 'License :: OSI Approved :: MIT License', diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index b58b3dc..54ba108 100644 --- a/src/pytest_mypy.py +++ b/src/pytest_mypy.py @@ -1,10 +1,11 @@ """Mypy static type checker plugin for Pytest""" -import functools import json import os from tempfile import NamedTemporaryFile +from typing import Dict, List, Optional, TextIO +import attr from filelock import FileLock # type: ignore import mypy.api import pytest # type: ignore @@ -124,26 +125,16 @@ def from_parent(cls, *args, **kwargs): def collect(self): """Create a MypyFileItem for the File.""" yield MypyFileItem.from_parent(parent=self, name=nodeid_name) - - -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_collection_modifyitems(session, config, items): - """ - Add a MypyStatusItem if any MypyFileItems were collected. - - Since mypy might check files that were not collected, - pytest could pass even though mypy failed! - To prevent that, add an explicit check for the mypy exit status. - - This should execute as late as possible to avoid missing any - MypyFileItems injected by other pytest_collection_modifyitems - implementations. - """ - yield - if any(isinstance(item, MypyFileItem) for item in items): - items.append( - MypyStatusItem.from_parent(parent=session, name=nodeid_name), - ) + # Since mypy might check files that were not collected, + # pytest could pass even though mypy failed! + # To prevent that, add an explicit check for the mypy exit status. + if not any( + isinstance(item, MypyStatusItem) for item in self.session.items + ): + yield MypyStatusItem.from_parent( + parent=self, + name=nodeid_name + "-status", + ) class MypyItem(pytest.Item): @@ -178,9 +169,9 @@ class MypyFileItem(MypyItem): def runtest(self): """Raise an exception if mypy found errors for this item.""" - results = _mypy_results(self.session) + results = MypyResults.from_session(self.session) abspath = os.path.abspath(str(self.fspath)) - errors = results['abspath_errors'].get(abspath) + errors = results.abspath_errors.get(abspath) if errors: raise MypyError(file_error_formatter(self, results, errors)) @@ -199,76 +190,96 @@ class MypyStatusItem(MypyItem): def runtest(self): """Raise a MypyError if mypy exited with a non-zero status.""" - results = _mypy_results(self.session) - if results['status']: + results = MypyResults.from_session(self.session) + if results.status: raise MypyError( 'mypy exited with status {status}.'.format( - status=results['status'], + status=results.status, ), ) -def _mypy_results(session): - """Get the cached mypy results for the session, or generate them.""" - return _cached_json_results( - results_path=( +@attr.s(frozen=True, kw_only=True) +class MypyResults: + + """Parsed results from Mypy.""" + + _abspath_errors_type = Dict[str, List[str]] + + opts = attr.ib(type=List[str]) + stdout = attr.ib(type=str) + stderr = attr.ib(type=str) + status = attr.ib(type=int) + abspath_errors = attr.ib(type=_abspath_errors_type) + unmatched_stdout = attr.ib(type=str) + + def dump(self, results_f: TextIO) -> None: + """Cache results in a format that can be parsed by load().""" + return json.dump(vars(self), results_f) + + @classmethod + def load(cls, results_f: TextIO) -> 'MypyResults': + """Get results cached by dump().""" + return cls(**json.load(results_f)) + + @classmethod + def from_mypy( + cls, + items: List[MypyFileItem], + *, + opts: Optional[List[str]] = None + ) -> 'MypyResults': + """Generate results from mypy.""" + + if opts is None: + opts = mypy_argv[:] + abspath_errors = { + os.path.abspath(str(item.fspath)): [] + for item in items + } # type: MypyResults._abspath_errors_type + + stdout, stderr, status = mypy.api.run(opts + list(abspath_errors)) + + unmatched_lines = [] + for line in stdout.split('\n'): + if not line: + continue + path, _, error = line.partition(':') + abspath = os.path.abspath(path) + try: + abspath_errors[abspath].append(error) + except KeyError: + unmatched_lines.append(line) + + return cls( + opts=opts, + stdout=stdout, + stderr=stderr, + status=status, + abspath_errors=abspath_errors, + unmatched_stdout='\n'.join(unmatched_lines), + ) + + @classmethod + def from_session(cls, session) -> 'MypyResults': + """Load (or generate) cached mypy results for a pytest session.""" + results_path = ( session.config._mypy_results_path if _is_master(session.config) else _get_xdist_workerinput(session.config)['_mypy_results_path'] - ), - results_factory=functools.partial( - _mypy_results_factory, - abspaths=[ - os.path.abspath(str(item.fspath)) - for item in session.items - if isinstance(item, MypyFileItem) - ], ) - ) - - -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), - } + with FileLock(results_path + '.lock'): + try: + with open(results_path, mode='r') as results_f: + results = cls.load(results_f) + except FileNotFoundError: + results = cls.from_mypy([ + item for item in session.items + if isinstance(item, MypyFileItem) + ]) + with open(results_path, mode='w') as results_f: + results.dump(results_f) + return results class MypyError(Exception): @@ -282,15 +293,16 @@ 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) + with open(config._mypy_results_path, mode='r') as results_f: + results = MypyResults.load(results_f) except FileNotFoundError: # No MypyItems executed. return - if results['unmatched_stdout'] or results['stderr']: + 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) + 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 45c211c..29f9ea9 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -1,3 +1,6 @@ +import signal +import textwrap + import pytest @@ -214,38 +217,6 @@ def pytest_configure(config): assert result.ret == 0 -def test_pytest_collection_modifyitems(testdir, xdist_args): - """ - Verify that collected files which are removed in a - pytest_collection_modifyitems implementation are not - checked by mypy. - - This would also fail if a MypyStatusItem were injected - despite there being no MypyFileItems. - """ - 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.MypyFileItem) - ]): - items.pop(mypy_item_i) - ''') - testdir.makepyfile(''' - def pyfunc(x: int) -> str: - return x * 2 - - def test_pass(): - pass - ''') - result = testdir.runpytest_subprocess('--mypy', *xdist_args) - test_count = 1 - result.assert_outcomes(passed=test_count) - assert result.ret == 0 - - def test_mypy_indirect(testdir, xdist_args): """Verify that uncollected files checked by mypy cause a failure.""" testdir.makepyfile(bad=''' @@ -259,38 +230,6 @@ def pyfunc(x: int) -> str: assert result.ret != 0 -def test_mypy_indirect_inject(testdir, xdist_args): - """ - Verify that uncollected files checked by mypy because of a MypyFileItem - injected in pytest_collection_modifyitems cause a failure. - """ - testdir.makepyfile(bad=''' - def pyfunc(x: int) -> str: - return x * 2 - ''') - testdir.makepyfile(good=''' - import bad - ''') - testdir.makepyfile(conftest=''' - import py - import pytest - - @pytest.hookimpl(trylast=True) # Inject as late as possible. - def pytest_collection_modifyitems(session, config, items): - plugin = config.pluginmanager.getplugin('mypy') - items.append( - plugin.MypyFileItem.from_parent( - parent=session, - name=str(py.path.local('good.py')), - ), - ) - ''') - name = 'empty' - testdir.mkdir(name) - result = testdir.runpytest_subprocess('--mypy', *xdist_args, name) - assert result.ret != 0 - - def test_api_error_formatter(testdir, xdist_args): """Ensure that the plugin can be configured in a conftest.py.""" testdir.makepyfile(bad=''' @@ -333,3 +272,87 @@ def pyfunc(x): '1: error: Function is missing a type annotation', ]) assert result.ret != 0 + + +def test_looponfail(testdir): + """Ensure that the plugin works with --looponfail.""" + + pass_source = textwrap.dedent( + """\ + def pyfunc(x: int) -> int: + return x * 2 + """, + ) + fail_source = textwrap.dedent( + """\ + def pyfunc(x: int) -> str: + return x * 2 + """, + ) + pyfile = testdir.makepyfile(fail_source) + looponfailroot = testdir.mkdir("looponfailroot") + looponfailroot_pyfile = looponfailroot.join(pyfile.basename) + pyfile.move(looponfailroot_pyfile) + pyfile = looponfailroot_pyfile + testdir.makeini( + textwrap.dedent( + """\ + [pytest] + looponfailroots = {looponfailroots} + """.format( + looponfailroots=looponfailroot, + ), + ), + ) + + child = testdir.spawn_pytest( + "--mypy --looponfail " + str(pyfile), + expect_timeout=30.0, + ) + + def _expect_session(): + child.expect("==== test session starts ====") + + def _expect_failure(): + _expect_session() + child.expect("==== FAILURES ====") + child.expect(pyfile.basename + " ____") + child.expect("2: error: Incompatible return value") + # These only show with mypy>=0.730: + # child.expect("==== mypy ====") + # child.expect("Found 1 error in 1 file (checked 1 source file)") + child.expect("2 failed") + child.expect("#### LOOPONFAILING ####") + _expect_waiting() + + def _expect_waiting(): + child.expect("#### waiting for changes ####") + child.expect("Watching") + + def _fix(): + pyfile.write(pass_source) + _expect_changed() + _expect_success() + + def _expect_changed(): + child.expect("MODIFIED " + str(pyfile)) + + def _expect_success(): + for _ in range(2): + _expect_session() + # These only show with mypy>=0.730: + # child.expect("==== mypy ====") + # child.expect("Success: no issues found in 1 source file") + child.expect("2 passed") + _expect_waiting() + + def _break(): + pyfile.write(fail_source) + _expect_changed() + _expect_failure() + + _expect_failure() + _fix() + _break() + _fix() + child.kill(signal.SIGTERM) diff --git a/tox.ini b/tox.ini index ff00899..73eac3d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -min_version = 3.7.0 +minversion = 3.20 isolated_build = true envlist = py35-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x} py36-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x} py37-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x} py38-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.71, 0.7x} + py39-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.78, 0.7x} publish static @@ -16,6 +17,7 @@ python = 3.6: py36-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x} 3.7: py37-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x} 3.8: py38-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.71, 0.7x}, publish, static + 3.9: py39-pytest{3.5, 3.x, 4.0, 4.x, 5.0, 5.x, 6.0, 6.x}-mypy{0.78, 0.7x} [testenv] deps = @@ -127,8 +129,11 @@ deps = mypy0.76: mypy >= 0.760, < 0.770 mypy0.77: mypy >= 0.770, < 0.780 mypy0.78: mypy >= 0.780, < 0.790 + mypy0.79: mypy >= 0.790, < 0.800 mypy0.7x: mypy >= 0.700, < 0.800 + pexpect ~= 4.8.0 + commands = py.test -p no:mypy --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs:-n auto} tests [testenv:publish] @@ -144,7 +149,7 @@ commands = deps = bandit ~= 1.6.2 flake8 ~= 3.8.3 - mypy >= 0.780, < 0.790 + mypy >= 0.790, < 0.800 commands = bandit --recursive src flake8 setup.py src tests