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/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