python-constricter 0.4.2__py3-none-any.whl → 0.4.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 (157) hide show
  1. constricter/__init__.py +0 -1
  2. constricter/__main__.py +3 -3
  3. constricter/_version.py +1 -2
  4. constricter/cli/__init__.py +0 -1
  5. constricter/cli/baseline.py +0 -1
  6. constricter/cli/collecting.py +6 -4
  7. constricter/cli/command.py +38 -15
  8. constricter/cli/config.py +7 -6
  9. constricter/cli/explain.py +21 -1
  10. constricter/cli/lsp/__init__.py +0 -1
  11. constricter/cli/lsp/connection.py +19 -22
  12. constricter/cli/lsp/edits.py +1 -2
  13. constricter/cli/lsp/guard.py +10 -8
  14. constricter/cli/lsp/hints.py +165 -53
  15. constricter/cli/lsp/protocol.py +39 -16
  16. constricter/cli/lsp/remembered.py +9 -10
  17. constricter/cli/lsp/stored.py +6 -6
  18. constricter/cli/options.py +24 -7
  19. constricter/cli/paths.py +0 -1
  20. constricter/cli/reading.py +5 -1
  21. constricter/cli/report.py +15 -8
  22. constricter/cli/rounds.py +72 -27
  23. constricter/cli/runs.py +34 -2
  24. constricter/cli/schedule.py +48 -100
  25. constricter/cli/workers.py +10 -24
  26. constricter/empty.py +0 -1
  27. constricter/fix/__init__.py +0 -1
  28. constricter/fix/core/__init__.py +0 -1
  29. constricter/fix/core/asked.py +0 -1
  30. constricter/fix/core/fixes.py +16 -4
  31. constricter/fix/core/imports.py +83 -58
  32. constricter/fix/core/inherited.py +4 -5
  33. constricter/fix/core/known.py +40 -27
  34. constricter/fix/core/signatures.py +2 -3
  35. constricter/fix/index/__init__.py +0 -1
  36. constricter/fix/index/across.py +145 -0
  37. constricter/fix/index/annotated.py +83 -0
  38. constricter/fix/index/awaits.py +11 -12
  39. constricter/fix/index/beyond.py +5 -6
  40. constricter/fix/index/callers.py +37 -8
  41. constricter/fix/index/consistent.py +133 -0
  42. constricter/fix/index/crossed.py +84 -35
  43. constricter/fix/index/decorated.py +21 -9
  44. constricter/fix/index/fixtures.py +21 -12
  45. constricter/fix/index/linked.py +29 -30
  46. constricter/fix/index/loose.py +9 -10
  47. constricter/fix/index/managed.py +9 -10
  48. constricter/fix/index/modules.py +90 -41
  49. constricter/fix/index/offers.py +10 -17
  50. constricter/fix/index/order.py +6 -11
  51. constricter/fix/index/origins.py +7 -3
  52. constricter/fix/index/own_methods.py +3 -4
  53. constricter/fix/index/own_overloads.py +9 -5
  54. constricter/fix/index/own_types.py +11 -15
  55. constricter/fix/index/params.py +6 -6
  56. constricter/fix/index/plain.py +3 -4
  57. constricter/fix/index/project.py +99 -660
  58. constricter/fix/index/sides.py +10 -75
  59. constricter/fix/index/spelling.py +706 -0
  60. constricter/fix/index/strings.py +1 -2
  61. constricter/fix/index/stubs/__init__.py +0 -1
  62. constricter/fix/index/stubs/atoms.py +36 -25
  63. constricter/fix/index/stubs/chained.py +2 -3
  64. constricter/fix/index/stubs/classnames.py +3 -4
  65. constricter/fix/index/stubs/declared.py +2 -3
  66. constricter/fix/index/stubs/installed.py +134 -43
  67. constricter/fix/index/stubs/names.py +8 -11
  68. constricter/fix/index/stubs/read.py +6 -7
  69. constricter/fix/index/stubs/signatures.py +11 -14
  70. constricter/fix/index/stubs/stubbed.py +10 -12
  71. constricter/fix/index/taken.py +10 -7
  72. constricter/fix/index/tuples.py +7 -11
  73. constricter/fix/libraries/__init__.py +0 -1
  74. constricter/fix/libraries/generics.py +50 -0
  75. constricter/fix/libraries/library.py +60 -22
  76. constricter/fix/libraries/opened.py +0 -1
  77. constricter/fix/libraries/operators.py +185 -0
  78. constricter/fix/libraries/overloads.py +90 -224
  79. constricter/fix/libraries/receivers.py +2 -11
  80. constricter/fix/libraries/stdlib.py +55 -11
  81. constricter/fix/libraries/walks.py +0 -1
  82. constricter/fix/tables/source.json +1 -1
  83. constricter/fix/values/__init__.py +0 -1
  84. constricter/fix/values/aliased.py +8 -19
  85. constricter/fix/values/bodies.py +41 -5
  86. constricter/fix/values/callables.py +14 -22
  87. constricter/fix/values/called.py +20 -24
  88. constricter/fix/values/classvars.py +34 -32
  89. constricter/fix/values/decided.py +2 -3
  90. constricter/fix/values/displays.py +0 -1
  91. constricter/fix/values/doubts.py +50 -31
  92. constricter/fix/values/entered.py +12 -19
  93. constricter/fix/values/fills.py +176 -33
  94. constricter/fix/values/guesses.py +34 -34
  95. constricter/fix/values/hinted.py +181 -41
  96. constricter/fix/values/inference.py +87 -55
  97. constricter/fix/values/loops.py +5 -8
  98. constricter/fix/values/members.py +142 -20
  99. constricter/fix/values/narrowed.py +283 -20
  100. constricter/fix/values/operated.py +0 -1
  101. constricter/fix/values/ordered.py +1 -2
  102. constricter/fix/values/reads.py +388 -0
  103. constricter/fix/values/refused.py +87 -0
  104. constricter/fix/values/returned.py +46 -48
  105. constricter/fix/values/returns.py +2 -2
  106. constricter/fix/values/shapes.py +30 -20
  107. constricter/fix/values/stored.py +64 -35
  108. constricter/{rules → fix/values}/tables.py +4 -5
  109. constricter/fix/values/targets.py +18 -6
  110. constricter/fix/values/unchecked.py +27 -14
  111. constricter/jsonc.py +0 -1
  112. constricter/noqa.py +0 -1
  113. constricter/notebook.py +2 -3
  114. constricter/offences.py +102 -41
  115. constricter/overrides.py +0 -1
  116. constricter/plugins/__init__.py +0 -1
  117. constricter/plugins/flake8.py +50 -50
  118. constricter/plugins/options.py +41 -0
  119. constricter/plugins/pylint.py +36 -65
  120. constricter/recording.py +63 -23
  121. constricter/rules/__init__.py +0 -1
  122. constricter/rules/attributes.py +258 -0
  123. constricter/rules/binding.py +149 -57
  124. constricter/rules/calls.py +105 -22
  125. constricter/rules/checker.py +115 -32
  126. constricter/rules/late.py +46 -312
  127. constricter/rules/narrowing.py +3 -4
  128. constricter/rules/parameters.py +94 -0
  129. constricter/rules/rebinding.py +1 -2
  130. constricter/rules/rebound.py +296 -0
  131. constricter/rules/recorded.py +8 -4
  132. constricter/rules/redundant.py +2 -3
  133. constricter/rules/returning.py +251 -0
  134. constricter/rules/scope.py +186 -38
  135. constricter/rules/scoped.py +11 -6
  136. constricter/rules/scoping.py +9 -11
  137. constricter/rules/settings.py +69 -40
  138. constricter/rules/signatures.py +77 -0
  139. constricter/rules/widened.py +10 -8
  140. constricter/source/__init__.py +1 -0
  141. constricter/{rules → source}/annotations.py +2 -3
  142. constricter/{rules → source}/declarations.py +170 -14
  143. constricter/{rules → source}/decorators.py +11 -30
  144. constricter/{rules → source}/flow.py +1 -2
  145. constricter/{rules → source}/keys.py +1 -2
  146. constricter/{rules → source}/parsed.py +2 -3
  147. constricter/{rules → source}/quoted.py +0 -1
  148. constricter/{rules → source}/syntax.py +99 -46
  149. constricter/{rules → source}/walked.py +17 -11
  150. constricter/trace.py +104 -35
  151. constricter/{cli/traced.py → traced.py} +54 -9
  152. {python_constricter-0.4.2.dist-info → python_constricter-0.4.4.dist-info}/METADATA +39 -11
  153. python_constricter-0.4.4.dist-info/RECORD +177 -0
  154. python_constricter-0.4.2.dist-info/RECORD +0 -162
  155. {python_constricter-0.4.2.dist-info → python_constricter-0.4.4.dist-info}/WHEEL +0 -0
  156. {python_constricter-0.4.2.dist-info → python_constricter-0.4.4.dist-info}/entry_points.txt +0 -0
  157. {python_constricter-0.4.2.dist-info → python_constricter-0.4.4.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py CHANGED
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """Every local variable typed where it's first bound."""
3
2
 
4
3
  from constricter._version import __version__ # written by the build, from the git tag
constricter/__main__.py CHANGED
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """`python -m constricter`, and the `constricter` script."""
3
2
 
4
3
  import contextlib
@@ -13,9 +12,10 @@ def run() -> None:
13
12
 
14
13
  Not by the interpreter's own exit, which frees every parsed file and the index an object at a
15
14
  time: a fifth of a check of a thousand files. What the command wrote is flushed first; nothing
16
- else waits on the exit (its workers have ended, its output file is closed).
15
+ else waits on the exit (its workers have ended, its output file is closed). Nor is the collector
16
+ turned on again for it (`leaving`).
17
17
  """
18
- status: int = main()
18
+ status: int = main(leaving=True)
19
19
  with contextlib.suppress(OSError, ValueError): # a reader that left, a stream closed already
20
20
  _ = sys.stdout.flush()
21
21
  _ = sys.stderr.flush()
constricter/_version.py CHANGED
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """The version this was built as, from its git tag (written by the build, not tracked)."""
3
2
 
4
- __version__ = "0.4.2"
3
+ __version__ = "0.4.4"
@@ -1,2 +1 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """The `constricter` command (`command.main`): its options, the files it checks, and how it reports."""
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """Baseline files: the offences a codebase already has, so only new ones are reported.
3
2
 
4
3
  An entry is a file, a code and a variable name, with how many times it occurs; line numbers aren't
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """The garbage collector, as the command runs it: by hand, once per file checked.
3
2
 
4
3
  A check keeps every file's tree (see `parsed`), and allocates a great deal for each file, little of it
@@ -24,9 +23,12 @@ _YOUNGER: Final = not sysconfig.get_config_var("Py_GIL_DISABLED")
24
23
 
25
24
 
26
25
  @contextlib.contextmanager
27
- def by_hand() -> Generator[None]:
26
+ def by_hand(*, leaving: bool = False) -> Generator[None]:
28
27
  """Turn automatic collection off for the command's run, and back as it was after.
29
28
 
29
+ Not for a process that leaves once the run's done (`leaving`): turned on again, the collector
30
+ looked through all the run left alive, at once (a check of pandas: 3.4s, for nothing).
31
+
30
32
  Yields:
31
33
  Nothing: the run.
32
34
 
@@ -36,9 +38,9 @@ def by_hand() -> Generator[None]:
36
38
  try:
37
39
  yield
38
40
  finally:
39
- if _FREEZES:
41
+ if _FREEZES and not leaving:
40
42
  gc.unfreeze()
41
- if was:
43
+ if was and not leaving:
42
44
  gc.enable()
43
45
 
44
46
 
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """The `constricter` command (see README)."""
3
2
 
4
3
  import codecs
@@ -18,7 +17,7 @@ from constricter.cli import baseline, collecting, schedule
18
17
  from constricter.cli.lsp.protocol import HintError
19
18
  from constricter.cli.options import Mode, Options, Output
20
19
  from constricter.cli.paths import STDIN, python_files
21
- from constricter.cli.reading import encoding_of, read_text
20
+ from constricter.cli.reading import READ_ERRORS, encoding_of, read_text
22
21
  from constricter.cli.report import Format, Result, fix_reasons, render, statistics
23
22
  from constricter.cli.rounds import checked_all, settled
24
23
  from constricter.cli.runs import Added, BaselineRun, CheckRun, CoverageRun, FileRun
@@ -32,16 +31,17 @@ from constricter.offences import (
32
31
  Edit,
33
32
  Offence,
34
33
  )
35
- from constricter.rules import parsed
36
34
  from constricter.rules.checker import Checked, Coverage, annotation_coverage, checked_source
35
+ from constricter.source import parsed
37
36
 
38
37
  if TYPE_CHECKING: # slow to import, and only needed for `--infer-with`
38
+ from types import ModuleType
39
+
39
40
  from constricter.cli.lsp import hints
40
41
 
41
42
  EXIT_CLEAN: Final = 0
42
43
  EXIT_FOUND: Final = 1
43
44
  EXIT_ERROR: Final = 2
44
- _ALL: Final = 100 # percent
45
45
 
46
46
 
47
47
  def _source(raw: str, name: Path) -> tuple[str, list[notebook.Line]]:
@@ -198,11 +198,21 @@ def _read_checked(
198
198
  checked: Checked
199
199
  try:
200
200
  raw, checked = _checked(path, name, checks, outside)
201
- except (OSError, ValueError, SyntaxError) as error: # UnicodeDecodeError is a ValueError
202
- return "", Checked([], Returns()), f"{name}: error: {error}"
201
+ except READ_ERRORS as error:
202
+ return "", Checked([], Returns()), _error(name, error)
203
203
  return raw, checked, ""
204
204
 
205
205
 
206
+ def _error(name: Path, error: Exception) -> str:
207
+ """Say why a file couldn't be read or parsed.
208
+
209
+ Returns:
210
+ The message.
211
+
212
+ """
213
+ return f"{name}: error: {error}"
214
+
215
+
206
216
  def _results(raw: str, name: Path, offences: Sequence[Offence], options: Options) -> list[Result]:
207
217
  """Turn the offences in `raw`, the text of `name`, into the results to report.
208
218
 
@@ -325,8 +335,8 @@ def _cover_path(path: Path, _outside: Outside, options: Options) -> CoverageRun:
325
335
  name: Path = options.input.name(path)
326
336
  try:
327
337
  return CoverageRun(annotation_coverage(_source(read_text(path), name)[0], options.checks))
328
- except (OSError, ValueError, SyntaxError) as error:
329
- return CoverageRun(error=f"{name}: error: {error}")
338
+ except READ_ERRORS as error:
339
+ return CoverageRun(error=_error(name, error))
330
340
 
331
341
 
332
342
  def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
@@ -370,12 +380,17 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
370
380
  return names, settled(paths, check, options, None)
371
381
  session: hints.Session
372
382
  # Imported only for `--infer-with`: it's slow to import, for every run's start.
373
- with cast("type[hints.Session]", importlib.import_module("constricter.cli.lsp.hints").Session)(
383
+ hinting: ModuleType = importlib.import_module("constricter.cli.lsp.hints")
384
+ asking: hints.Asking = cast("type[hints.Asking]", hinting.Asking)(
385
+ kept=not options.infer_fresh,
386
+ in_turn=options.infer_in_turn,
387
+ )
388
+ with cast("type[hints.Session]", hinting.Session)(
374
389
  options.infer_with,
375
390
  Path.cwd(),
376
391
  options.jobs,
377
392
  options.infer_memory,
378
- kept=not options.infer_fresh,
393
+ asking,
379
394
  ) as session:
380
395
  runs: list[FileRun] = settled(paths, check, options, session)
381
396
  checker: str
@@ -383,6 +398,11 @@ def _check_rounds(options: Options) -> tuple[list[Path], list[FileRun]]:
383
398
  for checker, path in session.abandoned:
384
399
  name: Path = options.input.name(path)
385
400
  _ = sys.stderr.write(f"constricter: warning: {checker} hung on {name} twice: no hints for it\n")
401
+ read: int
402
+ rounds: int
403
+ for checker, read, rounds in session.reused:
404
+ told: str = f"{checker}'s hints for {read} of {rounds} rounds were read from the store"
405
+ _ = sys.stderr.write(f"constricter: note: {told} (`--infer-fresh` asks again)\n")
386
406
  return names, runs
387
407
 
388
408
 
@@ -485,8 +505,8 @@ def _typed(found: Coverage) -> str:
485
505
  return f"{found.typed}/{found.total} typed ({found.percent:.1f}%){wider}"
486
506
 
487
507
 
488
- def _run(options: Options) -> int:
489
- """Check, fix, diff, count or baseline, printing what that finds.
508
+ def _run(options: Options, *, leaving: bool) -> int:
509
+ """Check, fix, diff, count or baseline, printing what that finds (`leaving`: see `main`).
490
510
 
491
511
  Returns:
492
512
  The exit status.
@@ -494,7 +514,7 @@ def _run(options: Options) -> int:
494
514
  """
495
515
  names: list[Path]
496
516
  runs: list[FileRun]
497
- with collecting.by_hand():
517
+ with collecting.by_hand(leaving=leaving):
498
518
  try:
499
519
  names, runs = _check_all(options)
500
520
  except HintError as error:
@@ -527,9 +547,12 @@ def _run(options: Options) -> int:
527
547
  return EXIT_ERROR if failed else status
528
548
 
529
549
 
530
- def main(argv: Sequence[str] | None = None) -> int:
550
+ def main(argv: Sequence[str] | None = None, *, leaving: bool = False) -> int:
531
551
  """Run the command.
532
552
 
553
+ `leaving`: the process leaves once it's done (`constricter.__main__.run`), its collector as the
554
+ run left it (see `collecting.by_hand`).
555
+
533
556
  Returns:
534
557
  Its exit status.
535
558
 
@@ -543,5 +566,5 @@ def main(argv: Sequence[str] | None = None) -> int:
543
566
  else contextlib.nullcontext(sys.stdout) as stream,
544
567
  contextlib.redirect_stdout(stream),
545
568
  ):
546
- status: int = _run(options)
569
+ status: int = _run(options, leaving=leaving)
547
570
  return EXIT_CLEAN if options.output.exit_zero and status == EXIT_FOUND else status
constricter/cli/config.py CHANGED
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """The command's defaults, from the nearest `pyproject.toml`'s `[tool.constricter]` table."""
3
2
 
4
3
  import re
@@ -221,11 +220,13 @@ def _checkers(value: _Toml) -> list[str] | None:
221
220
  """Read `infer-with`: a checker, or a list of them, each one it knows.
222
221
 
223
222
  Returns:
224
- Them, or `None` for anything else.
223
+ Them, each once in the order they're preferred, as `--infer-with` gives them; `None` for
224
+ anything else.
225
225
 
226
226
  """
227
227
  checkers: list[str] | None = [value] if isinstance(value, str) else _strings(value)
228
- return checkers if checkers and all(checker in SERVERS for checker in checkers) else None
228
+ known: bool = bool(checkers) and all(checker in SERVERS for checker in checkers or ())
229
+ return list(dict.fromkeys(checkers)) if checkers and known else None
229
230
 
230
231
 
231
232
  def _levels(value: _Toml) -> dict[str, str] | None:
@@ -248,7 +249,7 @@ def _lists(value: _Toml, read: Callable[[_Toml], list[str] | None]) -> dict[str,
248
249
  return None if None in lists.values() else {key: list(item or []) for key, item in lists.items()}
249
250
 
250
251
 
251
- def _baseline(value: _Toml, root: Path) -> str | None:
252
+ def _path(value: _Toml, root: Path) -> str | None:
252
253
  """Read a baseline's or a trace's path, relative to `root` (the pyproject.toml that names it).
253
254
 
254
255
  Returns:
@@ -303,8 +304,8 @@ def config_defaults(start: Path) -> dict[str, Default]:
303
304
  return {}
304
305
  readers: dict[str, Callable[[_Toml], Default | None]] = {
305
306
  **_READERS,
306
- "baseline": partial(_baseline, root=path.parent),
307
- "infer-from": partial(_baseline, root=path.parent),
307
+ "baseline": partial(_path, root=path.parent),
308
+ "infer-from": partial(_path, root=path.parent),
308
309
  }
309
310
  defaults: dict[str, Default] = {}
310
311
  key: str
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """Each code's rationale and fix, for `constricter --explain`."""
3
2
 
4
3
  from typing import Final
@@ -14,7 +13,10 @@ from constricter.offences import (
14
13
  OPT_IN,
15
14
  REDUNDANT_TYPE,
16
15
  UNANNOTATED,
16
+ UNANNOTATED_ATTRIBUTE,
17
17
  UNANNOTATED_MEMBER,
18
+ UNANNOTATED_PARAMETER,
19
+ UNANNOTATED_RETURN,
18
20
  UNTYPED_TARGET,
19
21
  UNUSED_UNION_MEMBER,
20
22
  VAGUE_TYPE,
@@ -83,6 +85,24 @@ _WHY: Final = {
83
85
  "(not from a nested function either) could say so: `limit: Final = 10`. Off unless selected\n"
84
86
  "by its full code (`--extend-select LVA012`): most locals are bound once."
85
87
  ),
88
+ UNANNOTATED_ATTRIBUTE: (
89
+ "An instance attribute a plain class's method first stores (`self.count = 0`), declared\n"
90
+ "nowhere in the class, is typed by whatever a type checker infers from that store. Annotate\n"
91
+ "it there: `self.count: int = 0`. Off unless selected by its full code (`--extend-select LVA013`)."
92
+ ),
93
+ UNANNOTATED_RETURN: (
94
+ "A plain function with no return annotation leaves its callers to whatever a type checker\n"
95
+ "infers. `--fix` writes what its own `return`s give (`-> int`), or `-> None` for one that\n"
96
+ "returns nothing. Off unless selected by its full code (`--extend-select LVA014`): ruff's ANN201\n"
97
+ "reports it too; this is for the fix."
98
+ ),
99
+ UNANNOTATED_PARAMETER: (
100
+ "A parameter with no annotation leaves the function's body to whatever a type checker\n"
101
+ "infers of it. `--fix` writes what typed it from outside, a guess: what every call passes it,\n"
102
+ "the one class with every member the function reads of it, its pytest fixture's value, or\n"
103
+ "the one type the checked files annotate its name with. Off unless selected by its full code\n"
104
+ "(`--extend-select LVA015`): ruff's ANN001 reports it too; this is for the fix."
105
+ ),
86
106
  }
87
107
 
88
108
 
@@ -1,2 +1 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """constricter.cli.lsp."""
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """A running language server, and the JSON-RPC conversation with it (`Connection`), for `hints`.
3
2
 
4
3
  The files it has open, the hint requests sent and not yet answered, and what it has said.
@@ -6,7 +5,6 @@ The files it has open, the hint requests sent and not yet answered, and what it
6
5
 
7
6
  import contextlib
8
7
  import itertools
9
- import json
10
8
  import os
11
9
  import queue
12
10
  import re
@@ -22,7 +20,7 @@ from pathlib import Path
22
20
  from typing import IO, Final, NamedTuple, TypeAlias, cast
23
21
 
24
22
  from constricter.cli.lsp import edits, guard, protocol
25
- from constricter.cli.lsp.protocol import HintError, HungError
23
+ from constricter.cli.lsp.protocol import ExitedError, HintError, HungError
26
24
  from constricter.fix.core.known import Offered
27
25
 
28
26
  _Json: TypeAlias = protocol.Json
@@ -79,22 +77,24 @@ class _Documents:
79
77
  texts: dict[str, str] = field(default_factory=dict[str, str])
80
78
 
81
79
 
82
- class _Asked(NamedTuple):
80
+ class Asked(NamedTuple):
83
81
  """Hint requests sent, not yet answered: each request's file, and each file's lines."""
84
82
 
85
83
  numbers: dict[int, Path]
86
84
  lines: dict[Path, list[str]]
87
85
 
88
86
 
89
- Sent: TypeAlias = tuple[dict[Path, str], _Asked] # a batch of files, and its hint requests
87
+ Sent: TypeAlias = tuple[dict[Path, str], Asked] # a batch of files, and its hint requests
90
88
 
91
89
 
92
90
  class Connection:
93
91
  """A running language server, and the JSON-RPC conversation with it."""
94
92
 
95
- def __init__(self, argv: Sequence[str], root: Path) -> None:
93
+ def __init__(self, argv: Sequence[str], root: Path, environment: Mapping[str, str] | None = None) -> None:
96
94
  """Start the server (`argv`) and initialize it, with `root` as its one workspace folder.
97
95
 
96
+ In `environment`, or else this process's.
97
+
98
98
  Raises:
99
99
  HintError: It can't be started, or fails to initialize.
100
100
 
@@ -115,6 +115,7 @@ class Connection:
115
115
  stdout=subprocess.PIPE,
116
116
  stderr=subprocess.DEVNULL,
117
117
  cwd=root,
118
+ env=environment,
118
119
  ),
119
120
  )
