1
1
"""Mypy static type checker plugin for Pytest"""
2
2
3
+ from __future__ import annotations
4
+
3
5
from dataclasses import dataclass
4
6
import json
5
7
from pathlib import Path
6
8
from tempfile import NamedTemporaryFile
7
- from typing import Dict , List , Optional , TextIO
9
+ import typing
8
10
import warnings
9
11
10
- from filelock import FileLock # type: ignore
12
+ from filelock import FileLock
11
13
import mypy .api
12
14
import pytest
13
15
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
+
14
37
15
38
@dataclass (frozen = True ) # compat python < 3.10 (kw_only=True)
16
39
class MypyConfigStash :
@@ -19,30 +42,34 @@ class MypyConfigStash:
19
42
mypy_results_path : Path
20
43
21
44
@classmethod
22
- def from_serialized (cls , serialized ) :
45
+ def from_serialized (cls , serialized : str ) -> MypyConfigStash :
23
46
return cls (mypy_results_path = Path (serialized ))
24
47
25
- def serialized (self ):
48
+ def serialized (self ) -> str :
26
49
return str (self .mypy_results_path )
27
50
28
51
29
- mypy_argv = []
52
+ mypy_argv : List [ str ] = []
30
53
nodeid_name = "mypy"
31
54
stash_key = {
32
55
"config" : pytest .StashKey [MypyConfigStash ](),
33
56
}
34
57
terminal_summary_title = "mypy"
35
58
36
59
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 :
38
65
"""Create a string to be displayed when mypy finds errors in a file."""
39
66
return "\n " .join (errors )
40
67
41
68
42
69
file_error_formatter = default_file_error_formatter
43
70
44
71
45
- def pytest_addoption (parser ) :
72
+ def pytest_addoption (parser : pytest . Parser ) -> None :
46
73
"""Add options for enabling and running mypy."""
47
74
group = parser .getgroup ("mypy" )
48
75
group .addoption ("--mypy" , action = "store_true" , help = "run mypy on .py files" )
@@ -59,31 +86,33 @@ def pytest_addoption(parser):
59
86
)
60
87
61
88
62
- def _xdist_worker (config ) :
89
+ def _xdist_worker (config : pytest . Config ) -> Dict [ str , Any ] :
63
90
try :
64
91
return {"input" : _xdist_workerinput (config )}
65
92
except AttributeError :
66
93
return {}
67
94
68
95
69
- def _xdist_workerinput (node ) :
96
+ def _xdist_workerinput (node : Union [ WorkerController , pytest . Config ]) -> Any :
70
97
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]
72
101
except AttributeError : # compat xdist < 2.0
73
- return node .slaveinput
102
+ return node .slaveinput # type: ignore[union-attr]
74
103
75
104
76
105
class MypyXdistControllerPlugin :
77
106
"""A plugin that is only registered on xdist controller processes."""
78
107
79
- def pytest_configure_node (self , node ) :
108
+ def pytest_configure_node (self , node : WorkerController ) -> None :
80
109
"""Pass the config stash to workers."""
81
110
_xdist_workerinput (node )["mypy_config_stash_serialized" ] = node .config .stash [
82
111
stash_key ["config" ]
83
112
].serialized ()
84
113
85
114
86
- def pytest_configure (config ) :
115
+ def pytest_configure (config : pytest . Config ) -> None :
87
116
"""
88
117
Initialize the path used to cache mypy results,
89
118
register a custom marker for MypyItems,
@@ -125,7 +154,10 @@ def pytest_configure(config):
125
154
mypy_argv .append (f"--config-file={ mypy_config_file } " )
126
155
127
156
128
- def pytest_collect_file (file_path , parent ):
157
+ def pytest_collect_file (
158
+ file_path : Path ,
159
+ parent : pytest .Collector ,
160
+ ) -> Optional [MypyFile ]:
129
161
"""Create a MypyFileItem for every file mypy should run on."""
130
162
if file_path .suffix in {".py" , ".pyi" } and any (
131
163
[
@@ -145,7 +177,7 @@ def pytest_collect_file(file_path, parent):
145
177
class MypyFile (pytest .File ):
146
178
"""A File that Mypy will run on."""
147
179
148
- def collect (self ):
180
+ def collect (self ) -> Iterator [ MypyItem ] :
149
181
"""Create a MypyFileItem for the File."""
150
182
yield MypyFileItem .from_parent (parent = self , name = nodeid_name )
151
183
# Since mypy might check files that were not collected,
@@ -163,24 +195,28 @@ class MypyItem(pytest.Item):
163
195
164
196
MARKER = "mypy"
165
197
166
- def __init__ (self , * args , ** kwargs ):
198
+ def __init__ (self , * args : Any , ** kwargs : Any ):
167
199
super ().__init__ (* args , ** kwargs )
168
200
self .add_marker (self .MARKER )
169
201
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 ]:
171
207
"""
172
208
Unwrap mypy errors so we get a clean error message without the
173
209
full exception repr.
174
210
"""
175
211
if excinfo .errisinstance (MypyError ):
176
- return excinfo .value .args [0 ]
212
+ return str ( excinfo .value .args [0 ])
177
213
return super ().repr_failure (excinfo )
178
214
179
215
180
216
class MypyFileItem (MypyItem ):
181
217
"""A check for Mypy errors in a File."""
182
218
183
- def runtest (self ):
219
+ def runtest (self ) -> None :
184
220
"""Raise an exception if mypy found errors for this item."""
185
221
results = MypyResults .from_session (self .session )
186
222
abspath = str (self .path .absolute ())
@@ -193,10 +229,10 @@ def runtest(self):
193
229
raise MypyError (file_error_formatter (self , results , errors ))
194
230
warnings .warn ("\n " + "\n " .join (errors ), MypyWarning )
195
231
196
- def reportinfo (self ):
232
+ def reportinfo (self ) -> Tuple [ str , None , str ] :
197
233
"""Produce a heading for the test report."""
198
234
return (
199
- self .path ,
235
+ str ( self .path ) ,
200
236
None ,
201
237
str (self .path .relative_to (self .config .invocation_params .dir )),
202
238
)
@@ -205,7 +241,7 @@ def reportinfo(self):
205
241
class MypyStatusItem (MypyItem ):
206
242
"""A check for a non-zero mypy exit status."""
207
243
208
- def runtest (self ):
244
+ def runtest (self ) -> None :
209
245
"""Raise a MypyError if mypy exited with a non-zero status."""
210
246
results = MypyResults .from_session (self .session )
211
247
if results .status :
@@ -216,7 +252,7 @@ def runtest(self):
216
252
class MypyResults :
217
253
"""Parsed results from Mypy."""
218
254
219
- _abspath_errors_type = Dict [str , List [str ]]
255
+ _abspath_errors_type = typing . Dict [str , typing . List [str ]]
220
256
221
257
opts : List [str ]
222
258
stdout : str
@@ -230,7 +266,7 @@ def dump(self, results_f: TextIO) -> None:
230
266
return json .dump (vars (self ), results_f )
231
267
232
268
@classmethod
233
- def load (cls , results_f : TextIO ) -> " MypyResults" :
269
+ def load (cls , results_f : TextIO ) -> MypyResults :
234
270
"""Get results cached by dump()."""
235
271
return cls (** json .load (results_f ))
236
272
@@ -240,7 +276,7 @@ def from_mypy(
240
276
paths : List [Path ],
241
277
* ,
242
278
opts : Optional [List [str ]] = None ,
243
- ) -> " MypyResults" :
279
+ ) -> MypyResults :
244
280
"""Generate results from mypy."""
245
281
246
282
if opts is None :
@@ -275,7 +311,7 @@ def from_mypy(
275
311
)
276
312
277
313
@classmethod
278
- def from_session (cls , session ) -> " MypyResults" :
314
+ def from_session (cls , session : pytest . Session ) -> MypyResults :
279
315
"""Load (or generate) cached mypy results for a pytest session."""
280
316
mypy_results_path = session .config .stash [stash_key ["config" ]].mypy_results_path
281
317
with FileLock (str (mypy_results_path ) + ".lock" ):
@@ -309,7 +345,11 @@ class MypyWarning(pytest.PytestWarning):
309
345
class MypyReportingPlugin :
310
346
"""A Pytest plugin that reports mypy results."""
311
347
312
- def pytest_terminal_summary (self , terminalreporter , config ):
348
+ def pytest_terminal_summary (
349
+ self ,
350
+ terminalreporter : TerminalReporter ,
351
+ config : pytest .Config ,
352
+ ) -> None :
313
353
"""Report stderr and unrecognized lines from stdout."""
314
354
mypy_results_path = config .stash [stash_key ["config" ]].mypy_results_path
315
355
try :
0 commit comments