python-constricter 0.2.4__tar.gz → 0.2.7__tar.gz

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 (80) hide show
  1. {python_constricter-0.2.4 → python_constricter-0.2.7}/PKG-INFO +9 -8
  2. {python_constricter-0.2.4 → python_constricter-0.2.7}/README.md +8 -7
  3. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/__init__.py +1 -1
  4. python_constricter-0.2.7/constricter/cli/collecting.py +47 -0
  5. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/command.py +116 -133
  6. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/config.py +8 -15
  7. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/hints.py +9 -103
  8. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/options.py +1 -1
  9. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/paths.py +31 -0
  10. python_constricter-0.2.7/constricter/cli/protocol.py +110 -0
  11. python_constricter-0.2.7/constricter/cli/runs.py +42 -0
  12. python_constricter-0.2.7/constricter/cli/schedule.py +222 -0
  13. python_constricter-0.2.7/constricter/cli/workers.py +154 -0
  14. python_constricter-0.2.7/constricter/fix/doubts.py +387 -0
  15. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/fills.py +47 -45
  16. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/fixes.py +60 -20
  17. python_constricter-0.2.7/constricter/fix/guesses.py +274 -0
  18. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/imports.py +76 -25
  19. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/inference.py +163 -197
  20. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/known.py +91 -5
  21. python_constricter-0.2.7/constricter/fix/library.py +80 -0
  22. python_constricter-0.2.7/constricter/fix/members.py +207 -0
  23. python_constricter-0.2.7/constricter/fix/modules.py +252 -0
  24. python_constricter-0.2.7/constricter/fix/narrowed.py +164 -0
  25. python_constricter-0.2.7/constricter/fix/order.py +145 -0
  26. python_constricter-0.2.7/constricter/fix/overloads.py +394 -0
  27. python_constricter-0.2.7/constricter/fix/project.py +615 -0
  28. python_constricter-0.2.7/constricter/fix/returned.py +678 -0
  29. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/returns.py +22 -29
  30. python_constricter-0.2.7/constricter/fix/stdlib.py +343 -0
  31. python_constricter-0.2.7/constricter/fix/tables/aliases.json +147 -0
  32. python_constricter-0.2.7/constricter/fix/tables/attributes.json +675 -0
  33. python_constricter-0.2.7/constricter/fix/tables/bases.json +888 -0
  34. python_constricter-0.2.7/constricter/fix/tables/classes.json +1939 -0
  35. python_constricter-0.2.7/constricter/fix/tables/method_overloads.json +353 -0
  36. python_constricter-0.2.7/constricter/fix/tables/method_signatures.json +655 -0
  37. python_constricter-0.2.7/constricter/fix/tables/methods.json +857 -0
  38. python_constricter-0.2.7/constricter/fix/tables/overloads.json +373 -0
  39. python_constricter-0.2.7/constricter/fix/tables/returns.json +1328 -0
  40. python_constricter-0.2.7/constricter/fix/tables/source.json +4 -0
  41. python_constricter-0.2.7/constricter/fix/tables/type_parameters.json +175 -0
  42. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/targets.py +64 -8
  43. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/offences.py +5 -0
  44. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/annotations.py +130 -11
  45. python_constricter-0.2.7/constricter/rules/binding.py +241 -0
  46. python_constricter-0.2.7/constricter/rules/checker.py +734 -0
  47. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/flow.py +8 -2
  48. python_constricter-0.2.7/constricter/rules/late.py +288 -0
  49. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/narrowing.py +48 -1
  50. python_constricter-0.2.7/constricter/rules/parsed.py +91 -0
  51. python_constricter-0.2.7/constricter/rules/rebinding.py +140 -0
  52. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/redundant.py +2 -8
  53. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/scope.py +211 -215
  54. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/syntax.py +99 -4
  55. python_constricter-0.2.7/constricter/rules/walked.py +127 -0
  56. {python_constricter-0.2.4 → python_constricter-0.2.7}/pyproject.toml +7 -5
  57. python_constricter-0.2.4/constricter/fix/guesses.py +0 -164
  58. python_constricter-0.2.4/constricter/fix/project.py +0 -370
  59. python_constricter-0.2.4/constricter/fix/returned.py +0 -217
  60. python_constricter-0.2.4/constricter/fix/stdlib.py +0 -255
  61. python_constricter-0.2.4/constricter/rules/checker.py +0 -745
  62. python_constricter-0.2.4/constricter/rules/walked.py +0 -57
  63. {python_constricter-0.2.4 → python_constricter-0.2.7}/LICENSE.md +0 -0
  64. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/__main__.py +0 -0
  65. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/__init__.py +0 -0
  66. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/baseline.py +0 -0
  67. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/explain.py +0 -0
  68. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/guard.py +0 -0
  69. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/cli/report.py +0 -0
  70. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/__init__.py +0 -0
  71. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/hinted.py +0 -0
  72. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/fix/opened.py +0 -0
  73. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/jsonc.py +0 -0
  74. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/noqa.py +0 -0
  75. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/notebook.py +0 -0
  76. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/plugins/__init__.py +0 -0
  77. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/plugins/flake8.py +0 -0
  78. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/plugins/pylint.py +0 -0
  79. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/py.typed +0 -0
  80. {python_constricter-0.2.4 → python_constricter-0.2.7}/constricter/rules/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-constricter
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
5
5
  Author: Ivy Duggan