120
121
  self.inbox: _Inbox = _Inbox()
@@ -188,7 +189,7 @@ class Connection:
188
189
  return False
189
190
  return True
190
191
 
191
- def ask(self, files: Mapping[Path, str]) -> "_Asked":
192
+ def ask(self, files: Mapping[Path, str]) -> "Asked":
192
193
  """Open each file (or send its new text), and ask for its hints over the whole of it.
193
194
 
194
195
  It doesn't wait for them. Each file is left open: basedpyright (1.40) fails the next file's
@@ -204,7 +205,7 @@ class Connection:
204
205
  text: str
205
206
  for path, text in files.items():
206
207
  self._opened(path.resolve().as_uri(), text)
207
- return _Asked({self._ask_hints(path, lines[path]): path for path in files}, lines)
208
+ return Asked({self._ask_hints(path, lines[path]): path for path in files}, lines)
208
209
 
209
210
  def _ask_hints(self, path: Path, lines: Sequence[str]) -> int:
210
211
  """Ask for an open file's hints over the whole of it, without waiting for them.
@@ -221,7 +222,7 @@ class Connection:
221
222
  },
222
223
  )
223
224
 
224
- def answers(self, asked: "_Asked") -> Found:
225
+ def answers(self, asked: "Asked") -> Found:
225
226
  """Wait for the answers to hint requests `ask` sent, asking again for those the server dropped.
226
227
 
227
228
  Returns:
@@ -327,7 +328,7 @@ class Connection:
327
328
 
328
329
  """
