python-constricter 0.2.2__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 +42 -0
- constricter/__main__.py +9 -0
- constricter/annotations.py +290 -0
- constricter/baseline.py +111 -0
- constricter/checker.py +651 -0
- constricter/cli.py +791 -0
- constricter/config.py +177 -0
- constricter/explain.py +61 -0
- constricter/fixes.py +22 -0
- constricter/flake8_plugin.py +90 -0
- constricter/jsonc.py +31 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +111 -0
- constricter/project.py +229 -0
- constricter/py.typed +0 -0
- constricter/pylint_plugin.py +127 -0
- constricter/report.py +252 -0
- python_constricter-0.2.2.dist-info/METADATA +456 -0
- python_constricter-0.2.2.dist-info/RECORD +22 -0
- python_constricter-0.2.2.dist-info/WHEEL +4 -0
- python_constricter-0.2.2.dist-info/entry_points.txt +6 -0
- python_constricter-0.2.2.dist-info/licenses/LICENSE.md +21 -0
constricter/cli.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The `constricter` command (see README)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import contextlib
|
|
6
|
+
import difflib
|
|
7
|
+
import itertools
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
12
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from fnmatch import fnmatch
|
|
16
|
+
from functools import partial
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Final, TextIO, cast
|
|
19
|
+
|
|
20
|
+
from constricter import __version__, baseline, fixes, notebook, project
|
|
21
|
+
from constricter.checker import (
|
|
22
|
+
DEFAULT_CHECKS,
|
|
23
|
+
LEVELS,
|
|
24
|
+
MESSAGES,
|
|
25
|
+
NESTING,
|
|
26
|
+
Checks,
|
|
27
|
+
Coverage,
|
|
28
|
+
Level,
|
|
29
|
+
Offence,
|
|
30
|
+
annotation_coverage,
|
|
31
|
+
check_source,
|
|
32
|
+
)
|
|
33
|
+
from constricter.config import DEFAULT_BASELINE, config_defaults, project_root, unknown_codes
|
|
34
|
+
from constricter.explain import explain
|
|
35
|
+
from constricter.noqa import lines, unsuppressed
|
|
36
|
+
from constricter.report import Format, Result, render, statistics
|
|
37
|
+
|
|
38
|
+
_SKIPPED_DIRS: frozenset[str] = frozenset(
|
|
39
|
+
{"__pycache__", "node_modules", "venv", "site-packages", "build", "dist"},
|
|
40
|
+
)
|
|
41
|
+
EXIT_CLEAN: Final = 0
|
|
42
|
+
EXIT_FOUND: Final = 1
|
|
43
|
+
EXIT_ERROR: Final = 2
|
|
44
|
+
_ALL: Final = 100 # percent
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _excluded(path: Path, patterns: Sequence[str]) -> bool:
|
|
48
|
+
text: str = path.as_posix()
|
|
49
|
+
return any(fnmatch(text, p) or fnmatch(path.name, p) for p in patterns)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def python_files(paths: Sequence[Path], exclude: Sequence[str] = ()) -> Iterator[Path]:
|
|
53
|
+
"""Find the files to check.
|
|
54
|
+
|
|
55
|
+
Yields:
|
|
56
|
+
Each file given, and each `*.py` under each directory given.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
path: Path
|
|
60
|
+
found: Path
|
|
61
|
+
for path in paths:
|
|
62
|
+
if not path.is_dir():
|
|
63
|
+
if not _excluded(path, exclude):
|
|
64
|
+
yield path
|
|
65
|
+
continue
|
|
66
|
+
for found in sorted([*path.rglob("*.py"), *path.rglob(f"*{notebook.SUFFIX}")]):
|
|
67
|
+
parts: tuple[str, ...] = found.relative_to(path).parts[:-1]
|
|
68
|
+
if any(p.startswith(".") or p in _SKIPPED_DIRS for p in parts):
|
|
69
|
+
continue
|
|
70
|
+
if not _excluded(found, exclude):
|
|
71
|
+
yield found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
STDIN: Final = Path("-")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read(path: Path) -> str:
|
|
78
|
+
"""Read `path`; `-` is standard input.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Its text.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
return sys.stdin.read() if path == STDIN else path.read_bytes().decode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _source(raw: str, name: Path) -> tuple[str, list[notebook.Line]]:
|
|
88
|
+
"""Extract the Python in `raw`: a notebook's code cells, joined.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The source, and each line's cell if it's a notebook.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
return notebook.parse(raw, str(name)) if name.suffix == notebook.SUFFIX else (raw, [])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_text(
|
|
98
|
+
raw: str,
|
|
99
|
+
name: Path,
|
|
100
|
+
checks: Checks = DEFAULT_CHECKS,
|
|
101
|
+
*,
|
|
102
|
+
calls: Mapping[str, str] | None = None,
|
|
103
|
+
) -> list[Offence]:
|
|
104
|
+
"""Return the offences in `raw`, the text of `name`, that no `# noqa` suppresses.
|
|
105
|
+
|
|
106
|
+
A notebook's code cells are checked as one module, and each offence placed in its cell.
|
|
107
|
+
Raises `ValueError` for a `.ipynb` that isn't a notebook.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The unsuppressed offences, a notebook's placed in their cells.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
source: str
|
|
114
|
+
where: list[notebook.Line]
|
|
115
|
+
source, where = _source(raw, name)
|
|
116
|
+
offences: list[Offence] = unsuppressed(
|
|
117
|
+
check_source(source, str(name), checks, calls=calls),
|
|
118
|
+
lines(source),
|
|
119
|
+
)
|
|
120
|
+
return (
|
|
121
|
+
[replace(o, line=where[o.line - 1].line, cell=where[o.line - 1].cell) for o in offences]
|
|
122
|
+
if where
|
|
123
|
+
else offences
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fixed(
|
|
128
|
+
raw: str,
|
|
129
|
+
name: Path,
|
|
130
|
+
offences: Sequence[Offence],
|
|
131
|
+
) -> tuple[str, list[tuple[str, list[str], list[str]]]]:
|
|
132
|
+
"""Add each fixable offence's annotation to `raw`, the text of `name`.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The new text, and each changed part (the file, or a notebook's cell): its label and old and
|
|
136
|
+
new lines.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
if name.suffix == notebook.SUFFIX:
|
|
140
|
+
text: str
|
|
141
|
+
cells: list[notebook.Cell]
|
|
142
|
+
text, cells = notebook.fix(raw, offences)
|
|
143
|
+
return text, [(f"{name}:cell {c.number}", c.old, c.new) for c in cells]
|
|
144
|
+
old: list[str] = lines(raw)
|
|
145
|
+
new: list[str] = fixes.apply(old, offences)
|
|
146
|
+
return "".join(new), [(str(name), old, new)] if new != old else []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _diff(raw: str, name: Path, offences: Sequence[Offence]) -> str:
|
|
150
|
+
return "".join(
|
|
151
|
+
"".join(difflib.unified_diff(old, new, label, label))
|
|
152
|
+
for label, old, new in _fixed(raw, name, offences)[1]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fix_file(path: Path, offences: Sequence[Offence]) -> int:
|
|
157
|
+
"""Add each fixable offence's annotation to `path` (a notebook's, in its cells).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
How many offences were fixed.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
count: int
|
|
164
|
+
if not (count := sum(1 for o in offences if o.fix)):
|
|
165
|
+
return 0
|
|
166
|
+
_ = path.write_bytes(_fixed(_read(path), path, offences)[0].encode())
|
|
167
|
+
return count
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _at_least(minimum: int) -> Callable[[str], int]:
|
|
171
|
+
"""Make a reader of whole numbers, for `--nesting` and `--jobs`.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A reader that rejects numbers below `minimum`.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def read(text: str) -> int:
|
|
179
|
+
"""Read `text`.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The whole number `text` is.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
argparse.ArgumentTypeError: It isn't one.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
if not text.isdigit() or int(text) < minimum:
|
|
189
|
+
message: str = f"expected a whole number of at least {minimum}, not {text!r}"
|
|
190
|
+
raise argparse.ArgumentTypeError(message)
|
|
191
|
+
return int(text)
|
|
192
|
+
|
|
193
|
+
return read
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _percent(text: str) -> float:
|
|
197
|
+
"""Read a percentage, 0 to 100.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The percentage.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
argparse.ArgumentTypeError: `text` isn't one.
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
value: float
|
|
207
|
+
try:
|
|
208
|
+
value = float(text)
|
|
209
|
+
except ValueError:
|
|
210
|
+
value = -1.0
|
|
211
|
+
if not 0 <= value <= _ALL:
|
|
212
|
+
message: str = f"expected a percentage from 0 to 100, not {text!r}"
|
|
213
|
+
raise argparse.ArgumentTypeError(message)
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _codes(text: str) -> list[str]:
|
|
218
|
+
"""Read a comma-separated list of codes or code prefixes (`LVA001,LVA00`).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The codes, upper-cased.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
argparse.ArgumentTypeError: One matches no code.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
codes: list[str] = [code.strip().upper() for code in text.split(",") if code.strip()]
|
|
228
|
+
unknown: list[str]
|
|
229
|
+
if unknown := unknown_codes(codes):
|
|
230
|
+
message: str = f"no code starts with {', '.join(unknown)}"
|
|
231
|
+
raise argparse.ArgumentTypeError(message)
|
|
232
|
+
return codes
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _parser() -> argparse.ArgumentParser:
|
|
236
|
+
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
|
237
|
+
prog="constricter",
|
|
238
|
+
description="Report local variables that aren't typed where they're first bound.",
|
|
239
|
+
)
|
|
240
|
+
_ = parser.add_argument(
|
|
241
|
+
"paths",
|
|
242
|
+
nargs="*",
|
|
243
|
+
type=Path,
|
|
244
|
+
help="files and directories (default: .); `-` reads standard input",
|
|
245
|
+
)
|
|
246
|
+
_ = parser.add_argument(
|
|
247
|
+
"--exclude",
|
|
248
|
+
action="append",
|
|
249
|
+
default=[],
|
|
250
|
+
metavar="GLOB",
|
|
251
|
+
help="skip paths matching this glob (repeatable), e.g. 'tests/fixtures/*'",
|
|
252
|
+
)
|
|
253
|
+
_ = parser.add_argument(
|
|
254
|
+
"--level",
|
|
255
|
+
choices=LEVELS,
|
|
256
|
+
default="strict",
|
|
257
|
+
help="which codes are errors rather than warnings (default: strict)",
|
|
258
|
+
)
|
|
259
|
+
_ = parser.add_argument(
|
|
260
|
+
"--format",
|
|
261
|
+
type=Format,
|
|
262
|
+
choices=list(Format),
|
|
263
|
+
default=Format.TEXT,
|
|
264
|
+
help="output format (default: text)",
|
|
265
|
+
)
|
|
266
|
+
_ = parser.add_argument(
|
|
267
|
+
"--type-comments",
|
|
268
|
+
action="store_true",
|
|
269
|
+
help="count `x = 1 # type: int` as annotated",
|
|
270
|
+
)
|
|
271
|
+
_ = parser.add_argument(
|
|
272
|
+
"--all-scopes",
|
|
273
|
+
action="store_true",
|
|
274
|
+
help="also check module and class bodies (LVA004)",
|
|
275
|
+
)
|
|
276
|
+
_ = parser.add_argument(
|
|
277
|
+
"--nesting",
|
|
278
|
+
type=_at_least(1),
|
|
279
|
+
default=NESTING,
|
|
280
|
+
metavar="N",
|
|
281
|
+
help=f"report an annotation nested N deep (LVA006; default: {NESTING})",
|
|
282
|
+
)
|
|
283
|
+
_ = parser.add_argument(
|
|
284
|
+
"--select",
|
|
285
|
+
type=_codes,
|
|
286
|
+
default=[],
|
|
287
|
+
metavar="CODES",
|
|
288
|
+
help="report only these codes or prefixes (LVA001,LVA00)",
|
|
289
|
+
)
|
|
290
|
+
_ = parser.add_argument(
|
|
291
|
+
"--ignore",
|
|
292
|
+
type=_codes,
|
|
293
|
+
default=[],
|
|
294
|
+
metavar="CODES",
|
|
295
|
+
help="don't report these codes or prefixes",
|
|
296
|
+
)
|
|
297
|
+
_ = parser.add_argument(
|
|
298
|
+
"--fix",
|
|
299
|
+
action="store_true",
|
|
300
|
+
help="add the annotations a value makes unambiguous, in place",
|
|
301
|
+
)
|
|
302
|
+
_ = parser.add_argument(
|
|
303
|
+
"--unsafe-fixes",
|
|
304
|
+
action="store_true",
|
|
305
|
+
help="with --fix or --diff: also apply guesses (a call to a class that may be generic)",
|
|
306
|
+
)
|
|
307
|
+
_ = parser.add_argument(
|
|
308
|
+
"--diff",
|
|
309
|
+
action="store_true",
|
|
310
|
+
help="print what --fix would change, and change nothing",
|
|
311
|
+
)
|
|
312
|
+
_ = parser.add_argument(
|
|
313
|
+
"--statistics",
|
|
314
|
+
action="store_true",
|
|
315
|
+
help="print counts per code instead of each offence (text)",
|
|
316
|
+
)
|
|
317
|
+
_ = parser.add_argument(
|
|
318
|
+
"--coverage",
|
|
319
|
+
action="store_true",
|
|
320
|
+
help="print the share of first bindings that are typed, per file",
|
|
321
|
+
)
|
|
322
|
+
_ = parser.add_argument(
|
|
323
|
+
"--fail-under",
|
|
324
|
+
type=_percent,
|
|
325
|
+
metavar="PCT",
|
|
326
|
+
help="with --coverage (which it implies): exit 1 if the typed share is below PCT",
|
|
327
|
+
)
|
|
328
|
+
_ = parser.add_argument(
|
|
329
|
+
"--baseline",
|
|
330
|
+
type=Path,
|
|
331
|
+
metavar="FILE",
|
|
332
|
+
help="don't report the offences recorded in this baseline file",
|
|
333
|
+
)
|
|
334
|
+
_ = parser.add_argument(
|
|
335
|
+
"--write-baseline",
|
|
336
|
+
action="store_true",
|
|
337
|
+
help=f"record every offence found in the baseline file (default: {DEFAULT_BASELINE}), and exit",
|
|
338
|
+
)
|
|
339
|
+
_ = parser.add_argument(
|
|
340
|
+
"--explain",
|
|
341
|
+
choices=list(MESSAGES),
|
|
342
|
+
metavar="CODE",
|
|
343
|
+
help="explain a code and exit",
|
|
344
|
+
)
|
|
345
|
+
_ = parser.add_argument(
|
|
346
|
+
"--jobs",
|
|
347
|
+
"-j",
|
|
348
|
+
type=_at_least(0),
|
|
349
|
+
default=1,
|
|
350
|
+
metavar="N",
|
|
351
|
+
help="check N files at a time (0: one per CPU; default: 1)",
|
|
352
|
+
)
|
|
353
|
+
_ = parser.add_argument(
|
|
354
|
+
"--stdin-filename",
|
|
355
|
+
type=Path,
|
|
356
|
+
default=STDIN,
|
|
357
|
+
metavar="PATH",
|
|
358
|
+
help="with `-` as the path: the name to report standard input under (a `.ipynb` is a notebook)",
|
|
359
|
+
)
|
|
360
|
+
_ = parser.add_argument("--exit-zero", action="store_true", help="exit 0 even when offences are errors")
|
|
361
|
+
_ = parser.add_argument(
|
|
362
|
+
"--output-file",
|
|
363
|
+
type=Path,
|
|
364
|
+
metavar="FILE",
|
|
365
|
+
help="write the report to FILE, not stdout",
|
|
366
|
+
)
|
|
367
|
+
_ = parser.add_argument("--quiet", "-q", action="store_true", help="don't print the text summary line")
|
|
368
|
+
_ = parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
369
|
+
return parser
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class _Mode(Enum):
|
|
373
|
+
"""What to do with what's found."""
|
|
374
|
+
|
|
375
|
+
CHECK = "check"
|
|
376
|
+
FIX = "fix"
|
|
377
|
+
DIFF = "diff"
|
|
378
|
+
WRITE_BASELINE = "write-baseline"
|
|
379
|
+
COVERAGE = "coverage"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True)
|
|
383
|
+
class _Input:
|
|
384
|
+
"""What to check: paths (`-` is standard input, reported as `stdin_name`) and globs to skip."""
|
|
385
|
+
|
|
386
|
+
paths: list[Path]
|
|
387
|
+
exclude: list[str]
|
|
388
|
+
stdin_name: Path = STDIN
|
|
389
|
+
|
|
390
|
+
def name(self, path: Path) -> Path:
|
|
391
|
+
"""Name `path` for reports.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
The name it's reported, levelled and baselined under.
|
|
395
|
+
|
|
396
|
+
"""
|
|
397
|
+
return self.stdin_name if path == STDIN else path
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass(frozen=True)
|
|
401
|
+
class _Filter:
|
|
402
|
+
"""Which offences are reported, and at which level."""
|
|
403
|
+
|
|
404
|
+
level: Level
|
|
405
|
+
per_path: dict[str, str]
|
|
406
|
+
select: list[str]
|
|
407
|
+
ignore: list[str]
|
|
408
|
+
per_file_ignores: dict[str, list[str]] = field(default_factory=dict[str, list[str]])
|
|
409
|
+
baseline_file: Path | None = None
|
|
410
|
+
entries: baseline.Entries = field(default_factory=dict[str, dict[str, int]])
|
|
411
|
+
|
|
412
|
+
def unbaselined(self, path: Path, offences: list[Offence]) -> tuple[list[Offence], int]:
|
|
413
|
+
"""Match `path`'s offences against the baseline.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The offences it doesn't cover, and how many it does.
|
|
417
|
+
|
|
418
|
+
"""
|
|
419
|
+
if self.baseline_file is None:
|
|
420
|
+
return offences, 0
|
|
421
|
+
return baseline.remaining(offences, self.entries.get(baseline.key(path, self.baseline_file), {}))
|
|
422
|
+
|
|
423
|
+
def level_for(self, path: Path) -> Level:
|
|
424
|
+
"""Decide `path`'s level.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
The level of the first `per-path-levels` glob it matches, else `--level`'s.
|
|
428
|
+
|
|
429
|
+
"""
|
|
430
|
+
glob: str
|
|
431
|
+
level: str
|
|
432
|
+
for glob, level in self.per_path.items():
|
|
433
|
+
if _excluded(path, [glob]):
|
|
434
|
+
return LEVELS[level]
|
|
435
|
+
return self.level
|
|
436
|
+
|
|
437
|
+
def results(self, path: Path, offences: Sequence[Offence]) -> list[Result]:
|
|
438
|
+
"""Filter `path`'s offences.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Those reported at its level.
|
|
442
|
+
|
|
443
|
+
"""
|
|
444
|
+
level: Level = self.level_for(path)
|
|
445
|
+
ignored: tuple[str, ...] = (
|
|
446
|
+
*self.ignore,
|
|
447
|
+
*(
|
|
448
|
+
code
|
|
449
|
+
for glob, codes in self.per_file_ignores.items()
|
|
450
|
+
if _excluded(path, [glob])
|
|
451
|
+
for code in codes
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
return [
|
|
455
|
+
Result(path, o, level)
|
|
456
|
+
for o in offences
|
|
457
|
+
if o.is_reported(level)
|
|
458
|
+
and (not self.select or o.code.startswith(tuple(self.select)))
|
|
459
|
+
and not o.code.startswith(ignored)
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass(frozen=True)
|
|
464
|
+
class _Output:
|
|
465
|
+
"""How the report looks, where it goes, and how the command exits."""
|
|
466
|
+
|
|
467
|
+
fmt: Format
|
|
468
|
+
statistics: bool
|
|
469
|
+
quiet: bool
|
|
470
|
+
fail_under: float | None = None # --coverage's threshold
|
|
471
|
+
exit_zero: bool = False
|
|
472
|
+
output_file: Path | None = None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@dataclass(frozen=True)
|
|
476
|
+
class _Options:
|
|
477
|
+
"""The command's parsed options."""
|
|
478
|
+
|
|
479
|
+
input: _Input
|
|
480
|
+
checks: Checks
|
|
481
|
+
unsafe_fixes: bool
|
|
482
|
+
filter: _Filter
|
|
483
|
+
output: _Output
|
|
484
|
+
mode: _Mode
|
|
485
|
+
jobs: int
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def parse(cls, argv: Sequence[str] | None) -> "_Options":
|
|
489
|
+
"""Parse `argv` over the defaults `pyproject.toml` sets; `--explain` prints and exits.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
The options.
|
|
493
|
+
|
|
494
|
+
"""
|
|
495
|
+
parser: argparse.ArgumentParser = _parser()
|
|
496
|
+
try:
|
|
497
|
+
parser.set_defaults(**config_defaults(Path.cwd()))
|
|
498
|
+
except ValueError as error:
|
|
499
|
+
parser.error(str(error))
|
|
500
|
+
args: argparse.Namespace = parser.parse_args(argv)
|
|
501
|
+
code: str | None
|
|
502
|
+
if (code := cast("str | None", args.explain)) is not None:
|
|
503
|
+
_ = sys.stdout.write(explain(code))
|
|
504
|
+
parser.exit()
|
|
505
|
+
paths: list[Path] = cast("list[Path]", args.paths) or [Path()]
|
|
506
|
+
if STDIN in paths and len(paths) > 1:
|
|
507
|
+
parser.error("`-` (standard input) must be the only path")
|
|
508
|
+
mode: _Mode = _mode(parser, args)
|
|
509
|
+
return cls(
|
|
510
|
+
input=_Input(paths, cast("list[str]", args.exclude), cast("Path", args.stdin_filename)),
|
|
511
|
+
checks=Checks(
|
|
512
|
+
type_comments=cast("bool", args.type_comments),
|
|
513
|
+
all_scopes=cast("bool", args.all_scopes),
|
|
514
|
+
nesting=cast("int", args.nesting),
|
|
515
|
+
),
|
|
516
|
+
unsafe_fixes=cast("bool", args.unsafe_fixes),
|
|
517
|
+
filter=_filter(parser, args, mode),
|
|
518
|
+
output=_Output(
|
|
519
|
+
fmt=cast("Format", args.format),
|
|
520
|
+
statistics=cast("bool", args.statistics),
|
|
521
|
+
quiet=cast("bool", args.quiet),
|
|
522
|
+
fail_under=cast("float | None", args.fail_under),
|
|
523
|
+
exit_zero=cast("bool", args.exit_zero),
|
|
524
|
+
output_file=cast("Path | None", args.output_file),
|
|
525
|
+
),
|
|
526
|
+
mode=mode,
|
|
527
|
+
# Standard input can only be read once, in this process.
|
|
528
|
+
jobs=1 if paths == [STDIN] else cast("int", args.jobs) or os.cpu_count() or 1,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _filter(parser: argparse.ArgumentParser, args: argparse.Namespace, mode: _Mode) -> _Filter:
|
|
533
|
+
"""Build the filter the options set, reading the baseline (the default one, if it exists).
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
The filter.
|
|
537
|
+
|
|
538
|
+
"""
|
|
539
|
+
default: Path = project_root(Path.cwd()) / DEFAULT_BASELINE
|
|
540
|
+
baseline_path: Path | None = cast("Path | None", args.baseline) or (
|
|
541
|
+
default if mode is _Mode.WRITE_BASELINE or default.is_file() else None
|
|
542
|
+
)
|
|
543
|
+
entries: baseline.Entries = {}
|
|
544
|
+
if baseline_path is not None and mode is not _Mode.WRITE_BASELINE:
|
|
545
|
+
try:
|
|
546
|
+
entries = baseline.read(baseline_path)
|
|
547
|
+
except ValueError as error:
|
|
548
|
+
parser.error(str(error))
|
|
549
|
+
return _Filter(
|
|
550
|
+
level=LEVELS[cast("str", args.level)],
|
|
551
|
+
per_path=cast("dict[str, str]", getattr(args, "per_path_levels", {})),
|
|
552
|
+
select=[c.upper() for c in cast("list[str]", args.select)],
|
|
553
|
+
ignore=[c.upper() for c in cast("list[str]", args.ignore)],
|
|
554
|
+
per_file_ignores=cast("dict[str, list[str]]", getattr(args, "per_file_ignores", {})),
|
|
555
|
+
baseline_file=baseline_path,
|
|
556
|
+
entries=entries,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _mode(parser: argparse.ArgumentParser, args: argparse.Namespace) -> _Mode:
|
|
561
|
+
"""Decide the mode; more than one of `--fix`, `--diff` and `--write-baseline` is an error.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
What the options ask for.
|
|
565
|
+
|
|
566
|
+
"""
|
|
567
|
+
modes: list[_Mode] = [
|
|
568
|
+
mode
|
|
569
|
+
for mode, chosen in (
|
|
570
|
+
(_Mode.FIX, cast("bool", args.fix)),
|
|
571
|
+
(_Mode.DIFF, cast("bool", args.diff)),
|
|
572
|
+
(_Mode.WRITE_BASELINE, cast("bool", args.write_baseline)),
|
|
573
|
+
(
|
|
574
|
+
_Mode.COVERAGE,
|
|
575
|
+
cast("bool", args.coverage) or cast("float | None", args.fail_under) is not None,
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
if chosen
|
|
579
|
+
]
|
|
580
|
+
if len(modes) > 1:
|
|
581
|
+
parser.error("--fix, --diff, --write-baseline and --coverage can't be combined")
|
|
582
|
+
return modes[0] if modes else _Mode.CHECK
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class _FileRun:
|
|
587
|
+
"""What checking one file found (and fixed, or would fix)."""
|
|
588
|
+
|
|
589
|
+
results: list[Result] = field(default_factory=list[Result])
|
|
590
|
+
fixed: int = 0
|
|
591
|
+
text: str = "" # --diff's diff, or standard input's fixed source
|
|
592
|
+
error: str = ""
|
|
593
|
+
baselined: int = 0
|
|
594
|
+
found: list[Offence] = field(default_factory=list[Offence]) # every offence, for --write-baseline
|
|
595
|
+
coverage: Coverage | None = None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _checked(path: Path, name: Path, checks: Checks, calls: Mapping[str, str]) -> tuple[str, list[Offence]]:
|
|
599
|
+
"""Read `path` and check it as `name`; raises what reading or parsing it does.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Its text, and its offences.
|
|
603
|
+
|
|
604
|
+
"""
|
|
605
|
+
raw: str = _read(path)
|
|
606
|
+
return raw, check_text(raw, name, checks, calls=calls)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _check_path(path: Path, calls: Mapping[str, str], options: _Options) -> _FileRun:
|
|
610
|
+
"""Check (and fix, or diff) one file, given the imported functions' return types.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
What it found; a file that can't be read or parsed is an error.
|
|
614
|
+
|
|
615
|
+
"""
|
|
616
|
+
name: Path = options.input.name(path)
|
|
617
|
+
raw: str
|
|
618
|
+
offences: list[Offence]
|
|
619
|
+
try:
|
|
620
|
+
raw, offences = _checked(path, name, options.checks, calls)
|
|
621
|
+
except (OSError, ValueError, SyntaxError) as error: # UnicodeDecodeError is a ValueError
|
|
622
|
+
return _FileRun(error=f"{name}: error: {error}")
|
|
623
|
+
if options.mode is _Mode.WRITE_BASELINE:
|
|
624
|
+
return _FileRun(found=offences)
|
|
625
|
+
baselined: int
|
|
626
|
+
offences, baselined = options.filter.unbaselined(name, offences)
|
|
627
|
+
results: list[Result] = options.filter.results(name, offences)
|
|
628
|
+
unsafe: bool = options.unsafe_fixes
|
|
629
|
+
fixing: list[Offence] = [r.offence for r in results if r.offence.fix and (unsafe or not r.offence.unsafe)]
|
|
630
|
+
if options.mode is _Mode.DIFF:
|
|
631
|
+
return _FileRun(text=_diff(raw, name, fixing))
|
|
632
|
+
if options.mode is not _Mode.FIX:
|
|
633
|
+
return _FileRun(results, baselined=baselined)
|
|
634
|
+
left: list[Result] = [r for r in results if r.offence not in fixing]
|
|
635
|
+
if path == STDIN: # the fixed source goes to stdout
|
|
636
|
+
return _FileRun(left, len(fixing), _fixed(raw, name, fixing)[0], baselined=baselined)
|
|
637
|
+
return _FileRun(left, fix_file(path, fixing), baselined=baselined)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _cover_path(path: Path, _calls: Mapping[str, str], options: _Options) -> _FileRun:
|
|
641
|
+
"""Count one file's typed first bindings.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
The counts; a file that can't be read or parsed is an error.
|
|
645
|
+
|
|
646
|
+
"""
|
|
647
|
+
name: Path = options.input.name(path)
|
|
648
|
+
try:
|
|
649
|
+
return _FileRun(coverage=annotation_coverage(_source(_read(path), name)[0], options.checks))
|
|
650
|
+
except (OSError, ValueError, SyntaxError) as error:
|
|
651
|
+
return _FileRun(error=f"{name}: error: {error}")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _check_all(options: _Options) -> tuple[list[Path], list[_FileRun]]:
|
|
655
|
+
"""Check every file (`--jobs` at a time), in order.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
The names, and what each file found.
|
|
659
|
+
|
|
660
|
+
"""
|
|
661
|
+
paths: list[Path] = list(python_files(options.input.paths, options.input.exclude))
|
|
662
|
+
check: Callable[[Path, Mapping[str, str]], _FileRun] = partial(
|
|
663
|
+
_cover_path if options.mode is _Mode.COVERAGE else _check_path,
|
|
664
|
+
options=options,
|
|
665
|
+
)
|
|
666
|
+
names: list[Path] = [options.input.name(path) for path in paths]
|
|
667
|
+
# The functions each file imports from the others, for --fix (and its hints).
|
|
668
|
+
modules: dict[str, project.Module] = {} if options.mode is _Mode.COVERAGE else project.index(paths)
|
|
669
|
+
calls: list[dict[str, str]] = [project.calls(modules, path) for path in paths]
|
|
670
|
+
if options.jobs == 1 or len(paths) <= 1:
|
|
671
|
+
return names, list(itertools.starmap(check, zip(paths, calls, strict=True)))
|
|
672
|
+
pool: ProcessPoolExecutor
|
|
673
|
+
with ProcessPoolExecutor(max_workers=options.jobs) as pool:
|
|
674
|
+
return names, list(pool.map(check, paths, calls))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _report(options: _Options, runs: Sequence[_FileRun], files: int) -> int:
|
|
678
|
+
"""Print the results.
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
The exit status.
|
|
682
|
+
|
|
683
|
+
"""
|
|
684
|
+
results: list[Result] = [result for run in runs for result in run.results]
|
|
685
|
+
output: _Output = options.output
|
|
686
|
+
text: bool = output.fmt is Format.TEXT
|
|
687
|
+
line: str
|
|
688
|
+
for line in statistics(results) if text and output.statistics else render(output.fmt, results):
|
|
689
|
+
_ = sys.stdout.write(f"{line}\n")
|
|
690
|
+
errors: int = sum(r.offence.is_error(r.level) for r in results)
|
|
691
|
+
if text and not output.quiet:
|
|
692
|
+
parts: list[str] = [
|
|
693
|
+
f"Found {errors} error(s) and {len(results) - errors} warning(s) in {files} file(s)",
|
|
694
|
+
]
|
|
695
|
+
if options.mode is _Mode.FIX:
|
|
696
|
+
parts.append(f"fixed {sum(run.fixed for run in runs)}")
|
|
697
|
+
guesses: int
|
|
698
|
+
if guesses := sum(r.offence.fix is not None for r in results):
|
|
699
|
+
parts.append(f"{guesses} more with --unsafe-fixes")
|
|
700
|
+
if options.filter.baseline_file is not None:
|
|
701
|
+
parts.append(f"{sum(run.baselined for run in runs)} baselined")
|
|
702
|
+
_ = sys.stdout.write("; ".join(parts) + ".\n")
|
|
703
|
+
return EXIT_FOUND if errors else EXIT_CLEAN
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _coverage(options: _Options, paths: Sequence[Path], runs: Sequence[_FileRun]) -> int:
|
|
707
|
+
"""Print each file's and the total annotation coverage.
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
The exit status.
|
|
711
|
+
|
|
712
|
+
"""
|
|
713
|
+
counted: list[tuple[Path, Coverage]] = [
|
|
714
|
+
(path, run.coverage) for path, run in zip(paths, runs, strict=True) if run.coverage is not None
|
|
715
|
+
]
|
|
716
|
+
total: Coverage = Coverage(sum(c.typed for _, c in counted), sum(c.total for _, c in counted))
|
|
717
|
+
if options.output.fmt is Format.JSON:
|
|
718
|
+
report: dict[str, str | int | float | list[dict[str, str | int | float]]] = {
|
|
719
|
+
"typed": total.typed,
|
|
720
|
+
"total": total.total,
|
|
721
|
+
"percent": round(total.percent, 1),
|
|
722
|
+
"files": [
|
|
723
|
+
{"path": str(path), "typed": c.typed, "total": c.total, "percent": round(c.percent, 1)}
|
|
724
|
+
for path, c in counted
|
|
725
|
+
],
|
|
726
|
+
}
|
|
727
|
+
_ = sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
728
|
+
else:
|
|
729
|
+
path: Path
|
|
730
|
+
c: Coverage
|
|
731
|
+
for path, c in counted:
|
|
732
|
+
_ = sys.stdout.write(f"{path}: {c.typed}/{c.total} typed ({c.percent:.1f}%)\n")
|
|
733
|
+
_ = sys.stdout.write(
|
|
734
|
+
f"Total: {total.typed}/{total.total} typed ({total.percent:.1f}%) in {len(counted)} file(s).\n",
|
|
735
|
+
)
|
|
736
|
+
threshold: float | None = options.output.fail_under
|
|
737
|
+
return EXIT_FOUND if threshold is not None and total.percent < threshold else EXIT_CLEAN
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _run(options: _Options) -> int:
|
|
741
|
+
"""Check, fix, diff, count or baseline, printing what that finds.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
The exit status.
|
|
745
|
+
|
|
746
|
+
"""
|
|
747
|
+
names: list[Path]
|
|
748
|
+
runs: list[_FileRun]
|
|
749
|
+
names, runs = _check_all(options)
|
|
750
|
+
_ = sys.stderr.write("".join(f"{run.error}\n" for run in runs if run.error))
|
|
751
|
+
failed: bool = any(run.error for run in runs)
|
|
752
|
+
status: int
|
|
753
|
+
if options.mode is _Mode.WRITE_BASELINE and options.filter.baseline_file is not None:
|
|
754
|
+
file: Path = options.filter.baseline_file
|
|
755
|
+
found: dict[str, list[Offence]] = {
|
|
756
|
+
baseline.key(n, file): run.found for n, run in zip(names, runs, strict=True)
|
|
757
|
+
}
|
|
758
|
+
_ = sys.stdout.write(f"Wrote {baseline.write(file, found)} offence(s) to {file}.\n")
|
|
759
|
+
status = EXIT_CLEAN
|
|
760
|
+
elif options.mode is _Mode.COVERAGE:
|
|
761
|
+
status = _coverage(options, names, runs)
|
|
762
|
+
elif options.mode is _Mode.DIFF:
|
|
763
|
+
diffs: str = "".join(run.text for run in runs)
|
|
764
|
+
_ = sys.stdout.write(diffs)
|
|
765
|
+
status = EXIT_FOUND if diffs else EXIT_CLEAN
|
|
766
|
+
elif options.mode is _Mode.FIX and options.input.paths == [STDIN] and runs and not failed:
|
|
767
|
+
_ = sys.stdout.write(runs[0].text) # standard input, fixed, is the whole output
|
|
768
|
+
status = EXIT_FOUND if any(r.offence.is_error(r.level) for r in runs[0].results) else EXIT_CLEAN
|
|
769
|
+
else:
|
|
770
|
+
status = _report(options, runs, len(names))
|
|
771
|
+
return EXIT_ERROR if failed else status
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
775
|
+
"""Run the command.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Its exit status.
|
|
779
|
+
|
|
780
|
+
"""
|
|
781
|
+
options: _Options = _Options.parse(argv)
|
|
782
|
+
file: Path | None = options.output.output_file
|
|
783
|
+
stream: TextIO
|
|
784
|
+
with (
|
|
785
|
+
file.open("w", encoding="utf-8", newline="\n")
|
|
786
|
+
if file
|
|
787
|
+
else contextlib.nullcontext(sys.stdout) as stream,
|
|
788
|
+
contextlib.redirect_stdout(stream),
|
|
789
|
+
):
|
|
790
|
+
status: int = _run(options)
|
|
791
|
+
return EXIT_CLEAN if options.output.exit_zero and status == EXIT_FOUND else status
|