6
6
  Requires-Python: >=3.11
@@ -231,12 +231,13 @@ ignore = ["LVA003"]
231
231
 
232
232
  `--fix` adds the annotation where the value decides it: a literal (`count = 0` becomes
233
233
  `count: int = 0`), a container whose elements agree, a constructor or a function that declares its
234
- return type (in another checked file too), a copy, subscript, attribute or method call of a local
235
- whose type is known, and values computed from those; a loop's target or an unpacking's names get a
236
- declaration on the line before. `--unsafe-fixes` adds guesses, and `--show-fixes` lists each fix and
237
- how its value decided it. `--infer-with basedpyright` (or `ty`, or `basedpyright,ty`, installed
238
- alongside) asks those type checkers for what `--fix` can't type itself, as guesses. The full list is
239
- in [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
234
+ return type or whose `return`s agree (in another checked file too), a copy, subscript, attribute or
235
+ method call of a local whose type is known, and values computed from those; a loop's target or an
236
+ unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
237
+ `--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
238
+ `basedpyright,ty`, installed alongside) asks those type checkers for what `--fix` can't type itself,
239
+ as guesses. The full list is in
240
+ [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
240
241
 
241
242
  ### Installing and running
242
243
 
@@ -261,7 +262,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
261
262
 
262
263
  ```yaml
263
264
  - repo: https://github.com/ivylikethevine/python-constricter
264
- rev: v0.2.3
265
+ rev: v0.2.7
265
266
  hooks:
266
267
  - id: constricter
267
268
  ```
@@ -204,12 +204,13 @@ ignore = ["LVA003"]
204
204
 
205
205
  `--fix` adds the annotation where the value decides it: a literal (`count = 0` becomes
206
206
  `count: int = 0`), a container whose elements agree, a constructor or a function that declares its
207
- return type (in another checked file too), a copy, subscript, attribute or method call of a local
208
- whose type is known, and values computed from those; a loop's target or an unpacking's names get a
209
- declaration on the line before. `--unsafe-fixes` adds guesses, and `--show-fixes` lists each fix and
210
- how its value decided it. `--infer-with basedpyright` (or `ty`, or `basedpyright,ty`, installed
211
- alongside) asks those type checkers for what `--fix` can't type itself, as guesses. The full list is
212
- in [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
207
+ return type or whose `return`s agree (in another checked file too), a copy, subscript, attribute or
208
+ method call of a local whose type is known, and values computed from those; a loop's target or an
209
+ unpacking's names get a declaration on the line before. `--unsafe-fixes` adds guesses, and
210
+ `--show-fixes` lists each fix and how its value decided it. `--infer-with basedpyright` (or `ty`, or
211
+ `basedpyright,ty`, installed alongside) asks those type checkers for what `--fix` can't type itself,
212
+ as guesses. The full list is in
213
+ [docs/FIXES.md](https://github.com/ivylikethevine/python-constricter/blob/main/docs/FIXES.md).
213
214
 
214
215
  ### Installing and running
215
216
 
@@ -234,7 +235,7 @@ pre-commit, after ruff's hooks (or `constricter-fix`, which runs `--fix` first):
234
235
 
235
236
  ```yaml
236
237
  - repo: https://github.com/ivylikethevine/python-constricter
237
- rev: v0.2.3
238
+ rev: v0.2.7
238
239
  hooks:
239
240
  - id: constricter
240
241
  ```
@@ -25,7 +25,7 @@ from constricter.offences import (
25
25
  )
26
26
  from constricter.rules.checker import Coverage, annotation_coverage, check_source, check_tree
27
27
 
28
- __version__ = "0.2.4"
28
+ __version__ = "0.2.7"
29
29
  __all__ = [
30
30
  "COMMENT_TYPED_TARGET",
31
31
  "DEFAULT_CHECKS",
@@ -0,0 +1,47 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The garbage collector, as the command runs it: by hand, once per file checked.
3
+
4
+ A check keeps every file's tree (see `parsed`), and allocates a great deal for each file, little of it
5
+ cyclic. Left to itself, the collector looked through the kept trees again and again as the check
6
+ allocated: a third of the standard library's check (34s, 21s without). So while the command runs,
7
+ it's off: what indexing built is frozen (never looked through again), and after each file, only
8
+ what's been allocated since (the young generations) is collected, which bounds what a cycle can hold.
9
+ """
10
+
11
+ import contextlib
12
+ import gc
13
+ from collections.abc import Generator
14
+ from typing import Final
15
+
16
+ _YOUNG: Final = 1 # the generations collected after each file: 0 and 1, never the frozen or the old
17
+ _FREEZES: Final = hasattr(gc, "freeze") # PyPy's collector has no freezing
18
+
19
+
20
+ @contextlib.contextmanager
21
+ def by_hand() -> Generator[None]:
22
+ """Turn automatic collection off for the command's run, and back as it was after.
23
+
24
+ Yields:
25
+ Nothing: the run.
26
+
27
+ """
28
+ was: bool = gc.isenabled()
29
+ gc.disable()
30
+ try:
31
+ yield
32
+ finally:
33
+ if _FREEZES:
34
+ gc.unfreeze()
35
+ if was:
36
+ gc.enable()
37
+
38
+
39
+ def indexed() -> None:
40
+ """Freeze what indexing built (the kept trees, the index): it lives until the check takes it."""
41
+ if _FREEZES:
42
+ gc.freeze()
43
+
44
+
45
+ def sweep() -> None:
46
+ """Collect what the file just checked left behind (see the module docstring)."""
47
+ _ = gc.collect(_YOUNG)
@@ -4,25 +4,27 @@
4
4
  import codecs
5
5
  import contextlib
6
6
  import difflib
7
+ import importlib
7
8
  import io
8
- import itertools
9
9
  import json
10
10
  import sys
11
11
  import tokenize
12
- from collections.abc import Callable, Mapping, Sequence
13
- from concurrent.futures import ProcessPoolExecutor
14
- from dataclasses import dataclass, field, replace
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import replace
15
14
  from functools import partial
16
15
  from pathlib import Path
17
- from typing import Final, NamedTuple, TextIO, TypeAlias, cast
16
+ from typing import TYPE_CHECKING, Final, NamedTuple, TextIO, TypeAlias, cast
18
17
 
19
18
  from constricter import notebook
20
- from constricter.cli import baseline, hints
19
+ from constricter.cli import baseline, collecting, schedule
21
20
  from constricter.cli.options import Mode, Options, Output
22
21
  from constricter.cli.paths import STDIN, python_files
22
+ from constricter.cli.protocol import HintError
23
23
  from constricter.cli.report import Format, Result, fix_reasons, render, statistics
24
+ from constricter.cli.runs import BaselineRun, CheckRun, CoverageRun, FileRun
25
+ from constricter.cli.workers import Workers
24
26
  from constricter.fix import fixes, project
25
- from constricter.fix.known import Hints, Outside
27
+ from constricter.fix.known import Hints, Outside, Returns
26
28
  from constricter.noqa import lines, unsuppressed
27
29
  from constricter.offences import (
28
30
  DEFAULT_CHECKS,
@@ -30,7 +32,10 @@ from constricter.offences import (
30
32
  Edit,
31
33
  Offence,
32
34
  )
33
- from constricter.rules.checker import Coverage, annotation_coverage, check_source
35
+ from constricter.rules.checker import Checked, Coverage, annotation_coverage, checked_source
36
+
37
+ if TYPE_CHECKING: # slow to import, and only needed for `--infer-with`
38
+ from constricter.cli import hints
34
39
 
35
40
  EXIT_CLEAN: Final = 0
36
41
  EXIT_FOUND: Final = 1
@@ -74,30 +79,29 @@ def _source(raw: str, name: Path) -> tuple[str, list[notebook.Line]]:
74
79
  return notebook.parse(raw, str(name)) if name.suffix == notebook.SUFFIX else (raw, [])
75
80
 
76
81
 
77
- def check_text(
82
+ def checked_text(
78
83
  raw: str,
79
84
  name: Path,
80
85
  checks: Checks = DEFAULT_CHECKS,
81
86
  *,
82
87
  outside: Outside | None = None,
83
- ) -> list[Offence]:
84
- """Return the offences in `raw`, the text of `name`, that no `# noqa` suppresses.
88
+ ) -> Checked:
89
+ """Check `raw`, the text of `name`: the offences no `# noqa` suppresses.
85
90
 
86
91
  A notebook's code cells are checked as one module, and each offence placed in its cell.
87
92
  Raises `ValueError` for a `.ipynb` that isn't a notebook.
88
93
 
89
94
  Returns:
90
- The unsuppressed offences, a notebook's placed in their cells.
95
+ The unsuppressed offences, a notebook's placed in their cells, and what its functions return
96
+ (see `Checked`).
91
97
 
92
98
  """
