diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index cca2f1d..dd36e5f 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8768342..26b71fb 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -4,10 +4,6 @@ Contributing Contributions are very welcome. Tests can be run with `tox `_. Please ensure the coverage at least stays the same before you submit a pull request. -.. image:: https://travis-ci.org/dbader/pytest-mypy.svg?branch=master - :target: https://travis-ci.org/dbader/pytest-mypy - :alt: See Build Status on Travis CI - Development Environment Setup ----------------------------- @@ -21,7 +17,7 @@ Here's how to install pytest-mypy in development mode so you can test your chang How to publish a new version to PyPI ------------------------------------ -Push a tag, and Travis CI will publish it automatically. +Push a tag, and the release will be published automatically. To publish manually: .. code-block:: bash diff --git a/README.rst b/README.rst index 8c91fea..a71c336 100644 --- a/README.rst +++ b/README.rst @@ -63,11 +63,11 @@ Meta Daniel Bader – `@dbader_org`_ – https://dbader.org – mail@dbader.org -https://github.com/dbader/pytest-mypy +https://github.com/realpython/pytest-mypy .. _`MIT`: http://opensource.org/licenses/MIT -.. _`file an issue`: https://github.com/dbader/pytest-mypy/issues +.. _`file an issue`: https://github.com/realpython/pytest-mypy/issues .. _`pip`: https://pypi.python.org/pypi/pip/ .. _`PyPI`: https://pypi.python.org/pypi .. _`mypy`: http://mypy-lang.org/ diff --git a/changelog.md b/changelog.md index d0393fe..ef37f37 100644 --- a/changelog.md +++ b/changelog.md @@ -1,79 +1,3 @@ # Changelog -## [0.10.3](https://github.com/dbader/pytest-mypy/milestone/21) -* Stop failing if mypy only produces notes. - -## [0.10.2](https://github.com/dbader/pytest-mypy/milestone/20) -* Update and loosen [build-system] requirements. - -## [0.10.1](https://github.com/dbader/pytest-mypy/milestone/19) -* Work around https://github.com/python/mypy/issues/14042. -* Add support for Python 3.11. - -## [0.10.0](https://github.com/dbader/pytest-mypy/milestone/18) -* Drop support for python<3.6. - -## [0.9.1](https://github.com/dbader/pytest-mypy/milestone/17) -* Add support for pytest 7. - -## [0.9.0](https://github.com/dbader/pytest-mypy/milestone/14) -* Drop support for pytest<4.6. -* Add --mypy-config-file. - -## [0.8.1](https://github.com/dbader/pytest-mypy/milestone/16) -* Add a partial workaround for https://github.com/pytest-dev/pytest/issues/8016. - -## [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. -* Enable custom error formatting. -* Fix compatibility with pytest-xdist 2. - -## [0.6.2](https://github.com/dbader/pytest-mypy/milestone/12) -* Stop ignoring `.pyi` files. - -## [0.6.1](https://github.com/dbader/pytest-mypy/milestone/11) -* Fix a PytestDeprecationWarning emitted by pytest>=5.4 - -## [0.6.0](https://github.com/dbader/pytest-mypy/milestone/10) -* Inject a test that checks the mypy exit status - -## [0.5.0](https://github.com/dbader/pytest-mypy/milestone/9) -* Remove `MypyItem.mypy_path` -* Add support for pytest-xdist -* Add a configurable name to MypyItem node IDs - -## [0.4.2](https://github.com/dbader/pytest-mypy/milestone/8) -* Make success message green instead of red -* Remove Python 3.8 beta/dev references -* Stop blacklisting early 0.5x and 0.7x mypy releases - -## [0.4.1](https://github.com/dbader/pytest-mypy/milestone/7) -* Stop overlapping `python_version`s in `install_requires` - -## [0.4.0](https://github.com/dbader/pytest-mypy/milestone/6) -* Run mypy once per session instead of once per file -* Stop passing --incremental (which mypy now defaults to) -* Support configuring the plugin in a conftest.py -* Add support for Python 3.8 - -## [0.3.3](https://github.com/dbader/pytest-mypy/milestone/3) -* Register `mypy` marker. -* Add a PEP 518 `[build-system]` -* Add dependency pins for Python 3.4 -* Add support for Python 3.7 - -## [0.3.2](https://github.com/dbader/pytest-mypy/milestone/2) -* Add `mypy` marker to run mypy checks only - -## [0.3.1](https://github.com/dbader/pytest-mypy/milestone/1) -* Only depend on `mypy.api` -* Add `--mypy-ignore-missing-imports` -* Invoke `mypy` with `--incremental` - -## [0.3.0](https://github.com/dbader/pytest-mypy/milestone/5) -* Change `mypy` dependency to pull in `mypy` instead of `mypy-lang` +The Changelog has moved to https://github.com/realpython/pytest-mypy/releases diff --git a/pyproject.toml b/pyproject.toml index f26c121..be63658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,44 @@ [build-system] -requires = ["setuptools >= 59.6", "setuptools-scm[toml] >= 6.4", "wheel >= 0.37.1"] +requires = ["setuptools >= 61.0", "setuptools-scm >= 7.1"] build-backend = "setuptools.build_meta" +[project] +name = "pytest-mypy" +dynamic = ["version"] +description = "A Pytest Plugin for Mypy" +readme = "README.rst" +license = {file = "LICENSE"} +maintainers = [ + {name = "David Tucker", email = "david@tucker.name"} +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Pytest", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Testing", +] +requires-python = ">=3.8" +dependencies = [ + "filelock>=3.0", + "mypy>=1.0", + "pytest>=7.0", +] + +[project.entry-points.pytest11] +mypy = "pytest_mypy" + +[project.urls] +homepage = "https://github.com/realpython/pytest-mypy" + [tool.setuptools_scm] diff --git a/setup.py b/setup.py deleted file mode 100644 index 9dbc660..0000000 --- a/setup.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import glob -import os -import codecs -from setuptools import setup, find_packages # type: ignore - - -def read(fname): - file_path = os.path.join(os.path.dirname(__file__), fname) - return codecs.open(file_path, encoding="utf-8").read() - - -setup( - name="pytest-mypy", - use_scm_version=True, - author="Daniel Bader", - author_email="mail@dbader.org", - maintainer="David Tucker", - maintainer_email="david@tucker.name", - license="MIT", - url="https://github.com/dbader/pytest-mypy", - description="Mypy static type checker plugin for Pytest", - long_description=read("README.rst"), - long_description_content_type="text/x-rst", - packages=find_packages("src"), - package_dir={"": "src"}, - py_modules=[ - os.path.splitext(os.path.basename(path))[0] for path in glob.glob("src/*.py") - ], - python_requires=">=3.6", - setup_requires=["setuptools-scm>=3.5"], - install_requires=[ - "attrs>=19.0", - "filelock>=3.0", - 'pytest>=4.6; python_version>="3.6" and python_version<"3.10"', - 'pytest>=6.2; python_version>="3.10"', - 'mypy>=0.500; python_version<"3.8"', - 'mypy>=0.700; python_version>="3.8" and python_version<"3.9"', - 'mypy>=0.780; python_version>="3.9" and python_version<"3.11"', - 'mypy>=0.900; python_version>="3.11"', - ], - classifiers=[ - "Development Status :: 4 - Beta", - "Framework :: Pytest", - "Intended Audience :: Developers", - "Topic :: Software Development :: Testing", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: Implementation :: CPython", - "Operating System :: OS Independent", - "License :: OSI Approved :: MIT License", - ], - entry_points={"pytest11": ["mypy = pytest_mypy"]}, -) diff --git a/src/pytest_mypy.py b/src/pytest_mypy.py deleted file mode 100644 index faa02a7..0000000 --- a/src/pytest_mypy.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Mypy static type checker plugin for Pytest""" - -import json -import os -from pathlib import Path -from tempfile import NamedTemporaryFile -from typing import Dict, List, Optional, TextIO -import warnings - -import attr -from filelock import FileLock # type: ignore -import mypy.api -import pytest # type: ignore - - -PYTEST_MAJOR_VERSION = int(pytest.__version__.partition(".")[0]) -mypy_argv = [] -nodeid_name = "mypy" - - -def default_file_error_formatter(item, results, errors): - """Create a string to be displayed when mypy finds errors in a file.""" - return "\n".join(errors) - - -file_error_formatter = default_file_error_formatter - - -def pytest_addoption(parser): - """Add options for enabling and running mypy.""" - group = parser.getgroup("mypy") - group.addoption("--mypy", action="store_true", help="run mypy on .py files") - group.addoption( - "--mypy-ignore-missing-imports", - action="store_true", - help="suppresses error messages about imports that cannot be resolved", - ) - group.addoption( - "--mypy-config-file", - action="store", - type=str, - help="adds custom mypy config file", - ) - - -XDIST_WORKERINPUT_ATTRIBUTE_NAMES = ( - "workerinput", - # xdist < 2.0.0: - "slaveinput", -) - - -def _get_xdist_workerinput(config_node): - workerinput = None - for attr_name in XDIST_WORKERINPUT_ATTRIBUTE_NAMES: - workerinput = getattr(config_node, attr_name, None) - if workerinput is not None: - break - return workerinput - - -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 _get_xdist_workerinput(config) is None - - -def pytest_configure(config): - """ - 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 workers 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.""" - _get_xdist_workerinput(node)[ - "_mypy_results_path" - ] = node.config._mypy_results_path - - config.pluginmanager.register(_MypyXdistPlugin()) - - config.addinivalue_line( - "markers", - f"{MypyItem.MARKER}: mark tests to be checked by mypy.", - ) - if config.getoption("--mypy-ignore-missing-imports"): - mypy_argv.append("--ignore-missing-imports") - - mypy_config_file = config.getoption("--mypy-config-file") - if mypy_config_file: - mypy_argv.append(f"--config-file={mypy_config_file}") - - -def pytest_collect_file(file_path, parent): - """Create a MypyFileItem for every file mypy should run on.""" - if file_path.suffix in {".py", ".pyi"} and any( - [ - parent.config.option.mypy, - parent.config.option.mypy_config_file, - parent.config.option.mypy_ignore_missing_imports, - ], - ): - # Do not create MypyFile instance for a .py file if a - # .pyi file with the same name already exists; - # pytest will complain about duplicate modules otherwise - if file_path.suffix == ".pyi" or not file_path.with_suffix(".pyi").is_file(): - return MypyFile.from_parent(parent=parent, path=file_path) - return None - - -if PYTEST_MAJOR_VERSION < 7: # pragma: no cover - _pytest_collect_file = pytest_collect_file - - def pytest_collect_file(path, parent): # type: ignore - try: - # https://docs.pytest.org/en/7.0.x/deprecations.html#py-path-local-arguments-for-hooks-replaced-with-pathlib-path - return _pytest_collect_file(Path(str(path)), parent) - except TypeError: - # https://docs.pytest.org/en/7.0.x/deprecations.html#fspath-argument-for-node-constructors-replaced-with-pathlib-path - return MypyFile.from_parent(parent=parent, fspath=path) - - -class MypyFile(pytest.File): - - """A File that Mypy will run on.""" - - @classmethod - def from_parent(cls, *args, **kwargs): - """Override from_parent for compatibility.""" - # pytest.File.from_parent did not exist before pytest 5.4. - return getattr(super(), "from_parent", cls)(*args, **kwargs) - - def collect(self): - """Create a MypyFileItem for the File.""" - yield MypyFileItem.from_parent(parent=self, 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): - - """A Mypy-related test Item.""" - - MARKER = "mypy" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.add_marker(self.MARKER) - - def collect(self): - """ - Partially work around https://github.com/pytest-dev/pytest/issues/8016 - for pytest < 6.0 with --looponfail. - """ - yield self - - @classmethod - def from_parent(cls, *args, **kwargs): - """Override from_parent for compatibility.""" - # pytest.Item.from_parent did not exist before pytest 5.4. - return getattr(super(), "from_parent", cls)(*args, **kwargs) - - def repr_failure(self, excinfo): - """ - Unwrap mypy errors so we get a clean error message without the - full exception repr. - """ - if excinfo.errisinstance(MypyError): - return excinfo.value.args[0] - return super().repr_failure(excinfo) - - -class MypyFileItem(MypyItem): - - """A check for Mypy errors in a File.""" - - def runtest(self): - """Raise an exception if mypy found errors for this item.""" - results = MypyResults.from_session(self.session) - abspath = os.path.abspath(str(self.fspath)) - errors = results.abspath_errors.get(abspath) - if errors: - if not all( - error.partition(":")[2].partition(":")[0].strip() == "note" - for error in errors - ): - raise MypyError(file_error_formatter(self, results, errors)) - # This line cannot be easily covered on mypy < 0.990: - warnings.warn("\n" + "\n".join(errors), MypyWarning) # pragma: no cover - - def reportinfo(self): - """Produce a heading for the test report.""" - return ( - self.fspath, - None, - self.config.invocation_dir.bestrelpath(self.fspath), - ) - - -class MypyStatusItem(MypyItem): - - """A check for a non-zero mypy exit status.""" - - def runtest(self): - """Raise a MypyError if mypy exited with a non-zero status.""" - results = MypyResults.from_session(self.session) - if results.status: - raise MypyError(f"mypy exited with status {results.status}.") - - -@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 + [os.path.relpath(key) for key in abspath_errors.keys()] - ) - - 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"] - ) - 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): - """ - An error caught by mypy, e.g a type checker violation - or a syntax error. - """ - - -class MypyWarning(pytest.PytestWarning): - """A non-failure message regarding the mypy run.""" - - -def pytest_terminal_summary(terminalreporter, config): - """Report stderr and unrecognized lines from stdout.""" - try: - 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: - 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/src/pytest_mypy/__init__.py b/src/pytest_mypy/__init__.py new file mode 100644 index 0000000..dd80bf3 --- /dev/null +++ b/src/pytest_mypy/__init__.py @@ -0,0 +1,449 @@ +"""Mypy static type checker plugin for Pytest""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from tempfile import NamedTemporaryFile +import typing + +from filelock import FileLock +import mypy.api +import pytest + +if typing.TYPE_CHECKING: # pragma: no cover + from typing import ( + Any, + Dict, + IO, + Iterator, + List, + Optional, + Tuple, + Union, + ) + + # https://github.com/pytest-dev/pytest/issues/7469 + from _pytest._code.code import TerminalRepr + + # https://github.com/pytest-dev/pytest/pull/12661 + from _pytest.terminal import TerminalReporter + + # https://github.com/pytest-dev/pytest-xdist/issues/1121 + from xdist.workermanage import WorkerController # type: ignore + + +@dataclass(frozen=True) # compat python < 3.10 (kw_only=True) +class MypyConfigStash: + """Plugin data stored in the pytest.Config stash.""" + + mypy_results_path: Path + + @classmethod + def from_serialized(cls, serialized: str) -> MypyConfigStash: + return cls(mypy_results_path=Path(serialized)) + + def serialized(self) -> str: + return str(self.mypy_results_path) + + +item_marker = "mypy" +mypy_argv: List[str] = [] +nodeid_name = "mypy" +stash_key = { + "config": pytest.StashKey[MypyConfigStash](), +} +terminal_summary_title = "mypy" + + +def default_test_name_formatter(*, item: MypyFileItem) -> str: + path = item.path.relative_to(item.config.invocation_params.dir) + return f"[{terminal_summary_title}] {path}" + + +test_name_formatter = default_test_name_formatter + + +def default_file_error_formatter( + item: MypyItem, + results: MypyResults, + lines: List[str], +) -> str: + """Create a string to be displayed when mypy finds errors in a file.""" + if item.config.option.mypy_report_style == "mypy": + return "\n".join(lines) + return "\n".join(line.partition(":")[2].strip() for line in lines) + + +file_error_formatter = default_file_error_formatter + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add options for enabling and running mypy.""" + group = parser.getgroup("mypy") + group.addoption("--mypy", action="store_true", help="run mypy on .py files") + group.addoption( + "--mypy-ignore-missing-imports", + action="store_true", + help="suppresses error messages about imports that cannot be resolved", + ) + group.addoption( + "--mypy-config-file", + action="store", + type=str, + help="adds custom mypy config file", + ) + styles = { + "mypy": "modify the original mypy output as little as possible", + "no-path": "(default) strip the path prefix from mypy errors", + } + group.addoption( + "--mypy-report-style", + choices=list(styles), + help="change the way mypy output is reported:\n" + + "\n".join(f"- {name}: {desc}" for name, desc in styles.items()), + ) + group.addoption( + "--mypy-no-status-check", + action="store_true", + help="ignore mypy's exit status", + ) + group.addoption( + "--mypy-xfail", + action="store_true", + help="xfail mypy errors", + ) + + +def _xdist_worker(config: pytest.Config) -> Dict[str, Any]: + try: + return {"input": _xdist_workerinput(config)} + except AttributeError: + return {} + + +def _xdist_workerinput(node: Union[WorkerController, pytest.Config]) -> Any: + try: + # mypy complains that pytest.Config does not have this attribute, + # but xdist.remote defines it in worker processes. + return node.workerinput # type: ignore[union-attr] + except AttributeError: # compat xdist < 2.0 + return node.slaveinput # type: ignore[union-attr] + + +class MypyXdistControllerPlugin: + """A plugin that is only registered on xdist controller processes.""" + + def pytest_configure_node(self, node: WorkerController) -> None: + """Pass the config stash to workers.""" + _xdist_workerinput(node)["mypy_config_stash_serialized"] = node.config.stash[ + stash_key["config"] + ].serialized() + + +def pytest_configure(config: pytest.Config) -> None: + """ + Initialize the path used to cache mypy results, + register a custom marker for MypyItems, + and configure the plugin based on the CLI. + """ + xdist_worker = _xdist_worker(config) + if not xdist_worker: + config.pluginmanager.register(MypyControllerPlugin()) + + # 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.stash[stash_key["config"]] = MypyConfigStash( + mypy_results_path=Path(tmp_f.name), + ) + + # If xdist is enabled, then the results path should be exposed to + # the workers so that they know where to read parsed results from. + if config.pluginmanager.getplugin("xdist"): + config.pluginmanager.register(MypyXdistControllerPlugin()) + else: + # xdist workers create the stash using input from the controller plugin. + config.stash[stash_key["config"]] = MypyConfigStash.from_serialized( + xdist_worker["input"]["mypy_config_stash_serialized"] + ) + + config.addinivalue_line( + "markers", + f"{item_marker}: mark tests to be checked by mypy.", + ) + if config.getoption("--mypy-ignore-missing-imports"): + mypy_argv.append("--ignore-missing-imports") + + mypy_config_file = config.getoption("--mypy-config-file") + if mypy_config_file: + mypy_argv.append(f"--config-file={mypy_config_file}") + + if any( + [ + config.option.mypy, + config.option.mypy_config_file, + config.option.mypy_report_style, + config.option.mypy_ignore_missing_imports, + config.option.mypy_no_status_check, + config.option.mypy_xfail, + ], + ): + config.pluginmanager.register(MypyCollectionPlugin()) + + +class MypyCollectionPlugin: + """A Pytest plugin that collects MypyFiles.""" + + def pytest_collect_file( + self, + file_path: Path, + parent: pytest.Collector, + ) -> Optional[MypyFile]: + """Create a MypyFileItem for every file mypy should run on.""" + if file_path.suffix in {".py", ".pyi"}: + # Do not create MypyFile instance for a .py file if a + # .pyi file with the same name already exists; + # pytest will complain about duplicate modules otherwise + if ( + file_path.suffix == ".pyi" + or not file_path.with_suffix(".pyi").is_file() + ): + return MypyFile.from_parent(parent=parent, path=file_path) + return None + + +class MypyFile(pytest.File): + """A File that Mypy will run on.""" + + def collect(self) -> Iterator[MypyItem]: + """Create a MypyFileItem for the File.""" + yield MypyFileItem.from_parent(parent=self, 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 self.session.config.option.mypy_no_status_check and 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): + """A Mypy-related test Item.""" + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.add_marker(item_marker) + + def repr_failure( + self, + excinfo: pytest.ExceptionInfo[BaseException], + style: Optional[str] = None, + ) -> Union[str, TerminalRepr]: + """ + Unwrap mypy errors so we get a clean error message without the + full exception repr. + """ + if excinfo.errisinstance(MypyError): + return str(excinfo.value.args[0]) + return super().repr_failure(excinfo) + + +def _error_severity(line: str) -> Optional[str]: + components = [component.strip() for component in line.split(":", 3)] + if len(components) < 2: + return None + # The second component is either the line or the severity: + # demo/note.py:2: note: By default the bodies of untyped functions are not checked + # demo/sub/conftest.py: error: Duplicate module named "conftest" + return components[2] if components[1].isdigit() else components[1] + + +class MypyFileItem(MypyItem): + """A check for Mypy errors in a File.""" + + def runtest(self) -> None: + """Raise an exception if mypy found errors for this item.""" + results = MypyResults.from_session(self.session) + lines = results.path_lines.get(self.path.resolve(), []) + if lines and not all(_error_severity(line) == "note" for line in lines): + if self.session.config.option.mypy_xfail: + self.add_marker( + pytest.mark.xfail( + raises=MypyError, + reason="mypy errors are expected by --mypy-xfail.", + ) + ) + raise MypyError(file_error_formatter(self, results, lines)) + + def reportinfo(self) -> Tuple[Path, None, str]: + """Produce a heading for the test report.""" + return (self.path, None, test_name_formatter(item=self)) + + +class MypyStatusItem(MypyItem): + """A check for a non-zero mypy exit status.""" + + def runtest(self) -> None: + """Raise a MypyError if mypy exited with a non-zero status.""" + results = MypyResults.from_session(self.session) + if results.status: + if self.session.config.option.mypy_xfail: + self.add_marker( + pytest.mark.xfail( + raises=MypyError, + reason=( + "A non-zero mypy exit status is expected by --mypy-xfail." + ), + ) + ) + raise MypyError(f"mypy exited with status {results.status}.") + + +@dataclass(frozen=True) # compat python < 3.10 (kw_only=True) +class MypyResults: + """Parsed results from Mypy.""" + + _encoding = "utf-8" + + opts: List[str] + args: List[str] + stdout: str + stderr: str + status: int + path_lines: Dict[Optional[Path], List[str]] + + def dump(self, results_f: IO[bytes]) -> None: + """Cache results in a format that can be parsed by load().""" + prepared = vars(self).copy() + prepared["path_lines"] = { + str(path or ""): lines for path, lines in prepared["path_lines"].items() + } + results_f.write(json.dumps(prepared).encode(self._encoding)) + + @classmethod + def load(cls, results_f: IO[bytes]) -> MypyResults: + """Get results cached by dump().""" + prepared = json.loads(results_f.read().decode(cls._encoding)) + prepared["path_lines"] = { + Path(path) if path else None: lines + for path, lines in prepared["path_lines"].items() + } + return cls(**prepared) + + @classmethod + def from_mypy( + cls, + paths: List[Path], + *, + opts: Optional[List[str]] = None, + ) -> MypyResults: + """Generate results from mypy.""" + + if opts is None: + opts = mypy_argv[:] + args = [str(path) for path in paths] + + stdout, stderr, status = mypy.api.run(opts + args) + + path_lines: Dict[Optional[Path], List[str]] = { + path.resolve(): [] for path in paths + } + path_lines[None] = [] + for line in stdout.split("\n"): + if not line: + continue + path = Path(line.partition(":")[0]).resolve() + try: + lines = path_lines[path] + except KeyError: + lines = path_lines[None] + lines.append(line) + + return cls( + opts=opts, + args=args, + stdout=stdout, + stderr=stderr, + status=status, + path_lines=path_lines, + ) + + @classmethod + def from_session(cls, session: pytest.Session) -> MypyResults: + """Load (or generate) cached mypy results for a pytest session.""" + mypy_results_path = session.config.stash[stash_key["config"]].mypy_results_path + with FileLock(str(mypy_results_path) + ".lock"): + try: + with open(mypy_results_path, mode="rb") as results_f: + results = cls.load(results_f) + except FileNotFoundError: + cwd = Path.cwd() + results = cls.from_mypy( + [ + item.path.relative_to(cwd) + for item in session.items + if isinstance(item, MypyFileItem) + ], + ) + with open(mypy_results_path, mode="wb") as results_f: + results.dump(results_f) + return results + + +class MypyError(Exception): + """ + An error caught by mypy, e.g a type checker violation + or a syntax error. + """ + + +class MypyControllerPlugin: + """A plugin that is not registered on xdist worker processes.""" + + def pytest_terminal_summary( + self, + terminalreporter: TerminalReporter, + config: pytest.Config, + ) -> None: + """Report mypy results.""" + mypy_results_path = config.stash[stash_key["config"]].mypy_results_path + try: + with open(mypy_results_path, mode="rb") as results_f: + results = MypyResults.load(results_f) + except FileNotFoundError: + # No MypyItems executed. + return + if not results.stdout and not results.stderr: + return + terminalreporter.section(terminal_summary_title) + if results.stdout: + if config.option.mypy_xfail: + terminalreporter.write(results.stdout) + else: + for note in ( + unreported_note + for path, lines in results.path_lines.items() + if path is not None + if all(_error_severity(line) == "note" for line in lines) + for unreported_note in lines + ): + terminalreporter.write_line(note) + if results.path_lines.get(None): + color = {"red": True} if results.status else {"green": True} + terminalreporter.write_line( + "\n".join(results.path_lines[None]), **color + ) + if results.stderr: + terminalreporter.write_line(results.stderr, yellow=True) + + def pytest_unconfigure(self, config: pytest.Config) -> None: + """Clean up the mypy results path.""" + config.stash[stash_key["config"]].mypy_results_path.unlink(missing_ok=True) diff --git a/src/pytest_mypy/py.typed b/src/pytest_mypy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 694d7d5..2306bcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,7 @@ +import mypy.version + pytest_plugins = "pytester" + + +def pytest_report_header(): + return f"mypy: {mypy.version.__version__}" diff --git a/tests/test_pytest_mypy.py b/tests/test_pytest_mypy.py index 6e86af3..498b5f4 100644 --- a/tests/test_pytest_mypy.py +++ b/tests/test_pytest_mypy.py @@ -1,14 +1,26 @@ import signal +import sys import textwrap import mypy.version from packaging.version import Version -import pexpect import pytest +import pytest_mypy + MYPY_VERSION = Version(mypy.version.__version__) PYTEST_VERSION = Version(pytest.__version__) +PYTHON_VERSION = Version( + ".".join( + str(token) + for token in [ + sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro, + ] + ) +) @pytest.fixture( @@ -40,12 +52,37 @@ def pyfunc(x: int) -> int: ) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = pyfile_count mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK + + +@pytest.mark.skipif( + PYTEST_VERSION < Version("7.4"), + reason="https://github.com/pytest-dev/pytest/pull/10935", +) +@pytest.mark.skipif( + PYTHON_VERSION < Version("3.10"), + reason="PEP 597 was added in Python 3.10.", +) +@pytest.mark.skipif( + PYTHON_VERSION >= Version("3.12") and MYPY_VERSION < Version("1.5"), + reason="https://github.com/python/mypy/pull/15558", +) +def test_mypy_encoding_warnings(testdir, monkeypatch): + """Ensure no warnings are detected by PYTHONWARNDEFAULTENCODING.""" + testdir.makepyfile("") + monkeypatch.setenv("PYTHONWARNDEFAULTENCODING", "1") + result = testdir.runpytest_subprocess("--mypy") + mypy_file_checks = 1 + mypy_status_check = 1 + mypy_checks = mypy_file_checks + mypy_status_check + expected_warnings = 2 # https://github.com/python/mypy/issues/14603 + result.assert_outcomes(passed=mypy_checks, warnings=expected_warnings) def test_mypy_pyi(testdir, xdist_args): @@ -76,7 +113,7 @@ def pyfunc(x: int) -> int: ... mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_error(testdir, xdist_args): @@ -89,16 +126,19 @@ def pyfunc(x: int) -> str: ) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() + assert "_mypy_results_path" not in result.stderr.str() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(failed=mypy_checks) result.stdout.fnmatch_lines(["2: error: Incompatible return value*"]) - assert result.ret != 0 + assert "_mypy_results_path" not in result.stderr.str() + assert result.ret == pytest.ExitCode.TESTS_FAILED -def test_mypy_annotation_unchecked(testdir, xdist_args): +def test_mypy_annotation_unchecked(testdir, xdist_args, tmp_path, monkeypatch): """Verify that annotation-unchecked warnings do not manifest as an error.""" testdir.makepyfile( """ @@ -114,15 +154,11 @@ def pyfunc(x): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check outcomes = {"passed": mypy_checks} - # mypy doesn't emit annotation-unchecked warnings until 0.990: - min_mypy_version = Version("0.990") - if MYPY_VERSION >= min_mypy_version and PYTEST_VERSION >= Version("7.0"): - # assert_outcomes does not support `warnings` until 7.x. - outcomes["warnings"] = 1 result.assert_outcomes(**outcomes) - if MYPY_VERSION >= min_mypy_version: - result.stdout.fnmatch_lines(["*MypyWarning*"]) - assert result.ret == 0 + result.stdout.fnmatch_lines( + ["*:2: note: By default the bodies of untyped functions are not checked*"] + ) + assert result.ret == pytest.ExitCode.OK def test_mypy_ignore_missings_imports(testdir, xdist_args): @@ -153,10 +189,10 @@ def test_mypy_ignore_missings_imports(testdir, xdist_args): ), ], ) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED result = testdir.runpytest_subprocess("--mypy-ignore-missing-imports", *xdist_args) result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_mypy_config_file(testdir, xdist_args): @@ -172,7 +208,7 @@ def pyfunc(x): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK mypy_config_file = testdir.makeini( """ [mypy] @@ -201,10 +237,10 @@ def test_fails(): mypy_status_check = 1 mypy_checks = mypy_file_checks + mypy_status_check result.assert_outcomes(failed=test_count, passed=mypy_checks) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED result = testdir.runpytest_subprocess("--mypy", "-m", "mypy", *xdist_args) result.assert_outcomes(passed=mypy_checks) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_non_mypy_error(testdir, xdist_args): @@ -226,6 +262,7 @@ def runtest(self): ) result = testdir.runpytest_subprocess(*xdist_args) result.assert_outcomes() + assert result.ret == pytest.ExitCode.NO_TESTS_COLLECTED result = testdir.runpytest_subprocess("--mypy", *xdist_args) mypy_file_checks = 1 # conftest.py mypy_status_check = 1 @@ -234,7 +271,7 @@ def runtest(self): passed=mypy_status_check, # conftest.py has no type errors. ) result.stdout.fnmatch_lines(["*" + message]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_mypy_stderr(testdir, xdist_args): @@ -285,7 +322,7 @@ def pytest_configure(config): """, ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK def test_api_nodeid_name(testdir, xdist_args): @@ -302,7 +339,30 @@ def pytest_configure(config): ) result = testdir.runpytest_subprocess("--mypy", "--verbose", *xdist_args) result.stdout.fnmatch_lines(["*conftest.py::" + nodeid_name + "*"]) - assert result.ret == 0 + assert result.ret == pytest.ExitCode.OK + + +def test_api_test_name_formatter(testdir, xdist_args): + """Ensure that the test_name_formatter can be replaced in a conftest.py.""" + test_name = "UnmistakableTestName" + testdir.makepyfile( + conftest=f""" + cause_a_mypy_error: str = 5 + + def custom_test_name_formatter(item): + return "{test_name}" + + def pytest_configure(config): + plugin = config.pluginmanager.getplugin('mypy') + plugin.test_name_formatter = custom_test_name_formatter + """, + ) + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + result.stdout.fnmatch_lines([f"*{test_name}*"]) + mypy_file_check = 1 + mypy_status_check = 1 + result.assert_outcomes(failed=mypy_file_check + mypy_status_check) + assert result.ret == pytest.ExitCode.TESTS_FAILED @pytest.mark.xfail( @@ -313,14 +373,7 @@ def pytest_configure(config): @pytest.mark.parametrize( "module_name", [ - pytest.param( - "__init__", - marks=pytest.mark.xfail( - Version("3.10") <= PYTEST_VERSION < Version("6.2"), - raises=AssertionError, - reason="https://github.com/pytest-dev/pytest/issues/8016", - ), - ), + "__init__", "good", ], ) @@ -343,27 +396,22 @@ def pyfunc(x: int) -> str: mypy_file_checks = 1 mypy_status_check = 1 result.assert_outcomes(passed=mypy_file_checks, failed=mypy_status_check) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED -def test_api_error_formatter(testdir, xdist_args): - """Ensure that the plugin can be configured in a conftest.py.""" +def test_api_file_error_formatter(testdir, xdist_args): + """Ensure that the file_error_formatter can be replaced in a conftest.py.""" testdir.makepyfile( bad=""" def pyfunc(x: int) -> str: return x * 2 """, ) + file_error = "UnmistakableFileError" testdir.makepyfile( - conftest=""" - def custom_file_error_formatter(item, results, errors): - return '\\n'.join( - '{path}:{error}'.format( - path=item.fspath, - error=error, - ) - for error in errors - ) + conftest=f""" + def custom_file_error_formatter(item, results, lines): + return '{file_error}' def pytest_configure(config): plugin = config.pluginmanager.getplugin('mypy') @@ -371,8 +419,28 @@ def pytest_configure(config): """, ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) - result.stdout.fnmatch_lines(["*/bad.py:2: error: Incompatible return value*"]) - assert result.ret != 0 + result.stdout.fnmatch_lines([f"*{file_error}*"]) + assert result.ret == pytest.ExitCode.TESTS_FAILED + + +def test_pyproject_toml(testdir, xdist_args): + """Ensure that the plugin allows configuration with pyproject.toml.""" + testdir.makefile( + ".toml", + pyproject=""" + [tool.mypy] + disallow_untyped_defs = true + """, + ) + testdir.makepyfile( + conftest=""" + def pyfunc(x): + return x * 2 + """, + ) + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + result.stdout.fnmatch_lines(["1: error: Function is missing a type annotation*"]) + assert result.ret == pytest.ExitCode.TESTS_FAILED def test_setup_cfg(testdir, xdist_args): @@ -392,7 +460,7 @@ def pyfunc(x): ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) result.stdout.fnmatch_lines(["1: error: Function is missing a type annotation*"]) - assert result.ret != 0 + assert result.ret == pytest.ExitCode.TESTS_FAILED @pytest.mark.parametrize("module_name", ["__init__", "test_demo"]) @@ -432,14 +500,6 @@ def pyfunc(x: int) -> str: expect_timeout=60.0, ) - num_tests = 2 - if module_name == "__init__" and Version("3.10") <= PYTEST_VERSION < Version("6.2"): - # https://github.com/pytest-dev/pytest/issues/8016 - # Pytest had a bug where it assumed only a Package would have a basename of - # __init__.py. In this test, Pytest mistakes MypyFile for a Package and - # returns after collecting only one object (the MypyFileItem). - num_tests = 1 - def _expect_session(): child.expect("==== test session starts ====") @@ -448,11 +508,9 @@ def _expect_failure(): child.expect("==== FAILURES ====") child.expect(pyfile.basename + " ____") child.expect("2: error: Incompatible return value") - # if num_tests == 2: - # # These only show with mypy>=0.730: - # child.expect("==== mypy ====") - # child.expect("Found 1 error in 1 file (checked 1 source file)") - child.expect(str(num_tests) + " failed") + child.expect("==== mypy ====") + child.expect("Found 1 error in 1 file (checked 1 source file)") + child.expect("2 failed") child.expect("#### LOOPONFAILING ####") _expect_waiting() @@ -471,29 +529,9 @@ def _expect_changed(): def _expect_success(): for _ in range(2): _expect_session() - # if num_tests == 2: - # # These only show with mypy>=0.730: - # child.expect("==== mypy ====") - # child.expect("Success: no issues found in 1 source file") - try: - child.expect(str(num_tests) + " passed") - except pexpect.exceptions.TIMEOUT: - if module_name == "__init__" and ( - Version("6.0") <= PYTEST_VERSION < Version("6.2") - ): - # MypyItems hit the __init__.py bug too when --looponfail - # re-collects them after the failing file is modified. - # Unlike MypyFile, MypyItem is not a Collector, so this used - # to cause an AttributeError until a workaround was added - # (MypyItem.collect was defined to yield itself). - # Mypy probably noticed the __init__.py problem during the - # development of Pytest 6.0, but the error was addressed - # with an isinstance assertion, which broke the workaround. - # Here, we hit that assertion: - child.expect("AssertionError") - child.expect("1 error") - pytest.xfail("https://github.com/pytest-dev/pytest/issues/8016") - raise + child.expect("==== mypy ====") + child.expect("Success: no issues found in 1 source file") + child.expect("2 passed") _expect_waiting() def _break(): @@ -508,25 +546,149 @@ def _break(): child.kill(signal.SIGTERM) -def test_mypy_item_collect(testdir, xdist_args): - """Ensure coverage for a 3.10<=pytest<6.0 workaround.""" +def test_mypy_results_from_mypy_with_opts(): + """MypyResults.from_mypy respects passed options.""" + mypy_results = pytest_mypy.MypyResults.from_mypy([], opts=["--version"]) + assert mypy_results.status == 0 + assert str(MYPY_VERSION) in mypy_results.stdout + + +def test_mypy_no_output(testdir, xdist_args): + """No terminal summary is shown if there is no output from mypy.""" testdir.makepyfile( - """ - def test_mypy_item_collect(request): - plugin = request.config.pluginmanager.getplugin("mypy") - mypy_items = [ - item - for item in request.session.items - if isinstance(item, plugin.MypyItem) - ] - assert mypy_items - for mypy_item in mypy_items: - assert all(item is mypy_item for item in mypy_item.collect()) + # Mypy prints a success message to stderr by default: + # "Success: no issues found in 1 source file" + # Clear stderr and unmatched_stdout to simulate mypy having no output: + conftest=""" + import pytest + + @pytest.hookimpl(trylast=True) + def pytest_configure(config): + pytest_mypy = config.pluginmanager.getplugin("mypy") + mypy_config_stash = config.stash[pytest_mypy.stash_key["config"]] + with open(mypy_config_stash.mypy_results_path, mode="wb") as results_f: + pytest_mypy.MypyResults( + opts=[], + args=[], + stdout="", + stderr="", + status=0, + path_lines={}, + ).dump(results_f) """, ) result = testdir.runpytest_subprocess("--mypy", *xdist_args) - test_count = 1 mypy_file_checks = 1 mypy_status_check = 1 - result.assert_outcomes(passed=test_count + mypy_file_checks + mypy_status_check) + mypy_checks = mypy_file_checks + mypy_status_check + result.assert_outcomes(passed=mypy_checks) + assert result.ret == pytest.ExitCode.OK + assert f"= {pytest_mypy.terminal_summary_title} =" not in str(result.stdout) + + +def test_py_typed(testdir): + """Mypy recognizes that pytest_mypy is typed.""" + name = "typed" + testdir.makepyfile(**{name: "import pytest_mypy"}) + result = testdir.run("mypy", f"{name}.py") assert result.ret == 0 + + +def test_mypy_no_status_check(testdir, xdist_args): + """Verify that --mypy-no-status-check disables MypyStatusItem collection.""" + testdir.makepyfile("one: int = 1") + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + mypy_file_checks = 1 + mypy_status_check = 1 + result.assert_outcomes(passed=mypy_file_checks + mypy_status_check) + assert result.ret == pytest.ExitCode.OK + result = testdir.runpytest_subprocess("--mypy-no-status-check", *xdist_args) + result.assert_outcomes(passed=mypy_file_checks) + assert result.ret == pytest.ExitCode.OK + + +def test_mypy_xfail_passes(testdir, xdist_args): + """Verify that --mypy-xfail passes passes.""" + testdir.makepyfile("one: int = 1") + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + mypy_file_checks = 1 + mypy_status_check = 1 + result.assert_outcomes(passed=mypy_file_checks + mypy_status_check) + assert result.ret == pytest.ExitCode.OK + result = testdir.runpytest_subprocess("--mypy-xfail", *xdist_args) + result.assert_outcomes(passed=mypy_file_checks + mypy_status_check) + assert result.ret == pytest.ExitCode.OK + + +def test_mypy_xfail_xfails(testdir, xdist_args): + """Verify that --mypy-xfail xfails failures.""" + testdir.makepyfile("one: str = 1") + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + mypy_file_checks = 1 + mypy_status_check = 1 + result.assert_outcomes(failed=mypy_file_checks + mypy_status_check) + assert result.ret == pytest.ExitCode.TESTS_FAILED + result = testdir.runpytest_subprocess("--mypy-xfail", *xdist_args) + result.assert_outcomes(xfailed=mypy_file_checks + mypy_status_check) + assert result.ret == pytest.ExitCode.OK + + +def test_mypy_xfail_reports_stdout(testdir, xdist_args): + """Verify that --mypy-xfail reports stdout from mypy.""" + stdout = "a distinct string on stdout" + testdir.makepyfile( + conftest=f""" + import pytest + + @pytest.hookimpl(trylast=True) + def pytest_configure(config): + pytest_mypy = config.pluginmanager.getplugin("mypy") + mypy_config_stash = config.stash[pytest_mypy.stash_key["config"]] + with open(mypy_config_stash.mypy_results_path, mode="wb") as results_f: + pytest_mypy.MypyResults( + opts=[], + args=[], + stdout="{stdout}", + stderr="", + status=0, + path_lines={{}}, + ).dump(results_f) + """, + ) + result = testdir.runpytest_subprocess("--mypy", *xdist_args) + assert result.ret == pytest.ExitCode.OK + assert stdout not in result.stdout.str() + result = testdir.runpytest_subprocess("--mypy-xfail", *xdist_args) + assert result.ret == pytest.ExitCode.OK + assert stdout in result.stdout.str() + + +def test_error_severity(): + """Verify that non-error lines produce no severity.""" + assert pytest_mypy._error_severity("arbitrary line with no error") is None + + +def test_mypy_report_style(testdir, xdist_args): + """Verify that --mypy-report-style functions correctly.""" + module_name = "unmistakable_module_name" + testdir.makepyfile( + **{ + module_name: """ + def pyfunc(x: int) -> str: + return x * 2 + """ + }, + ) + result = testdir.runpytest_subprocess("--mypy-report-style", "no-path", *xdist_args) + mypy_file_checks = 1 + mypy_status_check = 1 + mypy_checks = mypy_file_checks + mypy_status_check + result.assert_outcomes(failed=mypy_checks) + result.stdout.fnmatch_lines(["2: error: Incompatible return value*"]) + assert result.ret == pytest.ExitCode.TESTS_FAILED + result = testdir.runpytest_subprocess("--mypy-report-style", "mypy", *xdist_args) + result.assert_outcomes(failed=mypy_checks) + result.stdout.fnmatch_lines( + [f"{module_name}.py:2: error: Incompatible return value*"] + ) + assert result.ret == pytest.ExitCode.TESTS_FAILED diff --git a/tox.ini b/tox.ini index ff5684c..9deb574 100644 --- a/tox.ini +++ b/tox.ini @@ -1,113 +1,74 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -minversion = 3.20 +minversion = 4.4 isolated_build = true envlist = - py36-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - py37-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - py38-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.71, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - py39-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.78, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - py310-pytest{6.2, 6.x, 7.0, 7.x}-mypy{0.78, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - py311-pytest{6.2, 6.x, 7.0, 7.x}-mypy{0.90, 0.9x} - publish + py38-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + py39-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + py310-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + py311-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + py312-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + py313-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} static + publish [gh-actions] python = - 3.6: py36-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - 3.7: py37-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.50, 0.5x, 0.60, 0.6x, 0.70, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - 3.8: py38-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.71, 0.7x, 0.80, 0.8x, 0.90, 0.9x}, publish, static - 3.9: py39-pytest{4.6, 5.0, 5.x, 6.0, 6.x, 7.0, 7.x}-mypy{0.78, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - 3.10: py310-pytest{6.2, 6.x, 7.0, 7.x}-mypy{0.78, 0.7x, 0.80, 0.8x, 0.90, 0.9x} - 3.11: py311-pytest{6.2, 6.x, 7.0, 7.x}-mypy{0.90, 0.9x} + 3.8: py38-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + 3.9: py39-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + 3.10: py310-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + 3.11: py311-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} + 3.12: py312-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x}, static, publish + 3.13: py313-pytest{7.0, 7.x, 8.0, 8.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x} [testenv] +constrain_package_deps = true deps = - pytest4.6: pytest ~= 4.6.0 - pytest5.0: pytest ~= 5.0.0 - pytest5.x: pytest ~= 5.0 - pytest6.0: pytest ~= 6.0.0 - pytest6.2: pytest ~= 6.2.0 - pytest6.x: pytest ~= 6.0 pytest7.0: pytest ~= 7.0.0 pytest7.x: pytest ~= 7.0 - mypy0.50: mypy >= 0.500, < 0.510 - mypy0.51: mypy >= 0.510, < 0.520 - mypy0.52: mypy >= 0.520, < 0.530 - mypy0.53: mypy >= 0.530, < 0.540 - mypy0.54: mypy >= 0.540, < 0.550 - mypy0.55: mypy >= 0.550, < 0.560 - mypy0.56: mypy >= 0.560, < 0.570 - mypy0.57: mypy >= 0.570, < 0.580 - mypy0.58: mypy >= 0.580, < 0.590 - mypy0.59: mypy >= 0.590, < 0.600 - mypy0.5x: mypy >= 0.500, < 0.600 - mypy0.60: mypy >= 0.600, < 0.610 - mypy0.61: mypy >= 0.610, < 0.620 - mypy0.62: mypy >= 0.620, < 0.630 - mypy0.63: mypy >= 0.630, < 0.640 - mypy0.64: mypy >= 0.640, < 0.650 - mypy0.65: mypy >= 0.650, < 0.660 - mypy0.66: mypy >= 0.660, < 0.670 - mypy0.67: mypy >= 0.670, < 0.680 - mypy0.6x: mypy >= 0.600, < 0.700 - mypy0.70: mypy >= 0.700, < 0.710 - mypy0.71: mypy >= 0.710, < 0.720 - mypy0.72: mypy >= 0.720, < 0.730 - mypy0.73: mypy >= 0.730, < 0.740 - mypy0.74: mypy >= 0.740, < 0.750 - mypy0.75: mypy >= 0.750, < 0.760 - 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 - mypy0.80: mypy >= 0.800, < 0.810 - mypy0.81: mypy >= 0.810, < 0.820 - mypy0.8x: mypy >= 0.800, < 0.900 - mypy0.90: mypy >= 0.900, < 0.910 - mypy0.91: mypy >= 0.910, < 0.920 - mypy0.92: mypy >= 0.920, < 0.930 - mypy0.93: mypy >= 0.930, < 0.940 - mypy0.94: mypy >= 0.940, < 0.950 - mypy0.95: mypy >= 0.950, < 0.960 - mypy0.96: mypy >= 0.960, < 0.970 - mypy0.97: mypy >= 0.970, < 0.980 - mypy0.98: mypy >= 0.980, < 0.990 - mypy0.99: mypy >= 0.990, <= 0.999 - mypy0.9x: mypy >= 0.900, <= 0.999 + pytest8.0: pytest ~= 8.0.0 + pytest8.x: pytest ~= 8.0 + mypy1.0: mypy ~= 1.0.0 + mypy1.x: mypy ~= 1.0 + xdist1.x: pytest-xdist ~= 1.0 + xdist2.0: pytest-xdist ~= 2.0.0 + xdist2.x: pytest-xdist ~= 2.0 + xdist3.0: pytest-xdist ~= 3.0.0 + xdist3.x: pytest-xdist ~= 3.0 packaging ~= 21.3 - pexpect ~= 4.8.0 - pytest-cov ~= 2.10 + pytest-cov ~= 4.1.0 pytest-randomly ~= 3.4 - pytest-xdist ~= 1.34 - -commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-fail-under 100 --cov-report term-missing -n auto} +setenv = + COVERAGE_FILE = .coverage.{envname} +commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-branch --cov-fail-under 100 --cov-report term-missing -n auto} [pytest] testpaths = tests [testenv:publish] passenv = TWINE_* +constrain_package_deps = false deps = - build[virtualenv] ~= 0.9.0 - twine ~= 4.0.0 + build[virtualenv] ~= 1.0.0 + twine ~= 5.0.0 commands = - {envpython} -m build --outdir {distdir} . - twine {posargs:check} {distdir}/* + {envpython} -m build --outdir {envtmpdir} . + twine {posargs:check} {envtmpdir}/* [testenv:static] +basepython = py312 # pytest.Node.from_parent uses typing.Self deps = bandit ~= 1.7.0 - black ~= 22.3.0 - flake8 ~= 4.0.0 - mypy >= 0.900, < 0.910 + black ~= 24.2.0 + flake8 ~= 7.0.0 + mypy ~= 1.11.0 + pytest-xdist >= 3.6.0 # needed for type-checking commands = - black --check src setup.py tests - flake8 src setup.py tests - mypy src setup.py - bandit --recursive src setup.py + black --check src tests + flake8 src tests + mypy --strict src + bandit --recursive src [flake8] max-line-length = 88