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.
Files changed (56) hide show
  1. constricter/__init__.py +19 -6
  2. constricter/__main__.py +1 -1
  3. constricter/cli/__init__.py +2 -0
  4. constricter/{baseline.py → cli/baseline.py} +2 -6
  5. constricter/cli/command.py +598 -0
  6. constricter/{config.py → cli/config.py} +81 -11
  7. constricter/cli/explain.py +103 -0
  8. constricter/cli/guard.py +129 -0
  9. constricter/cli/hints.py +789 -0
  10. constricter/cli/options.py +601 -0
  11. constricter/cli/paths.py +51 -0
  12. constricter/{report.py → cli/report.py} +156 -16
  13. constricter/fix/__init__.py +2 -0
  14. constricter/fix/fills.py +226 -0
  15. constricter/fix/fixes.py +192 -0
  16. constricter/fix/guesses.py +164 -0
  17. constricter/fix/hinted.py +242 -0
  18. constricter/fix/imports.py +174 -0
  19. constricter/fix/inference.py +756 -0
  20. constricter/fix/known.py +177 -0
  21. constricter/fix/opened.py +74 -0
  22. constricter/fix/project.py +370 -0
  23. constricter/fix/returned.py +217 -0
  24. constricter/fix/returns.py +200 -0
  25. constricter/fix/stdlib.py +255 -0
  26. constricter/fix/targets.py +128 -0
  27. constricter/jsonc.py +22 -3
  28. constricter/noqa.py +1 -1
  29. constricter/notebook.py +10 -4
  30. constricter/offences.py +245 -0
  31. constricter/plugins/__init__.py +2 -0
  32. constricter/{flake8_plugin.py → plugins/flake8.py} +35 -2
  33. constricter/{pylint_plugin.py → plugins/pylint.py} +52 -7
  34. constricter/rules/__init__.py +2 -0
  35. constricter/rules/annotations.py +592 -0
  36. constricter/rules/checker.py +745 -0
  37. constricter/rules/flow.py +481 -0
  38. constricter/rules/narrowing.py +51 -0
  39. constricter/rules/redundant.py +166 -0
  40. constricter/rules/scope.py +608 -0
  41. constricter/rules/syntax.py +239 -0
  42. constricter/rules/walked.py +57 -0
  43. python_constricter-0.2.4.dist-info/METADATA +378 -0
  44. python_constricter-0.2.4.dist-info/RECORD +48 -0
  45. python_constricter-0.2.4.dist-info/entry_points.txt +6 -0
  46. constricter/annotations.py +0 -290
  47. constricter/checker.py +0 -651
  48. constricter/cli.py +0 -791
  49. constricter/explain.py +0 -61
  50. constricter/fixes.py +0 -22
  51. constricter/project.py +0 -229
  52. python_constricter-0.2.2.dist-info/METADATA +0 -456
  53. python_constricter-0.2.2.dist-info/RECORD +0 -22
  54. python_constricter-0.2.2.dist-info/entry_points.txt +0 -6
  55. {python_constricter-0.2.2.dist-info → python_constricter-0.2.4.dist-info}/WHEEL +0 -0
  56. {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.checker import (
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
- Coverage,
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.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
@@ -3,7 +3,7 @@
3
3
 
4
4
  import sys
5
5
 
6
- from constricter.cli import main
6
+ from constricter.cli.command import main
7
7
 
8
8
  if __name__ == "__main__": # not when `--jobs` workers import it
9
9
  sys.exit(main())
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The `constricter` command (`command.main`): its options, the files it checks, and how it reports."""
@@ -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.checker import Offence
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(_is_count(n) for n in counts.values()):
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