329
330
  self.last += 1
330
- self._send({"jsonrpc": "2.0", _ID: self.last, _METHOD: method, "params": params})
331
+ self._send({_ID: self.last, _METHOD: method, "params": params})
331
332
  return self.last
332
333
 
333
334
  def _collect(self, numbers: set[int], method: str) -> dict[int, _Json]:
@@ -391,7 +392,7 @@ class Connection:
391
392
  It.
392
393
 
393
394
  Raises:
394
- HintError: The server exited, or took too long.
395
+ ExitedError: The server exited (`_waited` raises `HungError` where it took too long).
395
396
 
396
397
  """
397
398
  # `_waited` raises rather than end: there is always a next message, or an error.
@@ -400,7 +401,7 @@ class Connection:
400
401
  )
401
402
  if message is None:
402
403
  error: str = f"{self.name} exited while answering `{method}`"
403
- raise HintError(error)
404
+ raise ExitedError(error)
404
405
  return message
405
406
 
406
407
  def _waited(self, method: str) -> _Object | None:
@@ -442,7 +443,7 @@ class Connection:
442
443
  def _answer(self, message: _Object) -> None:
443
444
  """Answer one of the server's own requests: its settings, and nothing else it asks for."""
444
445
  method: str = str(message[_METHOD])
445
- reply: _Object = {"jsonrpc": "2.0", _ID: message[_ID]}
446
+ reply: _Object = {_ID: message[_ID]}
446
447
  if method == _CONFIGURATION:
