From 3b59b06f7a663fa28d612f636044838b038d16b7 Mon Sep 17 00:00:00 2001 From: David Tucker Date: Wed, 12 Aug 2020 08:47:11 -0700 Subject: [PATCH 1/6] Create MypyResults --- setup.py | 1 + src/pytest_mypy.py | 162 +++++++++++++++++++++++++-------------------- 2 files changed, 93 insertions(+), 70 deletions(-) diff --git a/setup.py b/setup.py index 7c5c163..bf1dbf0 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 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"', diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index b58b3dc..6ba0a40 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 @@ -178,9 +179,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 +200,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 +303,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) From b785fa31561b6db7ec624778240b9415fd2e1b20 Mon Sep 17 00:00:00 2001 From: David Tucker Date: Sat, 10 Oct 2020 17:25:51 -0700 Subject: [PATCH 2/6] Test with mypy-0.790 --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ff00899..124dc8b 100644 --- a/tox.ini +++ b/tox.ini @@ -127,6 +127,7 @@ 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 commands = py.test -p no:mypy --cov pytest_mypy --cov-fail-under 100 --cov-report term-missing {posargs:-n auto} tests @@ -144,7 +145,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 From 9b2154aadafef8f9723e5a5d33582010ae431b88 Mon Sep 17 00:00:00 2001 From: David Tucker Date: Sat, 10 Oct 2020 17:54:06 -0700 Subject: [PATCH 3/6] Add support for Python 3.9 This also updates the tox minversion to a very recent version in hopes of triggering tox provisioning, which seems to avoid https://github.com/tox-dev/tox/issues/1484 --- .github/workflows/validation.yml | 2 +- setup.py | 4 +++- tox.ini | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) 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/setup.py b/setup.py index bf1dbf0..e19434e 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,8 @@ def read(fname): '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', @@ -52,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/tox.ini b/tox.ini index 124dc8b..92d12bd 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 = From cd459a48ce11d24e1bddf6877ef53f7d26cf3daf Mon Sep 17 00:00:00 2001 From: David Tucker Date: Sat, 25 Apr 2020 19:59:58 -0700 Subject: [PATCH 4/6] Stop injecting in pytest_collection_modifyitems --- src/pytest_mypy.py | 30 ++++++------------ tests/test_pytest_mypy.py | 64 --------------------------------------- 2 files changed, 10 insertions(+), 84 deletions(-) diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py index 6ba0a40..54ba108 100644 --- a/src/pytest_mypy.py +++ b/src/pytest_mypy.py @@ -125,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): diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index 45c211c..6a2dce5 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -214,38 +214,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 +227,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=''' From 09e4746b7d9092ffb23d824179057fa3a0cf3173 Mon Sep 17 00:00:00 2001 From: David Tucker Date: Mon, 5 Oct 2020 23:06:32 -0700 Subject: [PATCH 5/6] Add test_looponfail --- tests/test_pytest_mypy.py | 87 +++++++++++++++++++++++++++++++++++++++ tox.ini | 2 + 2 files changed, 89 insertions(+) diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index 6a2dce5..29f9ea9 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -1,3 +1,6 @@ +import signal +import textwrap + import pytest @@ -269,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 92d12bd..73eac3d 100644 --- a/tox.ini +++ b/tox.ini @@ -132,6 +132,8 @@ deps = 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] From c61f3ec1ce1784730511bcd966d5be27f1dc659b Mon Sep 17 00:00:00 2001 From: David Tucker Date: Fri, 13 Nov 2020 14:16:17 -0800 Subject: [PATCH 6/6] Update changelog.md for v0.8.0 --- changelog.md | 4 ++++ 1 file changed, 4 insertions(+) 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.