93
99
  source: str
94
100
  where: list[notebook.Line]
95
101
  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
102
+ checked: Checked = checked_source(source, str(name), checks, outside=outside)
103
+ offences: list[Offence] = unsuppressed(checked.offences, lines(source))
104
+ return checked._replace(offences=[_placed(o, where) for o in offences] if where else offences)
101
105
 
102
106
 
103
107
  def _placed(offence: Offence, where: list[notebook.Line]) -> Offence:
@@ -110,7 +114,8 @@ def _placed(offence: Offence, where: list[notebook.Line]) -> Offence:
110
114
  """
111
115
  line: notebook.Line = where[offence.line - 1]
112
116
  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
117
+ # A notebook's cells have no import block.
118
+ if offence.edit is not None and (offence.edit.imports or offence.edit.guarded):
114
119
  return replace(placed, edit=None)
115
120
  if offence.edit is not None and offence.edit.edit is Edit.DECLARE:
116
121
  statement: int = where[offence.edit.span[0] - 1].line
@@ -175,34 +180,6 @@ def fix_file(path: Path, offences: Sequence[Offence]) -> int:
175
180
  return count
176
181
 
177
182
 
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
183
  HINT_ROUNDS: Final = 4 # with `--fix --infer-with`: how many times each file is fixed, at most
207
184
 
208
185
 
@@ -222,15 +199,15 @@ def _shown_lines(raw: str, name: Path) -> dict[tuple[int | None, int], str]:
222
199
  }
223
200
 
224
201
 
225
- def _checked(path: Path, name: Path, checks: Checks, outside: Outside) -> tuple[str, list[Offence]]:
202
+ def _checked(path: Path, name: Path, checks: Checks, outside: Outside) -> tuple[str, Checked]:
226
203
  """Read `path` and check it as `name`; raises what reading or parsing it does.