447
448
  items: list[_Json] = cast("list[_Json]", cast("_Object", message["params"])["items"])
448
449
  reply["result"] = [protocol.settings(cast("_Object", item).get("section")) for item in items]
@@ -454,22 +455,18 @@ class Connection:
454
455
 
455
456
  def notify(self, method: str, params: _Json) -> None:
456
457
  """Send a notification: no answer comes."""
457
- self._send({"jsonrpc": "2.0", "method": method, "params": params})
458
+ self._send({"method": method, "params": params})
458
459
 
459
460
  def _send(self, message: _Object) -> None:
460
- """Frame and send one message.
461
+ """Frame and send one message (see `protocol.write`).
461
462
 
462
463
  Raises:
463
- HintError: The server has exited.
464
+ ExitedError: The server has exited.
464
465
 
465
466
  """
466
- body: bytes = json.dumps(message).encode()
467
467
  with self.writing: # one message at a time: the reader answers the server's own meanwhile
468
468
  try:
469
- protocol.write(
470
- cast("IO[bytes]", self.process.stdin),
471
- f"Content-Length: {len(body)}\r\n\r\n".encode() + body,
472
- )
469
+ protocol.write(cast("IO[bytes]", self.process.stdin), message)
473
470
  except (OSError, ValueError) as error: # it has gone, or its input was closed to stop it
474
471
  failure: str = f"{self.name} exited: {error}"
475
- raise HintError(failure) from error
472
+ raise ExitedError(failure) from error
@@ -1,4 +1,3 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """`--infer-with`: what a hint's own edits would write (see `Offered`).
3
2
 
