Skip to content

Commit abda733

Browse files
authored
Merge pull request #178 from dmtucker/types
Add strict type-checking
2 parents 749aae2 + fdc1116 commit abda733

File tree

4 files changed

+84
-33
lines changed

4 files changed

+84
-33
lines changed

src/pytest_mypy.py renamed to src/pytest_mypy/__init__.py

+67-27
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
"""Mypy static type checker plugin for Pytest"""
22

3+
from __future__ import annotations
4+
35
from dataclasses import dataclass
46
import json
57
from pathlib import Path
68
from tempfile import NamedTemporaryFile
7-
from typing import Dict, List, Optional, TextIO
9+
import typing
810
import warnings
911

10-
from filelock import FileLock # type: ignore
12+
from filelock import FileLock
1113
import mypy.api
1214
import pytest
1315

16+
if typing.TYPE_CHECKING: # pragma: no cover
17+
from typing import (
18+
Any,
19+
Dict,
20+
Iterator,
21+
List,
22+
Optional,
23+
TextIO,
24+
Tuple,
25+
Union,
26+
)
27+
28+
# https://github.com/pytest-dev/pytest/issues/7469
29+
from _pytest._code.code import TerminalRepr
30+
31+
# https://github.com/pytest-dev/pytest/pull/12661
32+
from _pytest.terminal import TerminalReporter
33+
34+
# https://github.com/pytest-dev/pytest-xdist/issues/1121
35+
from xdist.workermanage import WorkerController # type: ignore
36+
1437

1538
@dataclass(frozen=True) # compat python < 3.10 (kw_only=True)
1639
class MypyConfigStash:
@@ -19,30 +42,34 @@ class MypyConfigStash:
1942
mypy_results_path: Path
2043

2144
@classmethod
22-
def from_serialized(cls, serialized):
45+
def from_serialized(cls, serialized: str) -> MypyConfigStash:
2346
return cls(mypy_results_path=Path(serialized))
2447

25-
def serialized(self):
48+
def serialized(self) -> str:
2649
return str(self.mypy_results_path)
2750

2851

29-
mypy_argv = []
52+
mypy_argv: List[str] = []
3053
nodeid_name = "mypy"
3154
stash_key = {
3255
"config": pytest.StashKey[MypyConfigStash](),
3356
}
3457
terminal_summary_title = "mypy"
3558

3659

37-
def default_file_error_formatter(item, results, errors):
60+
def default_file_error_formatter(
61+
item: MypyItem,
62+
results: MypyResults,
63+
errors: List[str],
64+
) -> str:
3865
"""Create a string to be displayed when mypy finds errors in a file."""
3966
return "\n".join(errors)
4067

4168

4269
file_error_formatter = default_file_error_formatter
4370

4471

45-
def pytest_addoption(parser):
72+
def pytest_addoption(parser: pytest.Parser) -> None:
4673
"""Add options for enabling and running mypy."""
4774
group = parser.getgroup("mypy")
4875
group.addoption("--mypy", action="store_true", help="run mypy on .py files")
@@ -59,31 +86,33 @@ def pytest_addoption(parser):
5986
)
6087

6188

62-
def _xdist_worker(config):
89+
def _xdist_worker(config: pytest.Config) -> Dict[str, Any]:
6390
try:
6491
return {"input": _xdist_workerinput(config)}
6592
except AttributeError:
6693
return {}
6794

6895

69-
def _xdist_workerinput(node):
96+
def _xdist_workerinput(node: Union[WorkerController, pytest.Config]) -> Any:
7097
try:
71-
return node.workerinput
98+
# mypy complains that pytest.Config does not have this attribute,
99+
# but xdist.remote defines it in worker processes.
100+
return node.workerinput # type: ignore[union-attr]
72101
except AttributeError: # compat xdist < 2.0
73-
return node.slaveinput
102+
return node.slaveinput # type: ignore[union-attr]
74103

75104

76105
class MypyXdistControllerPlugin:
77106
"""A plugin that is only registered on xdist controller processes."""
78107