227
204
 
228
205
  Returns:
229
- Its text, and its offences.
206
+ Its text, and what checking it found.
230
207
 
231
208
  """
232
209
  raw: str = _read(path)
233
- return raw, check_text(raw, name, checks, outside=outside)
210
+ return raw, checked_text(raw, name, checks, outside=outside)
234
211
 
235
212
 
236
213
  def _read_checked(
@@ -238,20 +215,20 @@ def _read_checked(
238
215
  name: Path,
239
216
  checks: Checks,
240
217
  outside: Outside,
241
- ) -> tuple[str, list[Offence], str]:
218
+ ) -> tuple[str, Checked, str]:
242
219
  """Read and check `path`, turning a read or parse error into a message instead of raising.
243
220
 
244
221
  Returns:
245
- Its text and offences (empty on error), and the error message (empty on success).
222
+ Its text and what checking it found (nothing on error), and the error message (empty on success).
246
223
 
247
224
  """
248
225
  raw: str
249
- offences: list[Offence]
226
+ checked: Checked
250
227
  try:
251
- raw, offences = _checked(path, name, checks, outside)
228
+ raw, checked = _checked(path, name, checks, outside)
252
229
  except (OSError, ValueError, SyntaxError) as error: # UnicodeDecodeError is a ValueError
253
- return "", [], f"{name}: error: {error}"
254
- return raw, offences, ""
230
+ return "", Checked([], Returns()), f"{name}: error: {error}"
231
+ return raw, checked, ""
255
232
 
256
233
 
257
234
  def _results(raw: str, name: Path, offences: Sequence[Offence], options: Options) -> list[Result]:
@@ -273,7 +250,7 @@ def _results(raw: str, name: Path, offences: Sequence[Offence], options: Options
273
250
  ]
274
251
 
275
252
 
276
- def _check_path(path: Path, outside: Outside, options: Options) -> _CheckRun:
253
+ def _check_path(path: Path, outside: Outside, options: Options) -> CheckRun:
277
254
  """Check (and fix, or diff) one file, given what's known of it from outside it.
