pytest-revealtype-injector 0.2.3__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/PKG-INFO +6 -4
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/pyproject.toml +8 -10
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/__init__.py +1 -1
- pytest_revealtype_injector-0.4.0/src/pytest_revealtype_injector/adapter/__init__.py +22 -0
- pytest_revealtype_injector-0.4.0/src/pytest_revealtype_injector/adapter/basedpyright_.py +21 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/adapter/mypy_.py +59 -62
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/adapter/pyright_.py +54 -56
- pytest_revealtype_injector-0.4.0/src/pytest_revealtype_injector/hooks.py +90 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/log.py +1 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/main.py +21 -9
- pytest_revealtype_injector-0.4.0/src/pytest_revealtype_injector/models.py +167 -0
- pytest_revealtype_injector-0.4.0/tests/conftest.py +7 -0
- pytest_revealtype_injector-0.4.0/tests/test_import.py +103 -0
- pytest_revealtype_injector-0.4.0/tests/test_options.py +95 -0
- pytest_revealtype_injector-0.2.3/src/pytest_revealtype_injector/adapter/__init__.py +0 -13
- pytest_revealtype_injector-0.2.3/src/pytest_revealtype_injector/hooks.py +0 -73
- pytest_revealtype_injector-0.2.3/src/pytest_revealtype_injector/models.py +0 -111
- pytest_revealtype_injector-0.2.3/tests/conftest.py +0 -1
- pytest_revealtype_injector-0.2.3/tests/test_basic.py +0 -91
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/.gitignore +0 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/COPYING +0 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/COPYING.mit +0 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/README.md +0 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/plugin.py +0 -0
- {pytest_revealtype_injector-0.2.3 → pytest_revealtype_injector-0.4.0}/src/pytest_revealtype_injector/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-revealtype-injector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Pytest plugin for replacing reveal_type() calls inside test functions with static and runtime type checking result comparison, for confirming type annotation validity.
|
|
5
5
|
Project-URL: homepage, https://github.com/abelcheung/pytest-revealtype-injector
|
|
6
6
|
Author-email: Abel Cheung <abelcheung@gmail.com>
|
|
@@ -21,11 +21,13 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: basedpyright>=1.0
|
|
24
25
|
Requires-Dist: mypy>=1.11.2
|
|
25
|
-
Requires-Dist: pyright
|
|
26
|
-
Requires-Dist: pytest
|
|
26
|
+
Requires-Dist: pyright>=1.1
|
|
27
|
+
Requires-Dist: pytest<9,>=7.0
|
|
27
28
|
Requires-Dist: schema==0.7.7
|
|
28
|
-
Requires-Dist: typeguard
|
|
29
|
+
Requires-Dist: typeguard>=4.3
|
|
30
|
+
Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
|
|
29
31
|
Description-Content-Type: text/markdown
|
|
30
32
|
|
|
31
33
|

