python-constricter 0.2.2__py3-none-any.whl → 0.2.4__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.
- constricter/__init__.py +19 -6
- constricter/__main__.py +1 -1
- constricter/cli/__init__.py +2 -0
- constricter/{baseline.py → cli/baseline.py} +2 -6
- constricter/cli/command.py +598 -0
- constricter/{config.py → cli/config.py} +81 -11
- constricter/cli/explain.py +103 -0
- constricter/cli/guard.py +129 -0
- constricter/cli/hints.py +789 -0
- constricter/cli/options.py +601 -0
- constricter/cli/paths.py +51 -0
- constricter/{report.py → cli/report.py} +156 -16
- constricter/fix/__init__.py +2 -0
- constricter/fix/fills.py +226 -0
- constricter/fix/fixes.py +192 -0
- constricter/fix/guesses.py +164 -0
- constricter/fix/hinted.py +242 -0
- constricter/fix/imports.py +174 -0
- constricter/fix/inference.py +756 -0
- constricter/fix/known.py +177 -0
- constricter/fix/opened.py +74 -0
- constricter/fix/project.py +370 -0
- constricter/fix/returned.py +217 -0
- constricter/fix/returns.py +200 -0
- constricter/fix/stdlib.py +255 -0
- constricter/fix/targets.py +128 -0
- constricter/jsonc.py +22 -3
- constricter/noqa.py +1 -1
- constricter/notebook.py +10 -4
- constricter/offences.py +245 -0
- constricter/plugins/__init__.py +2 -0
- constricter/{flake8_plugin.py → plugins/flake8.py} +35 -2
- constricter/{pylint_plugin.py → plugins/pylint.py} +52 -7
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.py +592 -0
- constricter/rules/checker.py +745 -0
- constricter/rules/flow.py +481 -0
- constricter/rules/narrowing.py +51 -0
- constricter/rules/redundant.py +166 -0
- constricter/rules/scope.py +608 -0
- constricter/rules/syntax.py +239 -0
- constricter/rules/walked.py +57 -0
- python_constricter-0.2.4.dist-info/METADATA +378 -0
- python_constricter-0.2.4.dist-info/RECORD +48 -0
- python_constricter-0.2.4.dist-info/entry_points.txt +6 -0
- constricter/annotations.py +0 -290
- constricter/checker.py +0 -651
- constricter/cli.py +0 -791
- constricter/explain.py +0 -61
- constricter/fixes.py +0 -22
- constricter/project.py +0 -229
- python_constricter-0.2.2.dist-info/METADATA +0 -456
- python_constricter-0.2.2.dist-info/RECORD +0 -22
- python_constricter-0.2.2.dist-info/entry_points.txt +0 -6
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.4.dist-info}/WHEEL +0 -0
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.4.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
|
@@ -1,38 +1,51 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
"""Every local variable typed where it's first bound."""
|
|
3
3
|
|
|
4
|
-
from constricter.
|
|
4
|
+
from constricter.offences import (
|
|
5
5
|
COMMENT_TYPED_TARGET,
|
|
6
6
|
DEFAULT_CHECKS,
|
|
7
|
+
FIX_KINDS,
|
|
7
8
|
LEVELS,
|
|
9
|
+
LONG_TUPLE,
|
|
10
|
+
MAX_LENGTH,
|
|
11
|
+
MISMATCHED_TYPE,
|
|
12
|
+
NARROWABLE_TYPE,
|
|
8
13
|
NESTED_TYPE,
|
|
9
14
|
NESTING,
|
|
15
|
+
REDUNDANT_TYPE,
|
|
10
16
|
UNANNOTATED,
|
|
11
17
|
UNANNOTATED_MEMBER,
|
|
12
18
|
UNTYPED_TARGET,
|
|
19
|
+
UNUSED_UNION_MEMBER,
|
|
13
20
|
VAGUE_TYPE,
|
|
14
21
|
Checks,
|
|
15
|
-
|
|
22
|
+
FixPolicy,
|
|
16
23
|
Level,
|
|
17
24
|
Offence,
|
|
18
|
-
annotation_coverage,
|
|
19
|
-
check_source,
|
|
20
|
-
check_tree,
|
|
21
25
|
)
|
|
26
|
+
from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
|
|
22
27
|
|
|
23
|
-
__version__ = "0.2.
|
|
28
|
+
__version__ = "0.2.4"
|
|
24
29
|
__all__ = [
|
|
25
30
|
"COMMENT_TYPED_TARGET",
|
|
26
31
|
"DEFAULT_CHECKS",
|
|
32
|
+
"FIX_KINDS",
|
|
27
33
|
"LEVELS",
|
|
34
|
+
"LONG_TUPLE",
|
|
35
|
+
"MAX_LENGTH",
|
|
36
|
+
"MISMATCHED_TYPE",
|
|
37
|
+
"NARROWABLE_TYPE",
|
|
28
38
|
"NESTED_TYPE",
|
|
29
39
|
"NESTING",
|
|
40
|
+
"REDUNDANT_TYPE",
|
|
30
41
|
"UNANNOTATED",
|
|
31
42
|
"UNANNOTATED_MEMBER",
|
|
32
43
|
"UNTYPED_TARGET",
|
|
44
|
+
"UNUSED_UNION_MEMBER",
|
|
33
45
|
"VAGUE_TYPE",
|
|
34
46
|
"Checks",
|
|
35
47
|
"Coverage",
|
|
48
|
+
"FixPolicy",
|
|
36
49
|
"Level",
|
|
37
50
|
"Offence",
|
|
38
51
|
"__version__",
|
constricter/__main__.py
CHANGED
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
from typing import Final, TypeAlias, cast
|
|
14
14
|
|
|
15
15
|
from constricter import jsonc
|
|
16
|
-
from constricter.
|
|
16
|
+
from constricter.offences import Offence
|
|
17
17
|
|
|
18
18
|
VERSION: Final = 1
|
|
19
19
|
Entries: TypeAlias = dict[str, dict[str, int]] # file -> "CODE name" -> count
|
|
@@ -58,7 +58,7 @@ def read(baseline: Path) -> Entries:
|
|
|
58
58
|
path: str
|
|
59
59
|
counts: _Json
|
|
60
60
|
for path, counts in files.items():
|
|
61
|
-
if not isinstance(counts, dict) or not all(
|
|
61
|
+
if not isinstance(counts, dict) or not all(jsonc.is_int(n) for n in counts.values()):
|
|
62
62
|
break
|
|
63
63
|
entries[path] = {entry: n for entry, n in counts.items() if isinstance(n, int)}
|
|
64
64
|
else:
|
|
@@ -69,10 +69,6 @@ def read(baseline: Path) -> Entries:
|
|
|
69
69
|
raise ValueError(message)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def _is_count(value: _Json) -> bool:
|
|
73
|
-
return isinstance(value, int) and not isinstance(value, bool)
|
|
74
|
-
|
|
75
|
-
|
|
76
72
|
def write(baseline: Path, found: Mapping[str, Sequence[Offence]]) -> int:
|
|
77
73
|
"""Write every offence in `found` (keyed as `key` makes them) to `baseline`.
|
|
78
74
|
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The `constricter` command (see README)."""
|
|
3
|
+
|
|
4
|
+
import codecs
|
|
5
|
+
import contextlib
|
|
6
|
+
import difflib
|
|
7
|
+
import io
|
|
8
|
+
import itertools
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import tokenize
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from functools import partial
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final, NamedTuple, TextIO, TypeAlias, cast
|
|
18
|
+
|
|
19
|
+
from constricter import notebook
|
|
20
|
+
from constricter.cli import baseline, hints
|
|
21
|
+
from constricter.cli.options import Mode, Options, Output
|
|
22
|
+
from constricter.cli.paths import STDIN, python_files
|
|
23
|
+
from constricter.cli.report import Format, Result, fix_reasons, render, statistics
|
|
24
|
+
from constricter.fix import fixes, project
|
|
25
|
+
from constricter.fix.known import Hints, Outside
|
|
26
|
+
from constricter.noqa import lines, unsuppressed
|
|
27
|
+
from constricter.offences import (
|
|
28
|
+
DEFAULT_CHECKS,
|
|
29
|
+
Checks,
|
|
30
|
+
Edit,
|
|
31
|
+
Offence,
|
|
32
|
+
)
|
|
33
|
+
from constricter.rules.checker import Coverage, annotation_coverage, check_source
|
|
34
|
+
|
|
35
|
+
EXIT_CLEAN: Final = 0
|
|
36
|
+
EXIT_FOUND: Final = 1
|
|
37
|
+
EXIT_ERROR: Final = 2
|
|
38
|
+
_ALL: Final = 100 # percent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _encoding(path: Path, data: bytes) -> str:
|
|
42
|
+
"""Find the encoding of `path`, whose bytes are `data`: a notebook's is UTF-8 (it's JSON).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A module's, as Python finds it: a PEP 263 declaration (`# -*- coding: latin-1 -*-`), a BOM
|
|
46
|
+
(`utf-8-sig`), or UTF-8. Raises `SyntaxError` for an unknown or contradictory one.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
return (
|
|
50
|
+
"utf-8" if path.suffix == notebook.SUFFIX else tokenize.detect_encoding(io.BytesIO(data).readline)[0]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read(path: Path) -> str:
|
|
55
|
+
"""Read `path`, in its encoding; `-` is standard input.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Its text.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
if path == STDIN:
|
|
62
|
+
return sys.stdin.read()
|
|
63
|
+
data: bytes = path.read_bytes()
|
|
64
|
+
return data.decode(_encoding(path, data))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _source(raw: str, name: Path) -> tuple[str, list[notebook.Line]]:
|
|
68
|
+
"""Extract the Python in `raw`: a notebook's code cells, joined.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The source, and each line's cell if it's a notebook.
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
return notebook.parse(raw, str(name)) if name.suffix == notebook.SUFFIX else (raw, [])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_text(
|
|
78
|
+
raw: str,
|
|
79
|
+
name: Path,
|
|
80
|
+
checks: Checks = DEFAULT_CHECKS,
|
|
81
|
+
*,
|
|
82
|
+
outside: Outside | None = None,
|
|
83
|
+
) -> list[Offence]:
|
|
84
|
+
"""Return the offences in `raw`, the text of `name`, that no `# noqa` suppresses.
|
|
85
|
+
|
|
86
|
+
A notebook's code cells are checked as one module, and each offence placed in its cell.
|
|
87
|
+
Raises `ValueError` for a `.ipynb` that isn't a notebook.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The unsuppressed offences, a notebook's placed in their cells.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
source: str
|
|
94
|
+
where: list[notebook.Line]
|
|
95
|
+
source, where = _source(raw, name)
|
|
96
|
+
offences: list[Offence] = unsuppressed(
|
|
97
|
+
check_source(source, str(name), checks, outside=outside),
|
|
98
|
+
lines(source),
|
|
99
|
+
)
|
|
100
|
+
return [_placed(o, where) for o in offences] if where else offences
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _placed(offence: Offence, where: list[notebook.Line]) -> Offence:
|
|
104
|
+
"""Place an offence from a notebook's joined module in its cell, its declaration's line too.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The offence, its `line` (and a `Edit.DECLARE` fix's statement line) counted in its `cell`; a
|
|
108
|
+
fix that would add an import isn't offered.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
line: notebook.Line = where[offence.line - 1]
|
|
112
|
+
placed: Offence = replace(offence, line=line.line, cell=line.cell)
|
|
113
|
+
if offence.edit is not None and offence.edit.imports: # a notebook's cells have no import block
|
|
114
|
+
return replace(placed, edit=None)
|
|
115
|
+
if offence.edit is not None and offence.edit.edit is Edit.DECLARE:
|
|
116
|
+
statement: int = where[offence.edit.span[0] - 1].line
|
|
117
|
+
placed = replace(placed, edit=offence.edit._replace(span=(statement, offence.edit.span[1])))
|
|
118
|
+
return placed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# One changed part of a fixed file (the file, or a notebook's cell): its label, old and new lines.
|
|
122
|
+
_Change: TypeAlias = tuple[str, list[str], list[str]]
|
|
123
|
+
# One JSON value a coverage report holds, and one file's row of them.
|
|
124
|
+
_Scalar: TypeAlias = str | int | float
|
|
125
|
+
_Row: TypeAlias = dict[str, _Scalar]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Fixed(NamedTuple):
|
|
129
|
+
"""`raw`, fixed: its new text, and each changed part (the file, or a notebook's cell)."""
|
|
130
|
+
|
|
131
|
+
text: str
|
|
132
|
+
changes: list[_Change] # each part's label and old and new lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _fixed(raw: str, name: Path, offences: Sequence[Offence]) -> Fixed:
|
|
136
|
+
"""Add each fixable offence's annotation to `raw`, the text of `name`.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The new text, and each changed part.
|
|
140
|
+
|
|
141
|
+
"""
|
|
142
|
+
if name.suffix == notebook.SUFFIX:
|
|
143
|
+
text: str
|
|
144
|
+
cells: list[notebook.Cell]
|
|
145
|
+
text, cells = notebook.fix(raw, offences)
|
|
146
|
+
return Fixed(text, [(f"{name}:cell {c.number}", c.old, c.new) for c in cells])
|
|
147
|
+
old: list[str] = lines(raw)
|
|
148
|
+
new: list[str] = fixes.apply(old, offences)
|
|
149
|
+
return Fixed("".join(new), [(str(name), old, new)] if new != old else [])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _diff(raw: str, name: Path, offences: Sequence[Offence]) -> str:
|
|
153
|
+
return "".join(
|
|
154
|
+
"".join(difflib.unified_diff(old, new, label, label))
|
|
155
|
+
for label, old, new in _fixed(raw, name, offences).changes
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fix_file(path: Path, offences: Sequence[Offence]) -> int:
|
|
160
|
+
"""Add each fixable offence's annotation to `path` (a notebook's, in its cells), in its encoding.
|
|
161
|
+
|
|
162
|
+
Raises `UnicodeEncodeError`, leaving the file as it was, when an annotation can't be written in
|
|
163
|
+
the file's encoding (a PEP 263 declaration's).
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
How many offences were fixed.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
count: int
|
|
170
|
+
if not (count := sum(1 for o in offences if o.edit is not None)):
|
|
171
|
+
return 0
|
|
172
|
+
data: bytes = path.read_bytes()
|
|
173
|
+
encoding: str = _encoding(path, data)
|
|
174
|
+
_ = path.write_bytes(_fixed(data.decode(encoding), path, offences).text.encode(encoding))
|
|
175
|
+
return count
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class _CheckRun:
|
|
180
|
+
"""What checking one file found (and fixed, or would fix), in check/fix/diff mode."""
|
|
181
|
+
|
|
182
|
+
results: list[Result] = field(default_factory=list[Result])
|
|
183
|
+
baselined: int = 0
|
|
184
|
+
fixed: int = 0 # --fix: how many offences it fixed
|
|
185
|
+
text: str = "" # --diff: the diff; --fix on standard input: the fixed source
|
|
186
|
+
error: str = ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class _BaselineRun:
|
|
191
|
+
"""One file's offences, unfiltered, for --write-baseline."""
|
|
192
|
+
|
|
193
|
+
found: list[Offence] = field(default_factory=list[Offence])
|
|
194
|
+
error: str = ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class _CoverageRun:
|
|
199
|
+
"""One file's annotation coverage, for --coverage."""
|
|
200
|
+
|
|
201
|
+
coverage: Coverage | None = None
|
|
202
|
+
error: str = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_FileRun: TypeAlias = _CheckRun | _BaselineRun | _CoverageRun
|
|
206
|
+
HINT_ROUNDS: Final = 4 # with `--fix --infer-with`: how many times each file is fixed, at most
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _shown_lines(raw: str, name: Path) -> dict[tuple[int | None, int], str]:
|
|
210
|
+
"""Map each line of `raw` (a notebook's, in its cells) to its text, as offences are placed.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Each line's text, by its cell (`None` outside a notebook) and line number.
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
source: str
|
|
217
|
+
where: list[notebook.Line]
|
|
218
|
+
source, where = _source(raw, name)
|
|
219
|
+
return {
|
|
220
|
+
(where[index].cell if where else None, where[index].line if where else index + 1): text
|
|
221
|
+
for index, text in enumerate(source.splitlines())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _checked(path: Path, name: Path, checks: Checks, outside: Outside) -> tuple[str, list[Offence]]:
|
|
226
|
+
"""Read `path` and check it as `name`; raises what reading or parsing it does.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Its text, and its offences.
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
raw: str = _read(path)
|
|
233
|
+
return raw, check_text(raw, name, checks, outside=outside)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _read_checked(
|
|
237
|
+
path: Path,
|
|
238
|
+
name: Path,
|
|
239
|
+
checks: Checks,
|
|
240
|
+
outside: Outside,
|
|
241
|
+
) -> tuple[str, list[Offence], str]:
|
|
242
|
+
"""Read and check `path`, turning a read or parse error into a message instead of raising.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Its text and offences (empty on error), and the error message (empty on success).
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
raw: str
|
|
249
|
+
offences: list[Offence]
|
|
250
|
+
try:
|
|
251
|
+
raw, offences = _checked(path, name, checks, outside)
|
|
252
|
+
except (OSError, ValueError, SyntaxError) as error: # UnicodeDecodeError is a ValueError
|
|
253
|
+
return "", [], f"{name}: error: {error}"
|
|
254
|
+
return raw, offences, ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _results(raw: str, name: Path, offences: Sequence[Offence], options: Options) -> list[Result]:
|
|
258
|
+
"""Turn the offences in `raw`, the text of `name`, into the results to report.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Each one the options don't filter out, with its source line and its fix as a text edit.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
shown: dict[tuple[int | None, int], str] = _shown_lines(raw, name)
|
|
265
|
+
# A notebook's cells have no file lines for an edit to point at.
|
|
266
|
+
text: list[str] = [] if name.suffix == notebook.SUFFIX else lines(raw)
|
|
267
|
+
return [
|
|
268
|
+
r._replace(
|
|
269
|
+
source=shown.get((r.offence.cell, r.offence.line), ""),
|
|
270
|
+
replacements=fixes.replacements(text, r.offence) if text else (),
|
|
271
|
+
)
|
|
272
|
+
for r in options.filter.results(name, offences)
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _check_path(path: Path, outside: Outside, options: Options) -> _CheckRun:
|
|
277
|
+
"""Check (and fix, or diff) one file, given what's known of it from outside it.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
What it found; a file that can't be read or parsed is an error.
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
name: Path = options.input.name(path)
|
|
284
|
+
raw: str
|
|
285
|
+
offences: list[Offence]
|
|
286
|
+
error: str
|
|
287
|
+
raw, offences, error = _read_checked(path, name, options.checks, outside)
|
|
288
|
+
if error:
|
|
289
|
+
return _CheckRun(error=error)
|
|
290
|
+
baselined: int
|
|
291
|
+
offences, baselined = options.filter.unbaselined(name, offences)
|
|
292
|
+
results: list[Result] = _results(raw, name, offences, options)
|
|
293
|
+
unsafe: bool = options.unsafe_fixes
|
|
294
|
+
fixing: list[Offence] = [
|
|
295
|
+
r.offence for r in results if r.offence.edit is not None and (unsafe or not r.offence.unsafe)
|
|
296
|
+
]
|
|
297
|
+
if options.mode is Mode.DIFF:
|
|
298
|
+
return _CheckRun(text=_diff(raw, name, fixing))
|
|
299
|
+
if options.mode is not Mode.FIX:
|
|
300
|
+
return _CheckRun(results, baselined)
|
|
301
|
+
left: list[Result] = [r for r in results if r.offence not in fixing]
|
|
302
|
+
if path == STDIN: # the fixed source goes to stdout
|
|
303
|
+
return _CheckRun(left, baselined, len(fixing), _fixed(raw, name, fixing).text)
|
|
304
|
+
try:
|
|
305
|
+
return _CheckRun(left, baselined, fix_file(path, fixing))
|
|
306
|
+
except UnicodeEncodeError as failure: # the file is left as it was, its offences unfixed
|
|
307
|
+
# Its canonical name: PyPy reports `latin1` where CPython says `latin-1`.
|
|
308
|
+
encoding: str = codecs.lookup(failure.encoding).name
|
|
309
|
+
message: str = f"an annotation can't be written in its encoding, {encoding}; left as it was"
|
|
310
|
+
return _CheckRun(results, baselined, error=f"{name}: error: {message}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _baseline_path(path: Path, outside: Outside, options: Options) -> _BaselineRun:
|
|
314
|
+
"""Check one file, unfiltered, for --write-baseline.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Every offence found; a file that can't be read or parsed is an error.
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
name: Path = options.input.name(path)
|
|
321
|
+
offences: list[Offence]
|
|
322
|
+
error: str
|
|
323
|
+
_, offences, error = _read_checked(path, name, options.checks, outside)
|
|
324
|
+
return _BaselineRun(error=error) if error else _BaselineRun(found=offences)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _cover_path(path: Path, _outside: Outside, options: Options) -> _CoverageRun:
|
|
328
|
+
"""Count one file's typed first bindings.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The counts; a file that can't be read or parsed is an error.
|
|
332
|
+
|
|
333
|
+
"""
|
|
334
|
+
name: Path = options.input.name(path)
|
|
335
|
+
try:
|
|
336
|
+
return _CoverageRun(annotation_coverage(_source(_read(path), name)[0], options.checks))
|
|
337
|
+
except (OSError, ValueError, SyntaxError) as error:
|
|
338
|
+
return _CoverageRun(error=f"{name}: error: {error}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _check_all(options: Options) -> tuple[list[Path], list[_FileRun]]:
|
|
342
|
+
"""Check every file (`--jobs` at a time), in order.
|
|
343
|
+
|
|
344
|
+
With `--infer-with`, the type checker's server runs throughout: it's asked for every file's
|
|
345
|
+
hints first, and with `--fix`, each file a round changed is asked again and checked again, as its
|
|
346
|
+
new annotations change what the checker infers, until a round changes nothing (at most
|
|
347
|
+
`HINT_ROUNDS`). A baseline records offences, and coverage counts annotations: hints change
|
|
348
|
+
neither, only what `--fix` offers.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
The names, and what each file found.
|
|
352
|
+
|
|
353
|
+
"""
|
|
354
|
+
paths: list[Path] = list(python_files(options.input.paths, options.input.exclude))
|
|
355
|
+
check: Callable[[Path, Outside], _FileRun]
|
|
356
|
+
if options.mode is Mode.COVERAGE:
|
|
357
|
+
check = partial(_cover_path, options=options)
|
|
358
|
+
elif options.mode is Mode.WRITE_BASELINE:
|
|
359
|
+
check = partial(_baseline_path, options=options)
|
|
360
|
+
else:
|
|
361
|
+
check = partial(_check_path, options=options)
|
|
362
|
+
names: list[Path] = [options.input.name(path) for path in paths]
|
|
363
|
+
if not options.infer_with or options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
|
|
364
|
+
return names, _checked_all(paths, check, options)[0]
|
|
365
|
+
session: hints.Session
|
|
366
|
+
with hints.Session(options.infer_with, Path.cwd(), options.jobs, options.infer_memory) as session:
|
|
367
|
+
runs: list[_FileRun]
|
|
368
|
+
modules: project.Index
|
|
369
|
+
runs, modules = _checked_all(paths, check, options, session)
|
|
370
|
+
# The files the last round changed: only their hints can have changed.
|
|
371
|
+
again: list[int] = [
|
|
372
|
+
index
|
|
373
|
+
for index, run in enumerate(runs)
|
|
374
|
+
if options.mode is Mode.FIX and isinstance(run, _CheckRun) and run.fixed and paths[index] != STDIN
|
|
375
|
+
]
|
|
376
|
+
for _ in range(HINT_ROUNDS - 1):
|
|
377
|
+
if not again:
|
|
378
|
+
break
|
|
379
|
+
redone: list[_FileRun] = _checked_all(
|
|
380
|
+
[paths[i] for i in again],
|
|
381
|
+
check,
|
|
382
|
+
options,
|
|
383
|
+
session,
|
|
384
|
+
modules,
|
|
385
|
+
)[0]
|
|
386
|
+
index: int
|
|
387
|
+
run: _FileRun
|
|
388
|
+
for index, run in zip(again, redone, strict=True):
|
|
389
|
+
runs[index] = _merged(cast("_CheckRun", runs[index]), cast("_CheckRun", run))
|
|
390
|
+
again = [index for index, run in zip(again, redone, strict=True) if cast("_CheckRun", run).fixed]
|
|
391
|
+
return names, runs
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _checked_all(
|
|
395
|
+
paths: Sequence[Path],
|
|
396
|
+
check: Callable[[Path, Outside], _FileRun],
|
|
397
|
+
options: Options,
|
|
398
|
+
session: hints.Session | None = None,
|
|
399
|
+
modules: project.Index | None = None,
|
|
400
|
+
) -> tuple[list[_FileRun], project.Index]:
|
|
401
|
+
"""Check `paths` (`--jobs` at a time), with the `session`'s hints, and `modules` (else indexed).
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
What each file found, and the index of every file's module.
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
hinted: dict[Path, tuple[Hints, ...]] = {} if session is None else session.hints(_texts(paths))
|
|
408
|
+
coverage: bool = options.mode is Mode.COVERAGE # needs nothing from the other files
|
|
409
|
+
if options.jobs == 1 or len(paths) <= 1:
|
|
410
|
+
if modules is None:
|
|
411
|
+
modules = project.Index({}, []) if coverage else project.index(paths)
|
|
412
|
+
return list(
|
|
413
|
+
itertools.starmap(check, zip(paths, _outside(modules, paths, hinted), strict=True)),
|
|
414
|
+
), modules
|
|
415
|
+
pool: ProcessPoolExecutor
|
|
416
|
+
with ProcessPoolExecutor(max_workers=options.jobs) as pool:
|
|
417
|
+
if modules is None:
|
|
418
|
+
# The index, as the checks, read one file per task.
|
|
419
|
+
modules = (
|
|
420
|
+
project.Index({}, []) if coverage else project.index(paths, partial(pool.map, chunksize=16))
|
|
421
|
+
)
|
|
422
|
+
return list(pool.map(check, paths, _outside(modules, paths, hinted))), modules
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _merged(before: _CheckRun, after: _CheckRun) -> _CheckRun:
|
|
426
|
+
"""Join a file's two `--fix` rounds: what's left is the later round's, what's fixed is both's.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The joined run.
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
return replace(after, fixed=before.fixed + after.fixed)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _texts(paths: Sequence[Path]) -> dict[Path, str]:
|
|
436
|
+
"""Read each file to ask the type checker about (not a notebook, or standard input).
|
|
437
|
+
|
|
438
|
+
A file that can't be read is left out: checking it reports that.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Each file's text.
|
|
442
|
+
|
|
443
|
+
"""
|
|
444
|
+
texts: dict[Path, str] = {}
|
|
445
|
+
path: Path
|
|
446
|
+
for path in paths:
|
|
447
|
+
if path != STDIN and path.suffix != notebook.SUFFIX:
|
|
448
|
+
# UnicodeDecodeError is a ValueError; a bad encoding declaration, a SyntaxError.
|
|
449
|
+
with contextlib.suppress(OSError, ValueError, SyntaxError):
|
|
450
|
+
texts[path] = _read(path)
|
|
451
|
+
return texts
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _outside(
|
|
455
|
+
modules: project.Index,
|
|
456
|
+
paths: Sequence[Path],
|
|
457
|
+
hinted: Mapping[Path, tuple[Hints, ...]],
|
|
458
|
+
) -> list[Outside]:
|
|
459
|
+
"""Find what's known of each file from outside it: what it imports from the others, and its hints.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
Each file's, in order.
|
|
463
|
+
|
|
464
|
+
"""
|
|
465
|
+
found: list[Outside] = []
|
|
466
|
+
path: Path
|
|
467
|
+
for path in paths:
|
|
468
|
+
imported: project.Imported = project.imported(modules, path)
|
|
469
|
+
found.append(Outside(imported.calls, imported.classes, hinted.get(path, ())))
|
|
470
|
+
return found
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _report(options: Options, runs: Sequence[_FileRun], files: int) -> int:
|
|
474
|
+
"""Print the results (`runs` is check/fix/diff mode's: every other mode has its own printing).
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
The exit status.
|
|
478
|
+
|
|
479
|
+
"""
|
|
480
|
+
checked: Sequence[_CheckRun] = cast("Sequence[_CheckRun]", runs)
|
|
481
|
+
results: list[Result] = [result for run in checked for result in run.results]
|
|
482
|
+
output: Output = options.output
|
|
483
|
+
text: bool = output.fmt in {Format.TEXT, Format.FULL}
|
|
484
|
+
line: str
|
|
485
|
+
for line in statistics(results) if text and output.statistics else render(output.fmt, results):
|
|
486
|
+
_ = sys.stdout.write(f"{line}\n")
|
|
487
|
+
for line in fix_reasons(results) if text and output.show_fixes else ():
|
|
488
|
+
_ = sys.stdout.write(f"{line}\n")
|
|
489
|
+
errors: int = sum(r.offence.is_error(r.level) for r in results)
|
|
490
|
+
if text and not output.quiet:
|
|
491
|
+
parts: list[str] = [
|
|
492
|
+
f"Found {errors} error(s) and {len(results) - errors} warning(s) in {files} file(s)",
|
|
493
|
+
]
|
|
494
|
+
if options.mode is Mode.FIX:
|
|
495
|
+
parts.append(f"fixed {sum(run.fixed for run in checked)}")
|
|
496
|
+
guesses: int
|
|
497
|
+
if guesses := sum(r.offence.unsafe for r in results):
|
|
498
|
+
parts.append(f"{guesses} more with --unsafe-fixes")
|
|
499
|
+
if options.filter.baseline_file is not None:
|
|
500
|
+
parts.append(f"{sum(run.baselined for run in checked)} baselined")
|
|
501
|
+
_ = sys.stdout.write("; ".join(parts) + ".\n")
|
|
502
|
+
return EXIT_FOUND if errors else EXIT_CLEAN
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _coverage(options: Options, paths: Sequence[Path], runs: Sequence[_FileRun]) -> int:
|
|
506
|
+
"""Print each file's and the total annotation coverage (`runs` is --coverage mode's).
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
The exit status.
|
|
510
|
+
|
|
511
|
+
"""
|
|
512
|
+
covered: Sequence[_CoverageRun] = cast("Sequence[_CoverageRun]", runs)
|
|
513
|
+
counted: list[tuple[Path, Coverage]] = [
|
|
514
|
+
(path, run.coverage) for path, run in zip(paths, covered, strict=True) if run.coverage is not None
|
|
515
|
+
]
|
|
516
|
+
total: Coverage = Coverage(sum(c.typed for _, c in counted), sum(c.total for _, c in counted))
|
|
517
|
+
if options.output.fmt is Format.JSON:
|
|
518
|
+
report: dict[str, _Scalar | list[_Row]] = {
|
|
519
|
+
"typed": total.typed,
|
|
520
|
+
"total": total.total,
|
|
521
|
+
"percent": round(total.percent, 1),
|
|
522
|
+
"files": [
|
|
523
|
+
{"path": str(path), "typed": c.typed, "total": c.total, "percent": round(c.percent, 1)}
|
|
524
|
+
for path, c in counted
|
|
525
|
+
],
|
|
526
|
+
}
|
|
527
|
+
_ = sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
528
|
+
else:
|
|
529
|
+
path: Path
|
|
530
|
+
c: Coverage
|
|
531
|
+
for path, c in counted:
|
|
532
|
+
_ = sys.stdout.write(f"{path}: {c.typed}/{c.total} typed ({c.percent:.1f}%)\n")
|
|
533
|
+
_ = sys.stdout.write(
|
|
534
|
+
f"Total: {total.typed}/{total.total} typed ({total.percent:.1f}%) in {len(counted)} file(s).\n",
|
|
535
|
+
)
|
|
536
|
+
threshold: float | None = options.output.fail_under
|
|
537
|
+
return EXIT_FOUND if threshold is not None and total.percent < threshold else EXIT_CLEAN
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _run(options: Options) -> int:
|
|
541
|
+
"""Check, fix, diff, count or baseline, printing what that finds.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
The exit status.
|
|
545
|
+
|
|
546
|
+
"""
|
|
547
|
+
names: list[Path]
|
|
548
|
+
runs: list[_FileRun]
|
|
549
|
+
try:
|
|
550
|
+
names, runs = _check_all(options)
|
|
551
|
+
except hints.HintError as error:
|
|
552
|
+
_ = sys.stderr.write(f"constricter: error: {error}\n")
|
|
553
|
+
return EXIT_ERROR
|
|
554
|
+
_ = sys.stderr.write("".join(f"{run.error}\n" for run in runs if run.error))
|
|
555
|
+
failed: bool = any(run.error for run in runs)
|
|
556
|
+
status: int
|
|
557
|
+
if options.mode is Mode.WRITE_BASELINE and options.filter.baseline_file is not None:
|
|
558
|
+
file: Path = options.filter.baseline_file
|
|
559
|
+
baselined: Sequence[_BaselineRun] = cast("Sequence[_BaselineRun]", runs)
|
|
560
|
+
found: dict[str, list[Offence]] = {
|
|
561
|
+
baseline.key(n, file): run.found for n, run in zip(names, baselined, strict=True)
|
|
562
|
+
}
|
|
563
|
+
_ = sys.stdout.write(f"Wrote {baseline.write(file, found)} offence(s) to {file}.\n")
|
|
564
|
+
status = EXIT_CLEAN
|
|
565
|
+
elif options.mode is Mode.COVERAGE:
|
|
566
|
+
status = _coverage(options, names, runs)
|
|
567
|
+
elif options.mode is Mode.DIFF:
|
|
568
|
+
checked: Sequence[_CheckRun] = cast("Sequence[_CheckRun]", runs)
|
|
569
|
+
diffs: str = "".join(run.text for run in checked)
|
|
570
|
+
_ = sys.stdout.write(diffs)
|
|
571
|
+
status = EXIT_FOUND if diffs else EXIT_CLEAN
|
|
572
|
+
elif options.mode is Mode.FIX and options.input.paths == [STDIN] and runs and not failed:
|
|
573
|
+
fixed: _CheckRun = cast("_CheckRun", runs[0])
|
|
574
|
+
_ = sys.stdout.write(fixed.text) # standard input, fixed, is the whole output
|
|
575
|
+
status = EXIT_FOUND if any(r.offence.is_error(r.level) for r in fixed.results) else EXIT_CLEAN
|
|
576
|
+
else:
|
|
577
|
+
status = _report(options, runs, len(names))
|
|
578
|
+
return EXIT_ERROR if failed else status
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
582
|
+
"""Run the command.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
Its exit status.
|
|
586
|
+
|
|
587
|
+
"""
|
|
588
|
+
options: Options = Options.parse(argv)
|
|
589
|
+
file: Path | None = options.output.output_file
|
|
590
|
+
stream: TextIO
|
|
591
|
+
with (
|
|
592
|
+
file.open("w", encoding="utf-8", newline="\n")
|
|
593
|
+
if file
|
|
594
|
+
else contextlib.nullcontext(sys.stdout) as stream,
|
|
595
|
+
contextlib.redirect_stdout(stream),
|
|
596
|
+
):
|
|
597
|
+
status: int = _run(options)
|
|
598
|
+
return EXIT_CLEAN if options.output.exit_zero and status == EXIT_FOUND else status
|