278
255
 
279
256
  Returns:
@@ -282,11 +259,24 @@ def _check_path(path: Path, outside: Outside, options: Options) -> _CheckRun:
282
259
  """
283
260
  name: Path = options.input.name(path)
284
261
  raw: str
285
- offences: list[Offence]
262
+ checked: Checked
286
263
  error: str
287
- raw, offences, error = _read_checked(path, name, options.checks, outside)
264
+ raw, checked, error = _read_checked(path, name, options.checks, outside)
288
265
  if error:
289
- return _CheckRun(error=error)
266
+ return CheckRun(error=error)
267
+ return replace(_handled(path, (raw, name), checked.offences, options), returned=checked.returned)
268
+
269
+
270
+ def _handled(path: Path, text: tuple[str, Path], offences: list[Offence], options: Options) -> CheckRun:
271
+ """Report, fix or diff the offences found in `path` (`text`: its text, and its name).
272
+
273
+ Returns:
274
+ What was found, and fixed.
275
+
276
+ """
277
+ raw: str
278
+ name: Path
279
+ raw, name = text
290
280
  baselined: int
291
281
  offences, baselined = options.filter.unbaselined(name, offences)
292
282
  results: list[Result] = _results(raw, name, offences, options)
@@ -295,22 +285,22 @@ def _check_path(path: Path, outside: Outside, options: Options) -> _CheckRun:
295
285
  r.offence for r in results if r.offence.edit is not None and (unsafe or not r.offence.unsafe)
296
286
  ]
297
287
  if options.mode is Mode.DIFF:
298
- return _CheckRun(text=_diff(raw, name, fixing))
288
+ return CheckRun(text=_diff(raw, name, fixing))
299
289
  if options.mode is not Mode.FIX:
300
- return _CheckRun(results, baselined)
290
+ return CheckRun(results, baselined)
301
291
  left: list[Result] = [r for r in results if r.offence not in fixing]
302
292
  if path == STDIN: # the fixed source goes to stdout
303
- return _CheckRun(left, baselined, len(fixing), _fixed(raw, name, fixing).text)
293
+ return CheckRun(left, baselined, len(fixing), _fixed(raw, name, fixing).text)
304
294
  try:
305
- return _CheckRun(left, baselined, fix_file(path, fixing))
295
+ return CheckRun(left, baselined, fix_file(path, fixing))
306
296
  except UnicodeEncodeError as failure: # the file is left as it was, its offences unfixed
307
297
  # Its canonical name: PyPy reports `latin1` where CPython says `latin-1`.
308
298
  encoding: str = codecs.lookup(failure.encoding).name
309
299
  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}")
300
+ return CheckRun(results, baselined, error=f"{name}: error: {message}")
311
301
 
312
302
 
313
- def _baseline_path(path: Path, outside: Outside, options: Options) -> _BaselineRun:
303
+ def _baseline_path(path: Path, outside: Outside, options: Options) -> BaselineRun:
314
304
  """Check one file, unfiltered, for --write-baseline.
315
305
 
316
306
  Returns:
@@ -318,13 +308,13 @@ def _baseline_path(path: Path, outside: Outside, options: Options) -> _BaselineR
318
308
 
319
309
  """
320
310
  name: Path = options.input.name(path)
321
- offences: list[Offence]
311
+ checked: Checked
322
312
  error: str
323
- _, offences, error = _read_checked(path, name, options.checks, outside)
324
- return _BaselineRun(error=error) if error else _BaselineRun(found=offences)
313
+ _, checked, error = _read_checked(path, name, options.checks, outside)
314
+ return BaselineRun(error=error) if error else BaselineRun(checked.offences, returned=checked.returned)
325
315
 
326
316
 
327
- def _cover_path(path: Path, _outside: Outside, options: Options) -> _CoverageRun:
317
+ def _cover_path(path: Path, _outside: Outside, options: Options) -> CoverageRun:
328
318
  """Count one file's typed first bindings.
