pytest-revealtype-injector 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/__init__.py +1 -1
- pytest_revealtype_injector/adapter/__init__.py +12 -4
- pytest_revealtype_injector/adapter/basedpyright_.py +21 -0
- pytest_revealtype_injector/adapter/mypy_.py +56 -60
- pytest_revealtype_injector/adapter/pyright_.py +48 -53
- pytest_revealtype_injector/hooks.py +40 -21
- pytest_revealtype_injector/log.py +1 -0
- pytest_revealtype_injector/main.py +23 -13
- pytest_revealtype_injector/models.py +69 -20
- {pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/METADATA +5 -4
- pytest_revealtype_injector-0.3.0.dist-info/RECORD +17 -0
- pytest_revealtype_injector-0.2.2.dist-info/RECORD +0 -16
- {pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/WHEEL +0 -0
- {pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/entry_points.txt +0 -0
- {pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/licenses/COPYING +0 -0
- {pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/licenses/COPYING.mit +0 -0
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from ..models import TypeCheckerAdapter
|
|
4
|
-
from . import mypy_, pyright_
|
|
4
|
+
from . import basedpyright_, mypy_, pyright_
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
# Hardcode will do for now, it's not like we're going to have more
|
|
8
8
|
# adapters soon. Pyre and PyType are not there yet.
|
|
9
|
-
def
|
|
9
|
+
def generate() -> set[TypeCheckerAdapter]:
|
|
10
10
|
return {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
basedpyright_.generate_adapter(),
|
|
12
|
+
pyright_.generate_adapter(),
|
|
13
|
+
mypy_.generate_adapter(),
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
def get_adapter_classes() -> list[type[TypeCheckerAdapter]]:
|
|
17
|
+
return [
|
|
18
|
+
basedpyright_.BasedPyrightAdapter,
|
|
19
|
+
pyright_.PyrightAdapter,
|
|
20
|
+
mypy_.MypyAdapter,
|
|
21
|
+
]
|
|
@@ -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,15 +172,31 @@ 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
202
|
"Mypy {} with exit code {}: {}".format(
|
|
@@ -180,7 +205,7 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
180
205
|
diag["file"],
|
|
181
206
|
diag["line"],
|
|
182
207
|
)
|
|
183
|
-
if (m :=
|
|
208
|
+
if (m := self._type_mesg_re.fullmatch(diag["message"])) is None:
|
|
184
209
|
continue
|
|
185
210
|
# Mypy can insert extra character into expression so that it
|
|
186
211
|
# becomes invalid and unparsable. 0.9x days there
|
|
@@ -190,14 +215,14 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
190
215
|
expression = m["type"].translate({ord(c): None for c in "*?="})
|
|
191
216
|
try:
|
|
192
217
|
# Unlike pyright, mypy output doesn't contain variable name
|
|
193
|
-
|
|
218
|
+
self.typechecker_result[pos] = VarType(None, ForwardRef(expression))
|
|
194
219
|
except SyntaxError as e:
|
|
195
220
|
if (
|
|
196
221
|
m := re.fullmatch(r"<Deleted '(?P<var>.+)'>", expression)
|
|
197
222
|
) is not None:
|
|
198
223
|
raise TypeCheckerError(
|
|
199
224
|
"{} does not support reusing deleted variable '{}'".format(
|
|
200
|
-
|
|
225
|
+
self.id, m["var"]
|
|
201
226
|
),
|
|
202
227
|
diag["file"],
|
|
203
228
|
diag["line"],
|
|
@@ -208,46 +233,17 @@ class _MypyAdapter(TypeCheckerAdapter):
|
|
|
208
233
|
diag["line"],
|
|
209
234
|
) from e
|
|
210
235
|
|
|
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
|
-
|
|
236
|
+
def preprocess_config_file(self, path_str: str) -> bool:
|
|
237
|
+
if path_str:
|
|
238
|
+
return False
|
|
223
239
|
# HACK: when path_str is empty string, use no config file
|
|
224
240
|
# ('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
|
-
)
|
|
241
|
+
# The special value is for satisfying typing constraint;
|
|
242
|
+
# it will be treated specially in run_typechecker_on()
|
|
243
|
+
self.config_file = pathlib.Path()
|
|
244
|
+
self._logger.info(f"({self.id}) Config file usage forbidden")
|
|
245
|
+
return True
|
|
251
246
|
|
|
252
247
|
|
|
253
|
-
|
|
248
|
+
def generate_adapter() -> TypeCheckerAdapter:
|
|
249
|
+
return MypyAdapter()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import ast
|
|
2
4
|
import json
|
|
3
5
|
import pathlib
|
|
@@ -8,14 +10,13 @@ from collections.abc import (
|
|
|
8
10
|
Iterable,
|
|
9
11
|
)
|
|
10
12
|
from typing import (
|
|
11
|
-
Any,
|
|
12
13
|
ForwardRef,
|
|
13
14
|
Literal,
|
|
14
15
|
TypedDict,
|
|
16
|
+
TypeVar,
|
|
15
17
|
cast,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
|
-
import pytest
|
|
19
20
|
import schema as s
|
|
20
21
|
|
|
21
22
|
from ..log import get_logger
|
|
@@ -34,10 +35,12 @@ class _PyrightDiagPosition(TypedDict):
|
|
|
34
35
|
line: int
|
|
35
36
|
character: int
|
|
36
37
|
|
|
38
|
+
|
|
37
39
|
class _PyrightDiagRange(TypedDict):
|
|
38
40
|
start: _PyrightDiagPosition
|
|
39
41
|
end: _PyrightDiagPosition
|
|
40
42
|
|
|
43
|
+
|
|
41
44
|
class _PyrightDiagItem(TypedDict):
|
|
42
45
|
file: str
|
|
43
46
|
severity: Literal["information", "warning", "error"]
|
|
@@ -45,7 +48,15 @@ class _PyrightDiagItem(TypedDict):
|
|
|
45
48
|
range: _PyrightDiagRange
|
|
46
49
|
|
|
47
50
|
|
|
48
|
-
class
|
|
51
|
+
class NameCollector(NameCollectorBase):
|
|
52
|
+
type_checker = "pyright"
|
|
53
|
+
# Pre-register common used bare names from typing
|
|
54
|
+
collected = NameCollectorBase.collected | {
|
|
55
|
+
k: v
|
|
56
|
+
for k, v in NameCollectorBase.collected["typing"].__dict__.items()
|
|
57
|
+
if k[0].isupper() and not isinstance(v, TypeVar)
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
# Pyright inferred type results always contain bare names only,
|
|
50
61
|
# so don't need to bother with visit_Attribute()
|
|
51
62
|
def visit_Name(self, node: ast.Name) -> ast.Name:
|
|
@@ -58,18 +69,21 @@ class _NameCollector(NameCollectorBase):
|
|
|
58
69
|
continue
|
|
59
70
|
obj = getattr(self.collected[m], name)
|
|
60
71
|
self.collected[name] = obj
|
|
61
|
-
_logger.debug(
|
|
72
|
+
_logger.debug(
|
|
73
|
+
f"{self.type_checker} NameCollector resolved '{name}' as {obj}"
|
|
74
|
+
)
|
|
62
75
|
return node
|
|
63
76
|
raise
|
|
64
77
|
return node
|
|
65
78
|
|
|
66
79
|
|
|
67
|
-
class
|
|
80
|
+
class PyrightAdapter(TypeCheckerAdapter):
|
|
68
81
|
id = "pyright"
|
|
69
|
-
|
|
70
|
-
_type_mesg_re = re.compile('
|
|
71
|
-
|
|
72
|
-
#
|
|
82
|
+
_executable = "pyright"
|
|
83
|
+
_type_mesg_re = re.compile('Type of "(?P<var>.+?)" is "(?P<type>.+?)"')
|
|
84
|
+
_namecollector_class = NameCollector
|
|
85
|
+
# We only care about diagnostic messages that contain type information, that
|
|
86
|
+
# is, items under "generalDiagnostics" key. Metadata not validated here.
|
|
73
87
|
_schema = s.Schema({
|
|
74
88
|
"file": str,
|
|
75
89
|
"severity": s.Or(
|
|
@@ -82,30 +96,42 @@ class _PyrightAdapter(TypeCheckerAdapter):
|
|
|
82
96
|
"start": {"line": int, "character": int},
|
|
83
97
|
"end": {"line": int, "character": int},
|
|
84
98
|
},
|
|
99
|
+
s.Optional("rule"): str,
|
|
85
100
|
})
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
def run_typechecker_on(cls, paths: Iterable[pathlib.Path]) -> None:
|
|
102
|
+
def run_typechecker_on(self, paths: Iterable[pathlib.Path]) -> None:
|
|
89
103
|
cmd: list[str] = []
|
|
90
|
-
if shutil.which(
|
|
91
|
-
cmd.append(
|
|
104
|
+
if shutil.which(self._executable) is not None:
|
|
105
|
+
cmd.append(self._executable)
|
|
92
106
|
elif shutil.which("npx") is not None:
|
|
93
|
-
cmd.extend(["npx",
|
|
107
|
+
cmd.extend(["npx", self._executable])
|
|
94
108
|
else:
|
|
95
|
-
raise FileNotFoundError("
|
|
109
|
+
raise FileNotFoundError(f"{self._executable} is required to run test suite")
|
|
96
110
|
|
|
97
111
|
cmd.append("--outputjson")
|
|
98
|
-
if
|
|
99
|
-
cmd.extend(["--project", str(
|
|
112
|
+
if self.config_file is not None:
|
|
113
|
+
cmd.extend(["--project", str(self.config_file)])
|
|
100
114
|
cmd.extend(str(p) for p in paths)
|
|
101
115
|
|
|
116
|
+
_logger.debug(f"({self.id}) Run command: {cmd}")
|
|
102
117
|
proc = subprocess.run(cmd, capture_output=True)
|
|
103
118
|
if len(proc.stderr):
|
|
104
119
|
raise TypeCheckerError(proc.stderr.decode(), None, None)
|
|
105
120
|
|
|
106
121
|
report = json.loads(proc.stdout)
|
|
122
|
+
_logger.info(
|
|
123
|
+
"({}) Return code = {}, diagnostic count = {}.{}".format(
|
|
124
|
+
self.id,
|
|
125
|
+
proc.returncode,
|
|
126
|
+
len(report["generalDiagnostics"]),
|
|
127
|
+
" pytest -vv shows all items." if self.log_verbosity < 2 else "",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
107
131
|
for item in report["generalDiagnostics"]:
|
|
108
|
-
diag = cast(_PyrightDiagItem,
|
|
132
|
+
diag = cast(_PyrightDiagItem, self._schema.validate(item))
|
|
133
|
+
if self.log_verbosity >= 2:
|
|
134
|
+
_logger.debug(f"({self.id}) {diag}")
|
|
109
135
|
if diag["severity"] != ("error" if proc.returncode else "information"):
|
|
110
136
|
continue
|
|
111
137
|
# Pyright report lineno is 0-based, while
|
|
@@ -114,42 +140,11 @@ class _PyrightAdapter(TypeCheckerAdapter):
|
|
|
114
140
|
filename = pathlib.Path(diag["file"]).name
|
|
115
141
|
if proc.returncode:
|
|
116
142
|
raise TypeCheckerError(diag["message"], filename, lineno)
|
|
117
|
-
if (m :=
|
|
143
|
+
if (m := self._type_mesg_re.fullmatch(diag["message"])) is None:
|
|
118
144
|
continue
|
|
119
145
|
pos = FilePos(filename, lineno)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
@classmethod
|
|
123
|
-
def create_collector(
|
|
124
|
-
cls, globalns: dict[str, Any], localns: dict[str, Any]
|
|
125
|
-
) -> _NameCollector:
|
|
126
|
-
return _NameCollector(globalns, localns)
|
|
127
|
-
|
|
128
|
-
@classmethod
|
|
129
|
-
def set_config_file(cls, config: pytest.Config) -> None:
|
|
130
|
-
if (path_str := config.option.revealtype_pyright_config) is None:
|
|
131
|
-
_logger.info("Using default pyright configuration")
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
relpath = pathlib.Path(path_str)
|
|
135
|
-
if relpath.is_absolute():
|
|
136
|
-
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
137
|
-
result = (config.rootpath / relpath).resolve()
|
|
138
|
-
if not result.exists():
|
|
139
|
-
raise FileNotFoundError(f"Path '{result}' not found")
|
|
140
|
-
|
|
141
|
-
_logger.info(f"Using pyright configuration file at {result}")
|
|
142
|
-
cls.config_file = result
|
|
143
|
-
|
|
144
|
-
@staticmethod
|
|
145
|
-
def add_pytest_option(group: pytest.OptionGroup) -> None:
|
|
146
|
-
group.addoption(
|
|
147
|
-
"--revealtype-pyright-config",
|
|
148
|
-
type=str,
|
|
149
|
-
default=None,
|
|
150
|
-
help="Pyright configuration file, path is relative to pytest rootdir. "
|
|
151
|
-
"If unspecified, use pyright default behavior",
|
|
152
|
-
)
|
|
146
|
+
self.typechecker_result[pos] = VarType(m["var"], ForwardRef(m["type"]))
|
|
153
147
|
|
|
154
148
|
|
|
155
|
-
|
|
149
|
+
def generate_adapter() -> TypeCheckerAdapter:
|
|
150
|
+
return PyrightAdapter()
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import inspect
|
|
5
|
+
from typing import cast
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
7
9
|
from . import adapter, log
|
|
8
10
|
from .main import revealtype_injector
|
|
11
|
+
from .models import TypeCheckerAdapter
|
|
9
12
|
|
|
10
13
|
_logger = log.get_logger()
|
|
14
|
+
adapter_stash_key: pytest.StashKey[set[TypeCheckerAdapter]]
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> None:
|
|
14
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
|
+
|
|
15
22
|
for name in dir(pyfuncitem.module):
|
|
16
23
|
if name.startswith("__") or name.startswith("@py"):
|
|
17
24
|
continue
|
|
@@ -22,9 +29,9 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> None:
|
|
|
22
29
|
"typing",
|
|
23
30
|
"typing_extensions",
|
|
24
31
|
}:
|
|
25
|
-
setattr(pyfuncitem.module, name,
|
|
32
|
+
setattr(pyfuncitem.module, name, injected)
|
|
26
33
|
_logger.info(
|
|
27
|
-
f"Replaced {name}() from global import with {
|
|
34
|
+
f"Replaced {name}() from global import with {injected}"
|
|
28
35
|
)
|
|
29
36
|
continue
|
|
30
37
|
|
|
@@ -32,16 +39,21 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> None:
|
|
|
32
39
|
if item.__name__ not in {"typing", "typing_extensions"}:
|
|
33
40
|
continue
|
|
34
41
|
assert hasattr(item, "reveal_type")
|
|
35
|
-
setattr(item, "reveal_type",
|
|
36
|
-
_logger.info(f"Replaced {name}.reveal_type() with {
|
|
42
|
+
setattr(item, "reveal_type", injected)
|
|
43
|
+
_logger.info(f"Replaced {name}.reveal_type() with {injected}")
|
|
37
44
|
continue
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
def pytest_collection_finish(session: pytest.Session) -> None:
|
|
41
48
|
files = {i.path for i in session.items}
|
|
42
|
-
for adp in
|
|
43
|
-
|
|
49
|
+
for adp in session.config.stash[adapter_stash_key]:
|
|
50
|
+
try:
|
|
44
51
|
adp.run_typechecker_on(files)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
_logger.error(f"({adp.id}) {e}")
|
|
54
|
+
raise e
|
|
55
|
+
else:
|
|
56
|
+
_logger.info(f"({adp.id}) Type checker ran successfully")
|
|
45
57
|
|
|
46
58
|
|
|
47
59
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
@@ -49,25 +61,32 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
49
61
|
"revealtype-injector",
|
|
50
62
|
description="Type checker related options for revealtype-injector",
|
|
51
63
|
)
|
|
52
|
-
|
|
53
|
-
choices = tuple(adp.id for adp in adapters)
|
|
64
|
+
classes = adapter.get_adapter_classes()
|
|
54
65
|
group.addoption(
|
|
55
66
|
"--revealtype-disable-adapter",
|
|
56
67
|
type=str,
|
|
57
|
-
choices=
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
choices=tuple(c.id for c in classes),
|
|
69
|
+
action="append",
|
|
70
|
+
default=[],
|
|
71
|
+
help="Disable specific type checker. Can be used multiple times"
|
|
72
|
+
" to disable multiple checkers",
|
|
60
73
|
)
|
|
61
|
-
for
|
|
62
|
-
|
|
74
|
+
for c in classes:
|
|
75
|
+
c.add_pytest_option(group)
|
|
63
76
|
|
|
64
77
|
|
|
65
78
|
def pytest_configure(config: pytest.Config) -> None:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
global adapter_stash_key
|
|
80
|
+
adapter_stash_key = pytest.StashKey[set[TypeCheckerAdapter]]()
|
|
81
|
+
config.stash[adapter_stash_key] = set()
|
|
82
|
+
verbosity = config.get_verbosity(config.VERBOSITY_TEST_CASES)
|
|
83
|
+
log.set_verbosity(verbosity)
|
|
84
|
+
to_be_disabled = cast(list[str], config.getoption("revealtype_disable_adapter"))
|
|
85
|
+
for klass in adapter.get_adapter_classes():
|
|
86
|
+
if klass.id in to_be_disabled:
|
|
87
|
+
_logger.info(f"({klass.id}) adapter disabled with command line option")
|
|
88
|
+
continue
|
|
89
|
+
adp = klass()
|
|
90
|
+
adp.set_config_file(config)
|
|
91
|
+
adp.log_verbosity = verbosity
|
|
92
|
+
config.stash[adapter_stash_key].add(adp)
|
|
@@ -14,15 +14,15 @@ from typeguard import (
|
|
|
14
14
|
check_type_internal,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
from . import
|
|
17
|
+
from . import log
|
|
18
18
|
from .models import (
|
|
19
19
|
FilePos,
|
|
20
|
+
TypeCheckerAdapter,
|
|
20
21
|
TypeCheckerError,
|
|
21
22
|
VarType,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
_T = TypeVar("_T")
|
|
25
|
-
|
|
26
26
|
_logger = log.get_logger()
|
|
27
27
|
|
|
28
28
|
|
|
@@ -61,7 +61,7 @@ def _get_var_name(frame: inspect.Traceback) -> str | None:
|
|
|
61
61
|
return result
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def revealtype_injector(var: _T) -> _T:
|
|
64
|
+
def revealtype_injector(var: _T, adapters: set[TypeCheckerAdapter]) -> _T:
|
|
65
65
|
"""Replacement of `reveal_type()` that matches static and runtime type
|
|
66
66
|
checking result
|
|
67
67
|
|
|
@@ -105,9 +105,7 @@ def revealtype_injector(var: _T) -> _T:
|
|
|
105
105
|
globalns = caller_frame.f_globals
|
|
106
106
|
localns = caller_frame.f_locals
|
|
107
107
|
|
|
108
|
-
for adp in
|
|
109
|
-
if not adp.enabled:
|
|
110
|
-
continue
|
|
108
|
+
for adp in adapters:
|
|
111
109
|
try:
|
|
112
110
|
tc_result = adp.typechecker_result[pos]
|
|
113
111
|
except KeyError as e:
|
|
@@ -126,17 +124,29 @@ def revealtype_injector(var: _T) -> _T:
|
|
|
126
124
|
adp.typechecker_result[pos] = VarType(var_name, tc_result.type)
|
|
127
125
|
|
|
128
126
|
ref = tc_result.type
|
|
127
|
+
walker = adp.create_collector(globalns, localns)
|
|
129
128
|
try:
|
|
130
|
-
|
|
129
|
+
evaluated = eval(ref.__forward_arg__, globalns, localns | walker.collected)
|
|
131
130
|
except (TypeError, NameError):
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
new_ast = walker.visit(ref_ast)
|
|
131
|
+
old_ast = ast.parse(ref.__forward_arg__, mode="eval")
|
|
132
|
+
new_ast = walker.visit(old_ast)
|
|
135
133
|
if walker.modified:
|
|
136
134
|
ref = ForwardRef(ast.unparse(new_ast))
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
evaluated = eval(ref.__forward_arg__, globalns, localns | walker.collected)
|
|
136
|
+
|
|
137
|
+
# HACK Mainly serves as a guard against mypy's behavior of blanket
|
|
138
|
+
# inferring to Any when it can't determine the type under non-strict
|
|
139
|
+
# mode. This behavior causes typeguard to remain silent, since Any is
|
|
140
|
+
# compatible with everything. This has a side effect of disallowing
|
|
141
|
+
# use of reveal_type() on data truly of Any type.
|
|
142
|
+
if evaluated is Any:
|
|
143
|
+
raise TypeCheckerError(
|
|
144
|
+
f"Inferred type of '{var_name}' is Any, which "
|
|
145
|
+
"defeats the purpose of type checking",
|
|
146
|
+
pos.file,
|
|
147
|
+
pos.lineno,
|
|
148
|
+
)
|
|
149
|
+
memo = TypeCheckMemo(globalns, localns | walker.collected)
|
|
140
150
|
|
|
141
151
|
try:
|
|
142
152
|
check_type_internal(var, ref, memo)
|
|
@@ -15,8 +15,11 @@ from typing import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
import pytest
|
|
18
|
+
from _pytest.config import Notset
|
|
18
19
|
from schema import Schema
|
|
19
20
|
|
|
21
|
+
from .log import get_logger
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
class FilePos(NamedTuple):
|
|
22
25
|
file: str
|
|
@@ -47,6 +50,14 @@ class TypeCheckerError(Exception):
|
|
|
47
50
|
|
|
48
51
|
|
|
49
52
|
class NameCollectorBase(ast.NodeTransformer):
|
|
53
|
+
type_checker: ClassVar[str]
|
|
54
|
+
# typing_extensions guaranteed to be present,
|
|
55
|
+
# as a dependency of typeguard
|
|
56
|
+
collected: dict[str, Any] = {
|
|
57
|
+
m: importlib.import_module(m)
|
|
58
|
+
for m in ("builtins", "typing", "typing_extensions")
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
def __init__(
|
|
51
62
|
self,
|
|
52
63
|
globalns: dict[str, Any],
|
|
@@ -56,12 +67,7 @@ class NameCollectorBase(ast.NodeTransformer):
|
|
|
56
67
|
self._globalns = globalns
|
|
57
68
|
self._localns = localns
|
|
58
69
|
self.modified: bool = False
|
|
59
|
-
|
|
60
|
-
# as a dependency of typeguard
|
|
61
|
-
self.collected: dict[str, Any] = {
|
|
62
|
-
m: importlib.import_module(m)
|
|
63
|
-
for m in ("builtins", "typing", "typing_extensions")
|
|
64
|
-
}
|
|
70
|
+
self.collected = type(self).collected.copy()
|
|
65
71
|
|
|
66
72
|
def visit_Subscript(self, node: ast.Subscript) -> ast.expr:
|
|
67
73
|
node.value = cast("ast.expr", self.visit(node.value))
|
|
@@ -85,26 +91,69 @@ class NameCollectorBase(ast.NodeTransformer):
|
|
|
85
91
|
|
|
86
92
|
|
|
87
93
|
class TypeCheckerAdapter:
|
|
88
|
-
enabled: bool = True
|
|
89
|
-
config_file: ClassVar[pathlib.Path | None] = None
|
|
90
94
|
# Subclasses need to specify default values for below
|
|
91
95
|
id: ClassVar[str]
|
|
92
|
-
|
|
93
|
-
typechecker_result: ClassVar[dict[FilePos, VarType]]
|
|
96
|
+
_executable: ClassVar[str]
|
|
94
97
|
_type_mesg_re: ClassVar[re.Pattern[str]]
|
|
95
98
|
_schema: ClassVar[Schema]
|
|
99
|
+
_namecollector_class: ClassVar[type[NameCollectorBase]]
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
# {('file.py', 10): ('var_name', 'list[str]'), ...}
|
|
103
|
+
self.typechecker_result: dict[FilePos, VarType] = {}
|
|
104
|
+
self._logger = get_logger()
|
|
105
|
+
# logger level is already set by pytest_configure()
|
|
106
|
+
# this only affects how much debug message is shown
|
|
107
|
+
self.log_verbosity: int = 1
|
|
108
|
+
self.enabled: bool = True
|
|
109
|
+
self.config_file: pathlib.Path | None = None
|
|
96
110
|
|
|
97
111
|
@classmethod
|
|
112
|
+
def longopt_for_config(cls) -> str:
|
|
113
|
+
return f"--revealtype-{cls.id}-config"
|
|
114
|
+
|
|
98
115
|
@abc.abstractmethod
|
|
99
|
-
def run_typechecker_on(
|
|
100
|
-
|
|
101
|
-
@abc.abstractmethod
|
|
116
|
+
def run_typechecker_on(self, paths: Iterable[pathlib.Path]) -> None: ...
|
|
117
|
+
|
|
102
118
|
def create_collector(
|
|
103
|
-
|
|
104
|
-
) -> NameCollectorBase:
|
|
119
|
+
self, globalns: dict[str, Any], localns: dict[str, Any]
|
|
120
|
+
) -> NameCollectorBase:
|
|
121
|
+
return self._namecollector_class(globalns, localns)
|
|
122
|
+
|
|
123
|
+
def preprocess_config_file(self, path_str: str) -> bool:
|
|
124
|
+
"""Optional preprocessing of configuration file"""
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def set_config_file(self, config: pytest.Config) -> None:
|
|
128
|
+
path_str = config.getoption(self.longopt_for_config())
|
|
129
|
+
# pytest addoption() should have set default value
|
|
130
|
+
# to None even when option is not specified
|
|
131
|
+
assert not isinstance(path_str, Notset)
|
|
132
|
+
|
|
133
|
+
if path_str is None:
|
|
134
|
+
self._logger.info(f"({self.id}) Using default configuration")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if self.preprocess_config_file(path_str):
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
relpath = pathlib.Path(path_str)
|
|
141
|
+
if relpath.is_absolute():
|
|
142
|
+
raise ValueError(f"Path '{path_str}' must be relative to pytest rootdir")
|
|
143
|
+
result = (config.rootpath / relpath).resolve()
|
|
144
|
+
if not result.exists():
|
|
145
|
+
raise FileNotFoundError(f"Path '{result}' not found")
|
|
146
|
+
|
|
147
|
+
self._logger.info(f"({self.id}) Using config file at {result}")
|
|
148
|
+
self.config_file = result
|
|
149
|
+
|
|
105
150
|
@classmethod
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
def add_pytest_option(cls, group: pytest.OptionGroup) -> None:
|
|
152
|
+
group.addoption(
|
|
153
|
+
cls.longopt_for_config(),
|
|
154
|
+
type=str,
|
|
155
|
+
default=None,
|
|
156
|
+
metavar="RELATIVE_PATH",
|
|
157
|
+
help=f"{cls.id} configuration file, path is relative to pytest "
|
|
158
|
+
f"rootdir. If unspecified, use {cls.id} default behavior",
|
|
159
|
+
)
|
{pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-revealtype-injector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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,12 @@ 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
|
|
29
30
|
Description-Content-Type: text/markdown
|
|
30
31
|
|
|
31
32
|

|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pytest_revealtype_injector/__init__.py,sha256=SrfVl_wqFZ1_A18ITfSyDy5mV_HkJi_RDDt-Rk4Zu9c,211
|
|
2
|
+
pytest_revealtype_injector/hooks.py,sha256=VfUz-p0XvpQT8E4-2saifRQdKKL_jvu9kKDFij9-k3Y,3136
|
|
3
|
+
pytest_revealtype_injector/log.py,sha256=Ptd3yp1H1GlUum6BAwHc9cdyeGmaY8XYf0jp6qJmG4M,418
|
|
4
|
+
pytest_revealtype_injector/main.py,sha256=nRh2uk64nsn9Y3PdGYew-Bhpfl0HZxmIur39RWpMNCk,5434
|
|
5
|
+
pytest_revealtype_injector/models.py,sha256=AP6ihVHKZq-PC9vgbTQC7dOGFiRHAyyx_dAwYGPrYNw,4991
|
|
6
|
+
pytest_revealtype_injector/plugin.py,sha256=fkI6yF0dFVba0jEikIrsRp1NUQd2ohWLq4x2lSvFyH0,211
|
|
7
|
+
pytest_revealtype_injector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pytest_revealtype_injector/adapter/__init__.py,sha256=h2Xkwab_e7WTb5QWxd2beOQW4CYjGPkeVsM5G43UzHY,601
|
|
9
|
+
pytest_revealtype_injector/adapter/basedpyright_.py,sha256=8LX7GmJmg4OZ3LKO5WoQ7Ocub6Lxi3HTStIorMApzUA,466
|
|
10
|
+
pytest_revealtype_injector/adapter/mypy_.py,sha256=et-2AOWU2U6FtrnRIqMh0HkDXPYdWVW15b4PUR1HSFA,8767
|
|
11
|
+
pytest_revealtype_injector/adapter/pyright_.py,sha256=ExGrIFjGiHT0zEAs_Aj1c3tKAc2K29Ea5brEc8Nr2Iw,4678
|
|
12
|
+
pytest_revealtype_injector-0.3.0.dist-info/METADATA,sha256=Iq1LH7OI9VxkT6jqv7Ge8wK6RB5-t2eAgmJ20DIMU-s,5556
|
|
13
|
+
pytest_revealtype_injector-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
+
pytest_revealtype_injector-0.3.0.dist-info/entry_points.txt,sha256=UfOm7y3WQnOoGV1mgTMb42MI6iBRPIl88FJiAOnt6SY,74
|
|
15
|
+
pytest_revealtype_injector-0.3.0.dist-info/licenses/COPYING,sha256=LSYUX8PcSMvHCkhM5oi07eOrSLV89qdEJ-FVZmbcpNE,355
|
|
16
|
+
pytest_revealtype_injector-0.3.0.dist-info/licenses/COPYING.mit,sha256=IzYEFDIOECyuupg_B3O9FvgjnU9i4JtambpbleoYHdQ,1060
|
|
17
|
+
pytest_revealtype_injector-0.3.0.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
pytest_revealtype_injector/__init__.py,sha256=3zGbYqpjy1u4__CnFV56_VB1fSpZ1LPbOgsI35Vfpz8,211
|
|
2
|
-
pytest_revealtype_injector/hooks.py,sha256=cj1iXJJrVtqylXvvdLfjZ61HGBt5wsGBUe-iAgu9WiU,2434
|
|
3
|
-
pytest_revealtype_injector/log.py,sha256=U9IvsoZzhFQcdFmVtuSmt2OTYxxFuIP5o321dyE4hiA,417
|
|
4
|
-
pytest_revealtype_injector/main.py,sha256=ftTxEY13fNCY7Z3_oPvh38OtHQzqmxYAYnyzzgit29w,4786
|
|
5
|
-
pytest_revealtype_injector/models.py,sha256=XsxUQGdv1U4CFi1bB1VeEojPpiwCZp7uxRIWuTGhIaM,3214
|
|
6
|
-
pytest_revealtype_injector/plugin.py,sha256=fkI6yF0dFVba0jEikIrsRp1NUQd2ohWLq4x2lSvFyH0,211
|
|
7
|
-
pytest_revealtype_injector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
pytest_revealtype_injector/adapter/__init__.py,sha256=wQg3Ply0Xrfe9US_lzgqRgKFE89ZBvDFti6_77X_NKk,339
|
|
9
|
-
pytest_revealtype_injector/adapter/mypy_.py,sha256=q-stxOY2bffTAm2SoHiCAb7VA_f9jXc4UxlyimXV-RY,8840
|
|
10
|
-
pytest_revealtype_injector/adapter/pyright_.py,sha256=fn8lE0G3ZeRANH6xKpizDOEXyH8a9yHHYdLCgMSsiFg,4857
|
|
11
|
-
pytest_revealtype_injector-0.2.2.dist-info/METADATA,sha256=JXyzhgJp_UwveDqAvKDIWeFkJcdFv5lEEUrKS4I8A2o,5520
|
|
12
|
-
pytest_revealtype_injector-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
-
pytest_revealtype_injector-0.2.2.dist-info/entry_points.txt,sha256=UfOm7y3WQnOoGV1mgTMb42MI6iBRPIl88FJiAOnt6SY,74
|
|
14
|
-
pytest_revealtype_injector-0.2.2.dist-info/licenses/COPYING,sha256=LSYUX8PcSMvHCkhM5oi07eOrSLV89qdEJ-FVZmbcpNE,355
|
|
15
|
-
pytest_revealtype_injector-0.2.2.dist-info/licenses/COPYING.mit,sha256=IzYEFDIOECyuupg_B3O9FvgjnU9i4JtambpbleoYHdQ,1060
|
|
16
|
-
pytest_revealtype_injector-0.2.2.dist-info/RECORD,,
|
{pytest_revealtype_injector-0.2.2.dist-info → pytest_revealtype_injector-0.3.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|