79-
def pytest_configure_node(self, node):
108+
def pytest_configure_node(self, node: WorkerController) -> None:
80109
"""Pass the config stash to workers."""
81110
_xdist_workerinput(node)["mypy_config_stash_serialized"] = node.config.stash[
82111
stash_key["config"]
83112
].serialized()
84113

85114

86-
def pytest_configure(config):
115+
def pytest_configure(config: pytest.Config) -> None:
87116
"""
88117
Initialize the path used to cache mypy results,
89118
register a custom marker for MypyItems,
@@ -125,7 +154,10 @@ def pytest_configure(config):
125154
mypy_argv.append(f"--config-file={mypy_config_file}")
126155

127156

128-
def pytest_collect_file(file_path, parent):
157+
def pytest_collect_file(
158+
file_path: Path,
159+
parent: pytest.Collector,
160+
) -> Optional[MypyFile]:
129161
"""Create a MypyFileItem for every file mypy should run on."""
130162
if file_path.suffix in {".py", ".pyi"} and any(
131163
[
@@ -145,7 +177,7 @@ def pytest_collect_file(file_path, parent):
145177
class MypyFile(pytest.File):
146178
"""A File that Mypy will run on."""
147179

148-
def collect(self):
180+
def collect(self) -> Iterator[MypyItem]:
149181
"""Create a MypyFileItem for the File."""
150182
yield MypyFileItem.from_parent(parent=self, name=nodeid_name)
151183
# Since mypy might check files that were not collected,
@@ -163,24 +195,28 @@ class MypyItem(pytest.Item):
163195

164196
MARKER = "mypy"
165197

166-
def __init__(self, *args, **kwargs):
198+
def __init__(self, *args: Any, **kwargs: Any):
167199
super().__init__(*args, **kwargs)
168200
self.add_marker(self.MARKER)
169201

170-
def repr_failure(self, excinfo):
202+
def repr_failure(
203+
self,
204+
excinfo: pytest.ExceptionInfo[BaseException],
205+
style: Optional[str] = None,
206+
) -> Union[str, TerminalRepr]:
171207
"""
172208
Unwrap mypy errors so we get a clean error message without the
173209
full exception repr.
174210
"""
175211
if excinfo.errisinstance(MypyError):
176-
return excinfo.value.args[0]
212+
return str(excinfo.value.args[0])
177213
return super().repr_failure(excinfo)
178214

179215

180216
class MypyFileItem(MypyItem):
181217
"""A check for Mypy errors in a File."""
182218

183-
def runtest(self):
219+
def runtest(self) -> None:
184220
"""Raise an exception if mypy found errors for this item."""
185221
results = MypyResults.from_session(self.session)
186222
abspath = str(self.path.absolute())
@@ -193,10 +229,10 @@ def runtest(self):
193229
raise MypyError(file_error_formatter(self, results, errors))
194230
warnings.warn("\n" + "\n".join(errors), MypyWarning)
195231

196-
def reportinfo(self):
232+
def reportinfo(self) -> Tuple[str, None, str]:
197233
"""Produce a heading for the test report."""
198234
return (
199-
self.path,
235+
str(self.path),
200236
None,
201237
str(self.path.relative_to(self.config.invocation_params.dir)),
202238
)
@@ -205,7 +241,7 @@ def reportinfo(self):
205241
class MypyStatusItem(MypyItem):
206242
"""A check for a non-zero mypy exit status."""
207243

208-
def runtest(self):
244+
def runtest(self) -> None:
209245
"""Raise a MypyError if mypy exited with a non-zero status."""
210246
results = MypyResults.from_session(self.session)
211247
if results.status:
@@ -216,7 +252,7 @@ def runtest(self):
216252
class MypyResults:
217253
"""Parsed results from Mypy."""
218254

219-
_abspath_errors_type = Dict[str, List[str]]
255+
_abspath_errors_type = typing.Dict[str, typing.List[str]]
220256

221257
opts: List[str]
222258
stdout: str
@@ -230,7 +266,7 @@ def dump(self, results_f: TextIO) -> None:
230266
return json.dump(vars(self), results_f)
231267

232268
@classmethod
233-
def load(cls, results_f: TextIO) -> "MypyResults":
269+
def load(cls, results_f: TextIO) -> MypyResults:
234270
"""Get results cached by dump()."""
235271
return cls(**json.load(results_f))
236272

@@ -240,7 +276,7 @@ def from_mypy(
240276
paths: List[Path],
241277
*,
242278
opts: Optional[List[str]] = None,
243-
) -> "MypyResults":
279+
) -> MypyResults:
244280
"""Generate results from mypy."""
245281

246282
if opts is None:
@@ -275,7 +311,7 @@ def from_mypy(
275311
)
276312

277313
@classmethod
278-
def from_session(cls, session) -> "MypyResults":
314+
def from_session(cls, session: pytest.Session) -> MypyResults:
279315
"""Load (or generate) cached mypy results for a pytest session."""
280316
mypy_results_path = session.config.stash[stash_key["config"]].mypy_results_path
281317
with FileLock(str(mypy_results_path) + ".lock"):
@@ -309,7 +345,11 @@ class MypyWarning(pytest.PytestWarning):
309345
class MypyReportingPlugin:
310346
"""A Pytest plugin that reports mypy results."""
311347

312-
def pytest_terminal_summary(self, terminalreporter, config):
348+
def pytest_terminal_summary(
349+
self,
350+
terminalreporter: TerminalReporter,
351+
config: pytest.Config,
352+
) -> None:
313353
"""Report stderr and unrecognized lines from stdout."""
314354
mypy_results_path = config.stash[stash_key["config"]].mypy_results_path
315355
try:

src/pytest_mypy/py.typed

Whitespace-only changes.

tests/test_pytest_mypy.py

+8
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,11 @@ def pytest_configure(config):
540540
result.assert_outcomes(passed=mypy_checks)
541541
assert result.ret == pytest.ExitCode.OK
542542
assert f"= {pytest_mypy.terminal_summary_title} =" not in str(result.stdout)
543+
544+
545+
def test_py_typed(testdir):
546+
"""Mypy recognizes that pytest_mypy is typed."""
547+
name = "typed"
548+
testdir.makepyfile(**{name: "import pytest_mypy"})
549+
result = testdir.run("mypy", f"{name}.py")
550+
assert result.ret == 0

tox.ini

+9-6
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@ envlist =
99
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}
1010
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}
1111
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}
12-
publish
1312
static
13+
publish
1414

1515
[gh-actions]
1616
python =
1717
3.7: py37-pytest{7.0, 7.x}-mypy{1.0, 1.x}-xdist{1.x, 2.0, 2.x, 3.0, 3.x}
18-
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}, publish, static
18+
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}
1919
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}
2020
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}
2121
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}
22-
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}
22+
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
2323

2424
[testenv]
2525
constrain_package_deps = true
@@ -39,7 +39,8 @@ deps =
3939
packaging ~= 21.3
4040
pytest-cov ~= 4.1.0
4141
pytest-randomly ~= 3.4
42-
42+
setenv =
43+
COVERAGE_FILE = .coverage.{envname}
4344
commands = pytest -p no:mypy {posargs:--cov pytest_mypy --cov-branch --cov-fail-under 100 --cov-report term-missing -n auto}
4445

4546
[pytest]
@@ -56,15 +57,17 @@ commands =
5657
twine {posargs:check} {envtmpdir}/*
5758

5859
[testenv:static]
60+
basepython = py312 # pytest.Node.from_parent uses typing.Self
5961
deps =
6062
bandit ~= 1.7.0
6163
black ~= 24.2.0
6264
flake8 ~= 7.0.0
63-
mypy ~= 1.8.0
65+
mypy ~= 1.11.0
66+
pytest-xdist >= 3.6.0 # needed for type-checking
6467
commands =
6568
black --check src tests
6669
flake8 src tests
67-
mypy src
70+
mypy --strict src
6871
bandit --recursive src
6972

7073
[flake8]

0 commit comments

Comments
 (0)