329
319
 
330
320
  Returns:
@@ -333,12 +323,12 @@ def _cover_path(path: Path, _outside: Outside, options: Options) -> _CoverageRun
333
323
  """
334
324
  name: Path = options.input.name(path)
335
325
  try:
336
- return _CoverageRun(annotation_coverage(_source(_read(path), name)[0], options.checks))
326
+ return CoverageRun(annotation_coverage(_source(_read(path), name)[0], options.checks))
337
327
  except (OSError, ValueError, SyntaxError) as error:
338
- return _CoverageRun(error=f"{name}: error: {error}")
328
+ return CoverageRun(error=f"{name}: error: {error}")
339
329
 
340
330
 
341
- def _check_all(options: Options) -> tuple[list[Path], list[_FileRun]]:
331
+ def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
342
332
  """Check every file (`--jobs` at a time), in order.
343
333
 
344
334
  With `--infer-with`, the type checker's server runs throughout: it's asked for every file's
@@ -352,7 +342,7 @@ def _check_all(options: Options) -> tuple[list[Path], list[_FileRun]]:
352
342
 
353
343
  """
354
344
  paths: list[Path] = list(python_files(options.input.paths, options.input.exclude))
355
- check: Callable[[Path, Outside], _FileRun]
345
+ check: Callable[[Path, Outside], FileRun]
356
346
  if options.mode is Mode.COVERAGE:
357
347
  check = partial(_cover_path, options=options)
358
348
  elif options.mode is Mode.WRITE_BASELINE:
@@ -363,20 +353,26 @@ def _check_all(options: Options) -> tuple[list[Path], list[_FileRun]]:
363
353
  if not options.infer_with or options.mode in {Mode.COVERAGE, Mode.WRITE_BASELINE}:
364
354
  return names, _checked_all(paths, check, options)[0]
365
355
  session: hints.Session
366
- with hints.Session(options.infer_with, Path.cwd(), options.jobs, options.infer_memory) as session:
367
- runs: list[_FileRun]
356
+ # Imported only for `--infer-with`: it's slow to import, for every run's start.
357
+ with cast("type[hints.Session]", importlib.import_module("constricter.cli.hints").Session)(
358
+ options.infer_with,
359
+ Path.cwd(),
360
+ options.jobs,
361
+ options.infer_memory,
362
+ ) as session:
363
+ runs: list[FileRun]
368
364
  modules: project.Index
369
365
  runs, modules = _checked_all(paths, check, options, session)
370
366
  # The files the last round changed: only their hints can have changed.
371
367
  again: list[int] = [
372
368
  index
373
369
  for index, run in enumerate(runs)
374
- if options.mode is Mode.FIX and isinstance(run, _CheckRun) and run.fixed and paths[index] != STDIN
370
+ if options.mode is Mode.FIX and isinstance(run, CheckRun) and run.fixed and paths[index] != STDIN
375
371
  ]
376
372
  for _ in range(HINT_ROUNDS - 1):
377
373
  if not again:
378
374
  break
379
- redone: list[_FileRun] = _checked_all(
375
+ redone: list[FileRun] = _checked_all(
380
376
  [paths[i] for i in again],
381
377
  check,
382
378
  options,
@@ -384,20 +380,20 @@ def _check_all(options: Options) -> tuple[list[Path], list[_FileRun]]:
384
380
  modules,
385
381
  )[0]
386
382
  index: int
387
- run: _FileRun
383
+ run: FileRun
388
384
  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]
385
+ runs[index] = _merged(runs[index], run)
386
+ again = [index for index, run in zip(again, redone, strict=True) if cast("CheckRun", run).fixed]
391
387
  return names, runs
392
388
 
393
389
 
394
390
  def _checked_all(
395
391
  paths: Sequence[Path],
396
- check: Callable[[Path, Outside], _FileRun],
392
+ check: Callable[[Path, Outside], FileRun],
397
393
  options: Options,
398
- session: hints.Session | None = None,
394
+ session: "hints.Session | None" = None,
399
395
  modules: project.Index | None = None,
400
- ) -> tuple[list[_FileRun], project.Index]:
396
+ ) -> tuple[list[FileRun], project.Index]:
401
397
  """Check `paths` (`--jobs` at a time), with the `session`'s hints, and `modules` (else indexed).