|
|
@@ -15,10 +15,12 @@ requires-python = '>=3.10'
|
|
|
15
15
|
license = 'MIT'
|
|
16
16
|
license-files = ['COPYING*']
|
|
17
17
|
dependencies = [
|
|
18
|
+
'typing_extensions >= 4.0; python_version < "3.11"',
|
|
18
19
|
'mypy >= 1.11.2',
|
|
19
|
-
'pyright
|
|
20
|
-
'
|
|
21
|
-
'
|
|
20
|
+
'pyright >= 1.1',
|
|
21
|
+
'basedpyright >= 1.0',
|
|
22
|
+
'pytest >=7.0,<9',
|
|
23
|
+
'typeguard >= 4.3',
|
|
22
24
|
# schema with annotation support is still unreleased
|
|
23
25
|
'schema == 0.7.7',
|
|
24
26
|
]
|
|
@@ -58,9 +60,6 @@ homepage = 'https://github.com/abelcheung/pytest-revealtype-injector'
|
|
|
58
60
|
[project.entry-points.pytest11]
|
|
59
61
|
pytest-revealtype-injector = "pytest_revealtype_injector.plugin"
|
|
60
62
|
|
|
61
|
-
[tool.flit.module]
|
|
62
|
-
name = 'pytest_revealtype_injector'
|
|
63
|
-
|
|
64
63
|
[tool.hatch.version]
|
|
65
64
|
path = 'src/pytest_revealtype_injector/__init__.py'
|
|
66
65
|
|
|
@@ -92,10 +91,9 @@ target-version = "py312"
|
|
|
92
91
|
preview = true
|
|
93
92
|
|
|
94
93
|
[tool.ruff.lint]
|
|
95
|
-
select = [
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
'I',
|
|
94
|
+
select = ['E', 'F', 'I']
|
|
95
|
+
ignore = [
|
|
96
|
+
"E501",
|
|
99
97
|
]
|
|
100
98
|
task-tags = [
|
|
101
99
|
"BUG",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..models import TypeCheckerAdapter
|
|
4
|
+
from . import basedpyright_, mypy_, pyright_
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Hardcode will do for now, it's not like we're going to have more
|
|
8
|
+
# adapters soon. Pyre and PyType are not there yet.
|
|
9
|
+
def generate() -> set[TypeCheckerAdapter]:
|
|
10
|
+
return {
|
|
11
|
+
basedpyright_.generate_adapter(),
|
|
12
|
+
pyright_.generate_adapter(),
|
|
13
|
+
mypy_.generate_adapter(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_adapter_classes() -> list[type[TypeCheckerAdapter]]:
|
|
18
|
+
return [
|
|
19
|
+
basedpyright_.BasedPyrightAdapter,
|
|
20
|
+
pyright_.PyrightAdapter,
|
|
21
|
+
mypy_.MypyAdapter,
|
|
22
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..log import get_logger
|
|
4
|
+
from ..models import TypeCheckerAdapter
|
|
5
|
+
from . import pyright_
|
|
6
|
+
|
|
7
|
+
_logger = get_logger()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NameCollector(pyright_.NameCollector):
|
|
11
|
+
type_checker = "basedpyright"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BasedPyrightAdapter(pyright_.PyrightAdapter):
|
|
15
|
+
id = "basedpyright"
|
|
16
|
+
_executable = "basedpyright"
|
|
17
|
+
_namecollector_class = NameCollector
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_adapter() -> TypeCheckerAdapter:
|
|
21
|
+
return BasedPyrightAdapter()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import ast
|
|
2
4
|
import importlib
|
|
3
5
|
import json
|
|
@@ -7,7 +9,6 @@ from collections.abc import (
|
|
|
7
9
|
Iterable,
|
|
8
10
|
)
|
|
9
11
|
from typing import (
|
|
10
|
-
Any,
|
|
11
12
|
ForwardRef,
|
|
12
13
|
Literal,
|
|
13
14
|
TypedDict,
|
|
@@ -15,7 +16,6 @@ from typing import (
|
|
|
15
16
|
)
|
|
16
17
|
|
|
17
18
|
import mypy.api
|
|
18
|
-
import pytest
|
|
19
19
|
import schema as s
|
|
20
20
|
|
|
21
21
|
from ..log import get_logger
|
|
@@ -40,7 +40,9 @@ class _MypyDiagObj(TypedDict):
|
|
|
40
40
|
severity: Literal["note", "warning", "error"]
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
class
|
|
43
|
+
class NameCollector(NameCollectorBase):
|
|
44
|
+
type_checker = "mypy"
|
|
45
|
+
|
|
44
46
|
def visit_Attribute(self, node: ast.Attribute) -> ast.expr:
|
|
45
47
|
prefix = ast.unparse(node.value)
|
|
46
48
|
name = node.attr
|
|
@@ -69,7 +71,9 @@ class _NameCollector(NameCollectorBase):
|
|
|
69
71
|
if resolved := getattr(self.collected[prefix], name, False):
|
|
70
72
|
code = ast.unparse(node)
|
|
71
73
|
self.collected[code] = resolved
|
|
72
|
-
_logger.debug(
|
|
74
|
+
_logger.debug(
|
|
75
|
+
f"{self.type_checker} NameCollector resolved '{code}' as {resolved}"
|
|
76
|
+
)
|
|
73
77
|
return node
|
|
74
78
|
|
|
75
79
|
# For class defined in local scope, mypy just prepends test
|
|
@@ -102,13 +106,17 @@ class _NameCollector(NameCollectorBase):
|
|
|
102
106
|
pass
|
|
103
107
|
else:
|
|
104
108
|
self.collected[name] = mod
|
|
105
|
-
_logger.debug(
|
|
109
|
+
_logger.debug(
|
|
110
|
+
f"{self.type_checker} NameCollector resolved '{name}' as {mod}"
|
|
111
|
+
)
|
|
106
112
|
return node
|
|
107
113
|
|
|
108
114
|
if hasattr(self.collected["typing"], name):
|
|
109
115
|
obj = getattr(self.collected["typing"], name)
|
|
110
116
|
self.collected[name] = obj
|
|
111
|
-
_logger.debug(
|
|
117
|
+
_logger.debug(
|
|
118
|
+
f"{self.type_checker} NameCollector resolved '{name}' as {obj}"
|
|
119
|
+
)
|
|
112
120
|
return node
|
|
113
121
|
|
|
114
122
|
raise NameError(f'Cannot resolve "{name}"')
|
|
@@ -118,17 +126,17 @@ class _NameCollector(NameCollectorBase):
|
|
|
118
126
|
# Return only the left operand after processing.
|
|
119
127
|
def visit_BinOp(self, node: ast.BinOp) -> ast.expr:
|
|
120
128
|
if isinstance(node.op, ast.MatMult) and isinstance(node.right, ast.Constant):
|
|
121
|
-
# Mypy disallows returning Any
|
|
122
129
|
return cast("ast.expr", self.visit(node.left))
|
|
123
130
|
# For expression that haven't been accounted for, just don't
|
|
124
131
|
# process and allow name resolution to fail
|
|
125
132
|
return node
|
|
126
133
|
|
|
127
134
|
|
|
128
|
-
class
|
|
135
|
+
class MypyAdapter(TypeCheckerAdapter):
|
|
129
136
|
id = "mypy"
|
|
130
|
-
|
|
131
|
-
_type_mesg_re = re.compile(r'
|
|
137
|
+
_executable = "" # unused, calls mypy.api.run() here
|
|
138
|
+
_type_mesg_re = re.compile(r'Revealed type is "(?P<type>.+?)"')
|
|
139
|
+
_namecollector_class = NameCollector
|
|
132
140
|
_schema = s.Schema({
|
|
133
141
|
"file": str,
|
|
134
142
|
"line": int,
|
|
@@ -143,19 +151,20 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
143
151
|
),
|
|
144
152
|
})
|
|
145
153
|
|
|
146
|
-
|
|
147
|
-
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None:
|
|
154
|
+
def run_typechecker_on(self, paths: Iterable[pathlib.Path]) -> None:
|
|
148
155
|
mypy_args = [
|
|
149
156
|
"--output=json",
|
|
150
157
|
]
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
if self.config_file is not None:
|
|
159
|
+
if self.config_file == pathlib.Path():
|
|
160
|
+
cfg_str = "" # see preprocess_config_file() below
|
|
161
|
+
else:
|
|
162
|
+
cfg_str = str(self.config_file)
|
|
155
163
|
mypy_args.append(f"--config-file={cfg_str}")
|
|
156
164
|
|
|
157
165
|
mypy_args.extend(str(p) for p in paths)
|
|
158
166
|
|
|
167
|
+
_logger.debug(f"({self.id}) api.run(): {mypy_args}")
|
|
159
168
|
stdout, stderr, returncode = mypy.api.run(mypy_args)
|
|
160
169
|
|
|
161
170
|
# fatal error, before evaluation happens
|
|
@@ -163,24 +172,41 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
163
172
|
if stderr:
|
|
164
173
|
raise TypeCheckerError(stderr, None, None)
|
|
165
174
|
|
|
175
|
+
lines = stdout.splitlines()
|
|
176
|
+
_logger.info(
|
|
177
|
+
"({}) Return code = {}, diagnostic count = {}.{}".format(
|
|
178
|
+
self.id,
|
|
179
|
+
returncode,
|
|
180
|
+
len(lines),
|
|
181
|
+
" pytest -vv shows all items." if self.log_verbosity < 2 else "",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
166
185
|
# So-called mypy json output is merely a line-by-line
|
|
167
186
|
# transformation of plain text output into json object
|
|
168
|
-
for line in
|
|
187
|
+
for line in lines:
|
|
169
188
|
if len(line) <= 2 or line[0] != "{":
|
|
170
189
|
continue
|
|
190
|
+
if self.log_verbosity >= 2:
|
|
191
|
+
_logger.debug(f"({self.id}) {line}")
|
|
171
192
|
obj = json.loads(line)
|
|
172
|
-
diag = cast(_MypyDiagObj,
|
|
193
|
+
diag = cast(_MypyDiagObj, self._schema.validate(obj))
|
|
173
194
|
filename = pathlib.Path(diag["file"]).name
|
|
174
195
|
pos = FilePos(filename, diag["line"])
|
|
196
|
+
# HACK: Never trust return code from mypy. During early 1.11.x
|
|
197
|
+
# versions, mypy always return 1 for JSON output even when
|
|
198
|
+
# there's no error. Later on mypy command line has fixed this,
|
|
199
|
+
# but not mypy.api.run(), as of 1.13.
|
|
175
200
|
if diag["severity"] != "note":
|
|
176
201
|
raise TypeCheckerError(
|
|
177
|
-
"
|
|
178
|
-
diag["severity"], returncode, diag["message"]
|
|
202
|
+
"{} {} with exit code {}: {}".format(
|
|
203
|
+
self.id, diag["severity"], returncode, diag["message"]
|
|
179
204
|
),
|
|
180
205
|
diag["file"],
|
|
181
206
|
diag["line"],
|
|
207
|
+
diag["code"],
|
|
182
208
|
)
|
|
183
|
-
if (m :=
|
|
209
|
+
if (m := self._type_mesg_re.fullmatch(diag["message"])) is None:
|
|
184
210
|
continue
|
|
185
211
|
# Mypy can insert extra character into expression so that it
|
|
186
212
|
# becomes invalid and unparsable. 0.9x days there
|
|
@@ -190,14 +216,14 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
190
216
|
expression = m["type"].translate({ord(c): None for c in "*?="})
|
|
191
217
|
try:
|
|
192
218
|
# Unlike pyright, mypy output doesn't contain variable name
|
|
193
|
-
|
|
219
|
+
self.typechecker_result[pos] = VarType(None, ForwardRef(expression))
|
|
194
220
|
except SyntaxError as e:
|
|
195
221
|
if (
|
|
196
222
|
m := re.fullmatch(r"<Deleted '(?P<var>.+)'>", expression)
|
|
197
223
|
) is not None:
|
|
198
224
|
raise TypeCheckerError(
|
|
199
225
|
"{} does not support reusing deleted variable '{}'".format(
|
|
200
|
-
|
|
226
|
+
self.id, m["var"]
|
|
201
227
|
),
|
|
202
228
|
diag["file"],
|
|
203
229
|
diag["line"],
|
|
@@ -208,46 +234,17 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
208
234
|
diag["line"],
|
|
209
235
|
) from e
|
|
210
236
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
) -> _NameCollector:
|
|
215
|
-
return _NameCollector(globalns, localns)
|
|
216
|
-
|
|
217
|
-
@classmethod
|
|
218
|
-
def set_config_file(cls, config: pytest.Config) -> None:
|
|
219
|
-
if (path_str := config.option.revealtype_mypy_config) is None:
|
|
220
|
-
_logger.info("Using default mypy configuration")
|
|
221
|
-
return
|
|
222
|
-
|
|
237
|
+
def preprocess_config_file(self, path_str: str) -> bool:
|
|
238
|
+
if path_str:
|
|
239
|
+
return False
|
|
223
240
|
# HACK: when path_str is empty string, use no config file
|
|
224
241
|
# ('mypy --config-file=')
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
relpath = pathlib.Path(path_str)
|
|
233
|
-
if relpath.is_absolute():
|
|
234
|
-
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
235
|
-
result = (config.rootpath / relpath).resolve()
|
|
236
|
-
if not result.exists():
|
|
237
|
-
raise FileNotFoundError(f"Path '{result}' not found")
|
|
238
|
-
|
|
239
|
-
_logger.info(f"Using mypy configuration file at {result}")
|
|
240
|
-
cls.config_file = result
|
|
241
|
-
|
|
242
|
-
@staticmethod
|
|
243
|
-
def add_pytest_option(group: pytest.OptionGroup) -> None:
|
|
244
|
-
group.addoption(
|
|
245
|
-
"--revealtype-mypy-config",
|
|
246
|
-
type=str,
|
|
247
|
-
default=None,
|
|
248
|
-
help="Mypy configuration file, path is relative to pytest rootdir. "
|
|
249
|
-
"If unspecified, use mypy default behavior",
|
|
250
|
-
)
|
|
242
|
+
# The special value is for satisfying typing constraint;
|
|
243
|
+
# it will be treated specially in run_typechecker_on()
|
|
244
|
+
self.config_file = pathlib.Path()
|
|
245
|
+
self._logger.info(f"({self.id}) Config file usage forbidden")
|
|
246
|
+
return True
|
|
251
247
|
|
|
252
248
|
|
|
253
|
-
|
|
249
|
+
def generate_adapter() -> TypeCheckerAdapter:
|
|
250
|
+
return MypyAdapter()
|
|
@@ -1,22 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import ast
|
|
2
4
|
import json
|
|
3
5
|
import pathlib
|
|
4
6
|
import re
|
|
5
7
|
import shutil
|
|
6
8
|
import subprocess
|
|
9
|
+
import sys
|
|
7
10
|
from collections.abc import (
|
|
8
11
|
Iterable,
|
|
9
12
|
)
|
|
10
13
|
from typing import (
|
|
11
|
-
Any,
|
|
12
14
|
ForwardRef,
|
|
13
15
|
Literal,
|
|
14
|
-
TypedDict,
|
|
15
16
|
TypeVar,
|
|
16
17
|
cast,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
if sys.version_info >= (3, 11):
|
|
21
|
+
from typing import NotRequired, TypedDict
|
|
22
|
+
else:
|
|
23
|
+
from typing_extensions import NotRequired, TypedDict
|
|
24
|
+
|
|
20
25
|
import schema as s
|
|
21
26
|
|
|
22
27
|
from ..log import get_logger
|
|
@@ -46,9 +51,10 @@ class _PyrightDiagItem(TypedDict):
|
|
|
46
51
|
severity: Literal["information", "warning", "error"]
|
|
47
52
|
message: str
|
|
48
53
|
range: _PyrightDiagRange
|
|
54
|
+
rule: NotRequired[str]
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
class NameCollector(NameCollectorBase):
|
|
57
|
+
type_checker = "pyright"
|
|
52
58
|
# Pre-register common used bare names from typing
|
|
53
59
|
collected = NameCollectorBase.collected | {
|
|
54
60
|
k: v
|
|
@@ -68,18 +74,21 @@ class _NameCollector(NameCollectorBase):
|
|
|
68
74
|
continue
|
|
69
75
|
obj = getattr(self.collected[m], name)
|
|
70
76
|
self.collected[name] = obj
|
|
71
|
-
_logger.debug(
|
|
77
|
+
_logger.debug(
|
|
78
|
+
f"{self.type_checker} NameCollector resolved '{name}' as {obj}"
|
|
79
|
+
)
|
|
72
80
|
return node
|
|
73
81
|
raise
|
|
74
82
|
return node
|
|
75
83
|
|
|
76
84
|
|
|
77
|
-
class
|
|
85
|
+
class PyrightAdapter(TypeCheckerAdapter):
|
|
78
86
|
id = "pyright"
|
|
79
|
-
|
|
80
|
-
_type_mesg_re = re.compile('
|
|
81
|
-
|
|
82
|
-
#
|
|
87
|
+
_executable = "pyright"
|
|
88
|
+
_type_mesg_re = re.compile('Type of "(?P<var>.+?)" is "(?P<type>.+?)"')
|
|
89
|
+
_namecollector_class = NameCollector
|
|
90
|
+
# We only care about diagnostic messages that contain type information, that
|
|
91
|
+
# is, items under "generalDiagnostics" key. Metadata not validated here.
|
|
83
92
|
_schema = s.Schema({
|
|
84
93
|
"file": str,
|
|
85
94
|
"severity": s.Or(
|
|
@@ -92,30 +101,42 @@ class _PyrightAdapter(TypeCheckerAdapter):
|
|
|
92
101
|
"start": {"line": int, "character": int},
|
|
93
102
|
"end": {"line": int, "character": int},
|
|
94
103
|
},
|
|
104
|
+
s.Optional("rule"): str,
|
|
95
105
|
})
|
|
96
106
|
|
|
97
|
-
|
|
98
|
-
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None:
|
|
107
|
+
def run_typechecker_on(self, paths: Iterable[pathlib.Path]) -> None:
|
|
99
108
|
cmd: list[str] = []
|
|
100
|
-
if shutil.which(
|
|
101
|
-
cmd.append(
|
|
109
|
+
if shutil.which(self._executable) is not None:
|
|
110
|
+
cmd.append(self._executable)
|
|
102
111
|
elif shutil.which("npx") is not None:
|
|
103
|
-
cmd.extend(["npx",
|
|
112
|
+
cmd.extend(["npx", self._executable])
|
|
104
113
|
else:
|
|
105
|
-
raise FileNotFoundError("
|
|
114
|
+
raise FileNotFoundError(f"{self._executable} is required to run test suite")
|
|
106
115
|
|
|
107
116
|
cmd.append("--outputjson")
|
|
108
|
-
if
|
|
109
|
-
cmd.extend(["--project", str(
|
|
117
|
+
if self.config_file is not None:
|
|
118
|
+
cmd.extend(["--project", str(self.config_file)])
|
|
110
119
|
cmd.extend(str(p) for p in paths)
|
|
111
120
|
|
|
121
|
+
_logger.debug(f"({self.id}) Run command: {cmd}")
|
|
112
122
|
proc = subprocess.run(cmd, capture_output=True)
|
|
113
123
|
if len(proc.stderr):
|
|
114
124
|
raise TypeCheckerError(proc.stderr.decode(), None, None)
|
|
115
125
|
|
|
116
126
|
report = json.loads(proc.stdout)
|
|
127
|
+
_logger.info(
|
|
128
|
+
"({}) Return code = {}, diagnostic count = {}.{}".format(
|
|
129
|
+
self.id,
|
|
130
|
+
proc.returncode,
|
|
131
|
+
len(report["generalDiagnostics"]),
|
|
132
|
+
" pytest -vv shows all items." if self.log_verbosity < 2 else "",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
117
136
|
for item in report["generalDiagnostics"]:
|
|
118
|
-
diag = cast(_PyrightDiagItem,
|
|
137
|
+
diag = cast(_PyrightDiagItem, self._schema.validate(item))
|
|
138
|
+
if self.log_verbosity >= 2:
|
|
139
|
+
_logger.debug(f"({self.id}) {diag}")
|
|
119
140
|
if diag["severity"] != ("error" if proc.returncode else "information"):
|
|
120
141
|
continue
|
|
121
142
|
# Pyright report lineno is 0-based, while
|
|
@@ -123,43 +144,20 @@ class _PyrightAdapter(TypeCheckerAdapter):
|
|
|
123
144
|
lineno = diag["range"]["start"]["line"] + 1
|
|
124
145
|
filename = pathlib.Path(diag["file"]).name
|
|
125
146
|
if proc.returncode:
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
assert "rule" in diag
|
|
148
|
+
raise TypeCheckerError(
|
|
149
|
+
"{} {} with exit code {}: {}".format(
|
|
150
|
+
self.id, diag["severity"], proc.returncode, diag["message"]
|
|
151
|
+
),
|
|
152
|
+
filename,
|
|
153
|
+
lineno,
|
|
154
|
+
diag["rule"],
|
|
155
|
+
)
|
|
156
|
+
if (m := self._type_mesg_re.fullmatch(diag["message"])) is None:
|
|
128
157
|
continue
|
|
129
158
|
pos = FilePos(filename, lineno)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
@classmethod
|
|
133
|
-
def create_collector(
|
|
134
|
-
cls, globalns: dict[str, Any], localns: dict[str, Any]
|
|
135
|
-
) -> _NameCollector:
|
|
136
|
-
return _NameCollector(globalns, localns)
|
|
137
|
-
|
|
138
|
-
@classmethod
|
|
139
|
-
def set_config_file(cls, config: pytest.Config) -> None:
|
|
140
|
-
if (path_str := config.option.revealtype_pyright_config) is None:
|
|
141
|
-
_logger.info("Using default pyright configuration")
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
relpath = pathlib.Path(path_str)
|
|
145
|
-
if relpath.is_absolute():
|
|
146
|
-
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
147
|
-
result = (config.rootpath / relpath).resolve()
|
|
148
|
-
if not result.exists():
|
|
149
|
-
raise FileNotFoundError(f"Path '{result}' not found")
|
|
150
|
-
|
|
151
|
-
_logger.info(f"Using pyright configuration file at {result}")
|
|
152
|
-
cls.config_file = result
|
|
153
|
-
|
|
154
|
-
@staticmethod
|
|
155
|
-
def add_pytest_option(group: pytest.OptionGroup) -> None:
|
|
156
|
-
group.addoption(
|
|
157
|
-
"--revealtype-pyright-config",
|
|
158
|
-
type=str,
|
|
159
|
-
default=None,
|
|
160
|
-
help="Pyright configuration file, path is relative to pytest rootdir. "
|
|
161
|
-
"If unspecified, use pyright default behavior",
|
|
162
|
-
)
|
|
159
|
+
self.typechecker_result[pos] = VarType(m["var"], ForwardRef(m["type"]))
|
|
163
160
|
|
|
164
161
|
|
|
165
|
-
|
|
162
|
+
def generate_adapter() -> TypeCheckerAdapter:
|
|
163
|
+
return PyrightAdapter()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from . import adapter, log
|
|
10
|
+
from .main import revealtype_injector
|
|
11
|
+
from .models import TypeCheckerAdapter
|
|
12
|
+
|
|
13
|
+
_logger = log.get_logger()
|
|
14
|
+
adapter_stash_key: pytest.StashKey[set[TypeCheckerAdapter]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> None:
|
|
18
|
+
assert pyfuncitem.module is not None
|
|
19
|
+
adapters = pyfuncitem.config.stash[adapter_stash_key].copy()
|
|
20
|
+
injected = functools.partial(revealtype_injector, adapters=adapters)
|
|
21
|
+
|
|
22
|
+
for name in dir(pyfuncitem.module):
|
|
23
|
+
if name.startswith("__") or name.startswith("@py"):
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
item = getattr(pyfuncitem.module, name)
|
|
27
|
+
if inspect.isfunction(item):
|
|
28
|
+
if item.__name__ == "reveal_type" and item.__module__ in {
|
|
29
|
+
"typing",
|
|
30
|
+
"typing_extensions",
|
|
31
|
+
}:
|
|
32
|
+
setattr(pyfuncitem.module, name, injected)
|
|
33
|
+
_logger.info(f"Replaced {name}() from global import with {injected}")
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if inspect.ismodule(item):
|
|
37
|
+
if item.__name__ not in {"typing", "typing_extensions"}:
|
|
38
|
+
continue
|
|
39
|
+
assert hasattr(item, "reveal_type")
|
|
40
|
+
setattr(item, "reveal_type", injected)
|
|
41
|
+
_logger.info(f"Replaced {name}.reveal_type() with {injected}")
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pytest_collection_finish(session: pytest.Session) -> None:
|
|
46
|
+
files = {i.path for i in session.items}
|
|
47
|
+
for adp in session.config.stash[adapter_stash_key]:
|
|
48
|
+
try:
|
|
49
|
+
adp.run_typechecker_on(files)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
_logger.error(f"({adp.id}) {e}")
|
|
52
|
+
pytest.exit(f"({type(e).__name__}) " + str(e), pytest.ExitCode.INTERNAL_ERROR)
|
|
53
|
+
else:
|
|
54
|
+
_logger.info(f"({adp.id}) Type checker ran successfully")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
58
|
+
group = parser.getgroup(
|
|
59
|
+
"revealtype-injector",
|
|
60
|
+
description="Type checker related options for revealtype-injector",
|
|
61
|
+
)
|
|
62
|
+
classes = adapter.get_adapter_classes()
|
|
63
|
+
group.addoption(
|
|
64
|
+
"--revealtype-disable-adapter",
|
|
65
|
+
type=str,
|
|
66
|
+
choices=tuple(c.id for c in classes),
|
|
67
|
+
action="append",
|
|
68
|
+
default=[],
|
|
69
|
+
help="Disable specific type checker. Can be used multiple times"
|
|
70
|
+
" to disable multiple checkers",
|
|
71
|
+
)
|
|
72
|
+
for c in classes:
|
|
73
|
+
c.add_pytest_option(group)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
77
|
+
global adapter_stash_key
|
|
78
|
+
adapter_stash_key = pytest.StashKey[set[TypeCheckerAdapter]]()
|
|
79
|
+
config.stash[adapter_stash_key] = set()
|
|
80
|
+
verbosity = config.get_verbosity(config.VERBOSITY_TEST_CASES)
|
|
81
|
+
log.set_verbosity(verbosity)
|
|
82
|
+
to_be_disabled = cast(list[str], config.getoption("revealtype_disable_adapter"))
|
|
83
|
+
for klass in adapter.get_adapter_classes():
|
|
84
|
+
if klass.id in to_be_disabled:
|
|
85
|
+
_logger.info(f"({klass.id}) adapter disabled with command line option")
|
|
86
|
+
continue
|
|
87
|
+
adp = klass()
|
|
88
|
+
adp.set_config_file(config)
|
|
89
|
+
adp.log_verbosity = verbosity
|
|
90
|
+
config.stash[adapter_stash_key].add(adp)
|