4
3
  An editor that accepts an inlay hint applies its `textEdits`: the annotation, spelled as the file can
@@ -20,7 +19,7 @@ from urllib.request import url2pathname
20
19
  from constricter.cli.lsp.protocol import Json, Object
21
20
  from constricter.fix.core.known import Offered
22
21
  from constricter.fix.index.modules import SUFFIX, module_name
23
- from constricter.rules import parsed
22
+ from constricter.source import parsed
24
23
 
25
24
  _Where: TypeAlias = tuple[int, int] # a line (from 1) and a UTF-8 byte column, as `ast` counts
26
25
  _Locate: TypeAlias = Callable[[Object], _Where | None] # a position's, or `None` past the file's end
@@ -1,7 +1,6 @@
1
- # SPDX-License-Identifier: MIT
2
1
  """Run a type checker's language server for `--infer-with`, so it can't outlive constricter.
3
2
 
4
- python .../constricter/cli/guard.py GRACE EXECUTABLE [ARGUMENT ...]
3
+ python .../constricter/cli/lsp/guard.py GRACE EXECUTABLE [ARGUMENT ...]
5
4
 
6
5
  It imports only the standard library, so it runs by its path whatever the Python can import.
7
6
 
@@ -66,20 +65,23 @@ def main(argv: Sequence[str], stdin: IO[bytes] | None = None, stdout: IO[bytes]
66
65
 
67
66
  def _pass_on(source: IO[bytes], target: IO[bytes]) -> None:
68
67
  """Pass the server's output on until it ends (or constricter's end of it has gone)."""
69
- chunk: bytes
70
68
  with contextlib.suppress(OSError):
71
- for chunk in iter(lambda: _read(source), b""):
72
- _write(target, chunk)
69
+ _copied(source, target)
70
+
71
+
72
+ def _copied(source: IO[bytes], target: IO[bytes]) -> None:
73
+ """Copy `source` to `target` until it ends, each chunk as it comes: the reader waits on it."""
74
+ chunk: bytes
75
+ for chunk in iter(lambda: _read(source), b""):
76
+ _write(target, chunk)
73
77
 
74
78
 
75
79
  def relay_to(source: IO[bytes], server: "subprocess.Popen[bytes]", grace: float) -> None:
76
80
  """Pass `source` on to the server's input; when it ends, end the server's, and give it `grace` seconds."""
77
81
  target: IO[bytes] = cast("IO[bytes]", server.stdin)
78
- chunk: bytes
79
82
  # The server has gone (or this is exiting, its input closed): nothing more to pass on.
80
83
  with contextlib.suppress(OSError, ValueError):
81
- for chunk in iter(lambda: _read(source), b""):
82
- _write(target, chunk)
84
+ _copied(source, target)
83
85
  with contextlib.suppress(OSError): # what's left for a server that has gone is dropped
84
86
  target.close()
85
87
  try: