Repository: dbader/pytest-mypy
Branch: main
Commit: 1b3e931cb6ff
Files: 13
Total size: 48.1 KB
Directory structure:
gitextract_ycxknicb/
├── .github/
│ └── workflows/
│ ├── publication.yml
│ └── validation.yml
├── .gitignore
├── CONTRIBUTING.rst
├── LICENSE
├── README.rst
├── changelog.md
├── pyproject.toml
├── src/
│ └── pytest_mypy/
│ ├── __init__.py
│ └── py.typed
├── tests/
│ ├── conftest.py
│ └── test_pytest_mypy.py
└── tox.ini
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/publication.yml
================================================
name: Publication
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.8'
- run: python -m pip install --upgrade tox-gh-actions
- env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: tox -e publish -- upload
================================================
FILE: .github/workflows/validation.yml
================================================
name: Validation
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade tox-gh-actions
- run: python -m tox
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
================================================
FILE: CONTRIBUTING.rst
================================================
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.
Development Environment Setup
-----------------------------
Here's how to install pytest-mypy in development mode so you can test your changes locally:
.. code-block:: bash
tox --devenv venv
venv/bin/pytest --mypy test_example.py
How to publish a new version to PyPI
------------------------------------
Push a tag, and the release will be published automatically.
To publish manually:
.. code-block:: bash
tox -e publish -- upload
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2016 Daniel Bader
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.rst
================================================
pytest-mypy
===================================
Mypy static type checker plugin for pytest
.. image:: https://img.shields.io/pypi/v/pytest-mypy.svg
:target: https://pypi.org/project/pytest-mypy/
:alt: See Latest Release on PyPI
Features
--------
* Runs the mypy static type checker on your source files as part of your pytest test runs.
* Does for `mypy`_ what the `pytest-flake8`_ plugin does for `flake8`_.
* This is a work in progress – pull requests appreciated.
Installation
------------
You can install "pytest-mypy" via `pip`_ from `PyPI`_:
.. code-block:: bash
$ pip install pytest-mypy
Usage
-----
You can enable pytest-mypy with the ``--mypy`` flag:
.. code-block:: bash
$ py.test --mypy test_*.py
Mypy supports `reading configuration settings `_ from a ``mypy.ini`` file.
Alternatively, the plugin can be configured in a ``conftest.py`` to invoke mypy with extra options:
.. code-block:: python
def pytest_configure(config):
plugin = config.pluginmanager.getplugin('mypy')
plugin.mypy_argv.append('--check-untyped-defs')
You can restrict your test run to only perform mypy checks and not any other tests by using the `-m` option:
.. code-block:: bash
py.test --mypy -m mypy test_*.py
License
-------
Distributed under the terms of the `MIT`_ license, "pytest-mypy" is free and open source software
Issues
------
If you encounter any problems, please `file an issue`_ along with a detailed description.
Meta
----
Daniel Bader – `@dbader_org`_ – https://dbader.org – mail@dbader.org
https://github.com/realpython/pytest-mypy
.. _`MIT`: http://opensource.org/licenses/MIT
.. _`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/
.. _`pytest-flake8`: https://pypi.python.org/pypi/pytest-flake8
.. _`flake8`: https://pypi.python.org/pypi/flake8
.. _`@dbader_org`: https://twitter.com/dbader_org
================================================
FILE: changelog.md
================================================
# Changelog
The Changelog has moved to https://github.com/realpython/pytest-mypy/releases
================================================
FILE: pyproject.toml
================================================
[build-system]
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]
================================================
FILE: src/pytest_mypy/__init__.py
================================================
"""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
try:
path = Path(line.partition(":")[0]).resolve()
except OSError:
path = None
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)
================================================
FILE: src/pytest_mypy/py.typed
================================================
================================================
FILE: tests/conftest.py
================================================
import mypy.version
pytest_plugins = "pytester"
def pytest_report_header():
return f"mypy: {mypy.version.__version__}"
================================================
FILE: tests/test_pytest_mypy.py
================================================
import signal
import sys
import textwrap
import mypy.version
from packaging.version import Version
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(
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("pyfile_count", [1, 2])
def test_mypy_success(testdir, pyfile_count, xdist_args):
"""Verify that running on a module with no type errors passes."""
testdir.makepyfile(
**{
"pyfile_{0}".format(
pyfile_i,
): """
def pyfunc(x: int) -> int:
return x * 2
"""
for pyfile_i in range(pyfile_count)
},
)
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 == 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):
"""
Verify that a .py file will be skipped if
a .pyi file exists with the same filename.
"""
# The incorrect signature below should be ignored
# as the .pyi file takes priority
testdir.makepyfile(
pyfile="""
def pyfunc(x: int) -> str:
return x * 2
""",
)
testdir.makefile(
".pyi",
pyfile="""
def pyfunc(x: int) -> int: ...
""",
)
result = testdir.runpytest_subprocess(*xdist_args)
result.assert_outcomes()
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(passed=mypy_checks)
assert result.ret == pytest.ExitCode.OK
def test_mypy_error(testdir, xdist_args):
"""Verify that running on a module with type errors fails."""
testdir.makepyfile(
"""
def pyfunc(x: int) -> str:
return x * 2
""",
)
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 "_mypy_results_path" not in result.stderr.str()
assert result.ret == pytest.ExitCode.TESTS_FAILED
def test_mypy_path_error(testdir, xdist_args):
"""Verify that runs are not affected by path errors."""
testdir.makepyfile(
conftest="""
def pytest_configure(config):
plugin = config.pluginmanager.getplugin('mypy')
class FakePath:
def __init__(self, _):
pass
def resolve(self):
raise OSError
Path = plugin.Path
plugin.Path = FakePath
plugin.MypyResults.from_mypy([], opts=['--version'])
plugin.Path = Path
""",
)
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(passed=mypy_checks)
assert result.ret == pytest.ExitCode.OK
def test_mypy_annotation_unchecked(testdir, xdist_args, tmp_path):
"""Verify that annotation-unchecked warnings do not manifest as an error."""
testdir.makepyfile(
"""
def pyfunc(x):
y: int = 2
return x * y
""",
)
result = testdir.runpytest_subprocess(*xdist_args)
result.assert_outcomes()
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
mypy_file_checks = 1
mypy_status_check = 1
mypy_checks = mypy_file_checks + mypy_status_check
outcomes = {"passed": mypy_checks}
result.assert_outcomes(**outcomes)
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):
"""
Verify that --mypy-ignore-missing-imports
causes mypy to ignore missing imports.
"""
module_name = "is_always_missing"
testdir.makepyfile(
"""
try:
import {module_name}
except ImportError:
pass
""".format(
module_name=module_name,
),
)
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: Cannot find *module named *{module_name}*".format(
module_name=module_name,
),
],
)
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 == pytest.ExitCode.OK
def test_mypy_config_file(testdir, xdist_args):
"""Verify that --mypy-config-file works."""
testdir.makepyfile(
"""
def pyfunc(x):
return x * 2
""",
)
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(passed=mypy_checks)
assert result.ret == pytest.ExitCode.OK
mypy_config_file = testdir.makeini(
"""
[mypy]
disallow_untyped_defs = True
""",
)
result = testdir.runpytest_subprocess(
"--mypy-config-file",
mypy_config_file,
*xdist_args,
)
result.assert_outcomes(failed=mypy_checks)
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", *xdist_args)
test_count = 1
mypy_file_checks = 1
mypy_status_check = 1
mypy_checks = mypy_file_checks + mypy_status_check
result.assert_outcomes(failed=test_count, passed=mypy_checks)
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 == pytest.ExitCode.OK
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(
conftest="""
def pytest_configure(config):
plugin = config.pluginmanager.getplugin('mypy')
class PatchedMypyFileItem(plugin.MypyFileItem):
def runtest(self):
raise Exception('{message}')
plugin.MypyFileItem = PatchedMypyFileItem
""".format(
message=message,
),
)
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
result.assert_outcomes(
failed=mypy_file_checks, # patched to raise an Exception
passed=mypy_status_check, # conftest.py has no type errors.
)
result.stdout.fnmatch_lines(["*" + message])
assert result.ret == pytest.ExitCode.TESTS_FAILED
def test_mypy_stderr(testdir, xdist_args):
"""Verify that stderr from mypy is printed."""
stderr = "This is stderr from mypy."
testdir.makepyfile(
conftest="""
import mypy.api
def _patched_run(*args, **kwargs):
return '', '{stderr}', 1
mypy.api.run = _patched_run
""".format(
stderr=stderr,
),
)
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
result.stdout.fnmatch_lines([stderr])
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="""
import mypy.api
def _patched_run(*args, **kwargs):
return '{stdout}', '', 1
mypy.api.run = _patched_run
""".format(
stdout=stdout,
),
)
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
result.stdout.fnmatch_lines([stdout])
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", *xdist_args)
assert result.ret == pytest.ExitCode.OK
def test_api_nodeid_name(testdir, xdist_args):
"""Ensure that the plugin can be configured in a conftest.py."""
nodeid_name = "UnmistakableNodeIDName"
testdir.makepyfile(
conftest="""
def pytest_configure(config):
plugin = config.pluginmanager.getplugin('mypy')
plugin.nodeid_name = '{}'
""".format(
nodeid_name,
),
)
result = testdir.runpytest_subprocess("--mypy", "--verbose", *xdist_args)
result.stdout.fnmatch_lines(["*conftest.py::" + nodeid_name + "*"])
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(
Version("0.971") <= MYPY_VERSION,
raises=AssertionError,
reason="https://github.com/python/mypy/issues/13701",
)
@pytest.mark.parametrize(
"module_name",
[
"__init__",
"good",
],
)
def test_mypy_indirect(testdir, xdist_args, module_name):
"""Verify that uncollected files checked by mypy cause a failure."""
testdir.makepyfile(
bad="""
def pyfunc(x: int) -> str:
return x * 2
""",
)
pyfile = testdir.makepyfile(
**{
module_name: """
import bad
""",
},
)
result = testdir.runpytest_subprocess("--mypy", *xdist_args, str(pyfile))
mypy_file_checks = 1
mypy_status_check = 1
result.assert_outcomes(passed=mypy_file_checks, failed=mypy_status_check)
assert result.ret == pytest.ExitCode.TESTS_FAILED
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=f"""
def custom_file_error_formatter(item, results, lines):
return '{file_error}'
def pytest_configure(config):
plugin = config.pluginmanager.getplugin('mypy')
plugin.file_error_formatter = custom_file_error_formatter
""",
)
result = testdir.runpytest_subprocess("--mypy", *xdist_args)
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):
"""Ensure that the plugin allows configuration with setup.cfg."""
testdir.makefile(
".cfg",
setup="""
[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
@pytest.mark.parametrize("module_name", ["__init__", "test_demo"])
def test_looponfail(testdir, module_name):
"""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(**{module_name: 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=60.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")
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()
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)
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(
# 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)
mypy_file_checks = 1
mypy_status_check = 1
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
================================================
FILE: tox.ini
================================================
# For more information about tox, see https://tox.readthedocs.io/en/latest/
[tox]
minversion = 4.4
isolated_build = true
envlist =
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.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 =
pytest7.0: pytest ~= 7.0.0
pytest7.x: pytest ~= 7.0
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
pytest-cov ~= 4.1.0
pytest-randomly ~= 3.4
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] ~= 1.0.0
twine ~= 5.0.0
commands =
{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 ~= 24.2.0
flake8 ~= 7.0.0
mypy ~= 1.11.0
pytest-xdist >= 3.6.0 # needed for type-checking
commands =
black --check src tests
flake8 src tests
mypy --strict src
bandit --recursive src
[flake8]
max-line-length = 88
extend-ignore = E203