402
398
 
403
399
  Returns:
@@ -406,30 +402,35 @@ def _checked_all(
406
402
  """
407
403
  hinted: dict[Path, tuple[Hints, ...]] = {} if session is None else session.hints(_texts(paths))
408
404
  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:
405
+ stack: contextlib.ExitStack
406
+ with contextlib.ExitStack() as stack:
407
+ pool: Workers | None = None
408
+ if options.jobs != 1 and len(paths) > 1:
409
+ pool = stack.enter_context(Workers(paths, options.jobs))
410
+ if modules is None and pool:
411
+ modules = project.Index({}, []) if coverage else pool.index()
412
+ elif modules is None:
411
413
  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.
414
+ collecting.indexed()
415
+ return schedule.checked(
416
+ paths,
417
+ check,
418
+ pool,
419
+ (modules, hinted),
420
+ _merged if options.mode is Mode.FIX else None,
421
+ )
422
+
423
+
424
+ def _merged(before: FileRun, after: FileRun) -> FileRun:
425
+ """Join a file's two `--fix` rounds' `CheckRun`s: what's left is the later's, what's fixed is both's.
427
426
 
428
427
  Returns:
429
428
  The joined run.
430
429
 
431
430
  """
432
- return replace(after, fixed=before.fixed + after.fixed)
431
+ first: CheckRun = cast("CheckRun", before)
432
+ later: CheckRun = cast("CheckRun", after)
433
+ return replace(later, fixed=first.fixed + later.fixed)
433
434
 
434
435
 
435
436
  def _texts(paths: Sequence[Path]) -> dict[Path, str]:
@@ -451,33 +452,14 @@ def _texts(paths: Sequence[Path]) -> dict[Path, str]:
451
452
  return texts
452
453
 
453
454
 
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:
455
+ def _report(options: Options, runs: Sequence[FileRun], files: int) -> int:
474
456
  """Print the results (`runs` is check/fix/diff mode's: every other mode has its own printing).
475
457
 
476
458
  Returns:
477
459
  The exit status.
478
460
 
479
461
  """
480
- checked: Sequence[_CheckRun] = cast("Sequence[_CheckRun]", runs)
462
+ checked: Sequence[CheckRun] = cast("Sequence[CheckRun]", runs)
481
463
  results: list[Result] = [result for run in checked for result in run.results]
482
464
  output: Output = options.output
483
465
  text: bool = output.fmt in {Format.TEXT, Format.FULL}
@@ -502,14 +484,14 @@ def _report(options: Options, runs: Sequence[_FileRun], files: int) -> int:
502
484
  return EXIT_FOUND if errors else EXIT_CLEAN
503
485
 
504
486
 
505
- def _coverage(options: Options, paths: Sequence[Path], runs: Sequence[_FileRun]) -> int:
487
+ def _coverage(options: Options, paths: Sequence[Path], runs: Sequence[FileRun]) -> int:
506
488
  """Print each file's and the total annotation coverage (`runs` is --coverage mode's).
507
489
 
508
490
  Returns:
509
491
  The exit status.
510
492
 
511
493
  """
512
- covered: Sequence[_CoverageRun] = cast("Sequence[_CoverageRun]", runs)
494
+ covered: Sequence[CoverageRun] = cast("Sequence[CoverageRun]", runs)
513
495
  counted: list[tuple[Path, Coverage]] = [
514
496
  (path, run.coverage) for path, run in zip(paths, covered, strict=True) if run.coverage is not None
515
497
  ]
@@ -545,18 +527,19 @@ def _run(options: Options) -> int:
545
527
 
546
528
  """
547
529
  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
530
+ runs: list[FileRun]
531
+ with collecting.by_hand():
532
+ try:
533
+ names, runs = _check_all(options)
534
+ except HintError as error:
535
+ _ = sys.stderr.write(f"constricter: error: {error}\n")
536
+ return EXIT_ERROR
554
537
  _ = sys.stderr.write("".join(f"{run.error}\n" for run in runs if run.error))
555
538
  failed: bool = any(run.error for run in runs)
556
539
  status: int
557
540
  if options.mode is Mode.WRITE_BASELINE and options.filter.baseline_file is not None:
558
541
  file: Path = options.filter.baseline_file
559
- baselined: Sequence[_BaselineRun] = cast("Sequence[_BaselineRun]", runs)
542
+ baselined: Sequence[BaselineRun] = cast("Sequence[BaselineRun]", runs)
560
543
  found: dict[str, list[Offence]] = {
561
544
  baseline.key(n, file): run.found for n, run in zip(names, baselined, strict=True)
562
545
  }
@@ -565,12 +548,12 @@ def _run(options: Options) -> int:
565
548
  elif options.mode is Mode.COVERAGE:
566
549
  status = _coverage(options, names, runs)
567
550
  elif options.mode is Mode.DIFF:
568
- checked: Sequence[_CheckRun] = cast("Sequence[_CheckRun]", runs)
551
+ checked: Sequence[CheckRun] = cast("Sequence[CheckRun]", runs)
569
552
  diffs: str = "".join(run.text for run in checked)
570
553
  _ = sys.stdout.write(diffs)
571
554
  status = EXIT_FOUND if diffs else EXIT_CLEAN
572
555
  elif options.mode is Mode.FIX and options.input.paths == [STDIN] and runs and not failed:
573
- fixed: _CheckRun = cast("_CheckRun", runs[0])
556
+ fixed: CheckRun = cast("CheckRun", runs[0])
574
557
  _ = sys.stdout.write(fixed.text) # standard input, fixed, is the whole output
575
558
  status = EXIT_FOUND if any(r.offence.is_error(r.level) for r in fixed.results) else EXIT_CLEAN
576
559
  else:
@@ -7,7 +7,7 @@ from functools import partial
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING, Final, TypeAlias
9
9
 
10
- from constricter.cli.hints import SERVERS
10
+ from constricter.cli.protocol import SERVERS
11
11
  from constricter.jsonc import is_int
12
12
  from constricter.offences import FIX_KINDS, LEVELS, MESSAGES
13
13
 
@@ -165,24 +165,17 @@ def _levels(value: _Toml) -> dict[str, str] | None:
165
165
  return None if None in levels.values() else {glob: str(level) for glob, level in levels.items()}
166
166
 
167
167
 
168
- def _ignores(value: _Toml) -> dict[str, list[str]] | None:
169
- if not isinstance(value, dict):
170
- return None
171
- ignores: dict[str, list[str] | None] = {glob: _codes(codes) for glob, codes in value.items()}
172
- return None if None in ignores.values() else {glob: list(codes or []) for glob, codes in ignores.items()}
173
-
174
-
175
- def _narrower(value: _Toml) -> dict[str, list[str]] | None:
176
- """Read a type hierarchy: each type, and the list of types it's narrower than.
168
+ def _lists(value: _Toml, read: Callable[[_Toml], list[str] | None]) -> dict[str, list[str]] | None:
169
+ """Read a table of lists, each as `read` reads one: `per-file-ignores`' codes, `narrower`'s types.
177
170
 
178
171
  Returns:
179
- It, or `None` if `value` isn't a table of string lists.
172
+ It, or `None` if `value` isn't a table, or `read` can't read one of its values.
180
173
 
181
174
  """
182
175
  if not isinstance(value, dict):
183
176
  return None
184
- wider: dict[str, list[str] | None] = {name: _strings(types) for name, types in value.items()}
185
- return None if None in wider.values() else {name: list(types or []) for name, types in wider.items()}
177
+ lists: dict[str, list[str] | None] = {key: read(item) for key, item in value.items()}
178
+ return None if None in lists.values() else {key: list(item or []) for key, item in lists.items()}
186
179
 
187
180
 
188
181
  def _baseline(value: _Toml, root: Path) -> str | None:
@@ -211,8 +204,8 @@ _READERS: dict[str, Callable[[_Toml], Default | None]] = {
211
204
  "type-comments": _flag,
212
205
  "all-scopes": _flag,
213
206
  "per-path-levels": _levels,
214
- "per-file-ignores": _ignores,
215
- "narrower": _narrower,
207
+ "per-file-ignores": partial(_lists, read=_codes),
208
+ "narrower": partial(_lists, read=_strings), # a type hierarchy: each type, and those it's narrower than
216
209
  "infer-with": _checkers,
217
210
  "infer-memory": _gigabytes,
218
211
  }