python-constricter 0.4.3__py3-none-any.whl → 0.4.5__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 (145) hide show
  1. constricter/_version.py +1 -1
  2. constricter/cli/command.py +16 -7
  3. constricter/cli/config.py +7 -5
  4. constricter/cli/explain.py +8 -0
  5. constricter/cli/lsp/connection.py +5 -10
  6. constricter/cli/lsp/edits.py +1 -1
  7. constricter/cli/lsp/guard.py +10 -7
  8. constricter/cli/lsp/hints.py +23 -18
  9. constricter/cli/lsp/protocol.py +25 -14
  10. constricter/cli/lsp/remembered.py +9 -9
  11. constricter/cli/lsp/stored.py +6 -5
  12. constricter/cli/options.py +10 -5
  13. constricter/cli/reading.py +5 -0
  14. constricter/cli/report.py +14 -7
  15. constricter/cli/rounds.py +91 -22
  16. constricter/cli/runs.py +2 -1
  17. constricter/cli/schedule.py +28 -103
  18. constricter/cli/workers.py +10 -23
  19. constricter/fix/core/fixes.py +5 -1
  20. constricter/fix/core/imports.py +93 -56
  21. constricter/fix/core/inherited.py +4 -4
  22. constricter/fix/core/known.py +96 -35
  23. constricter/fix/core/signatures.py +2 -2
  24. constricter/fix/index/across.py +173 -0
  25. constricter/fix/index/annotated.py +1 -1
  26. constricter/fix/index/awaits.py +11 -11
  27. constricter/fix/index/beyond.py +5 -5
  28. constricter/fix/index/callers.py +37 -7
  29. constricter/fix/index/consistent.py +11 -20
  30. constricter/fix/index/crossed.py +144 -32
  31. constricter/fix/index/decorated.py +21 -8
  32. constricter/fix/index/fixtures.py +7 -5
  33. constricter/fix/index/linked.py +39 -34
  34. constricter/fix/index/loose.py +9 -9
  35. constricter/fix/index/managed.py +9 -9
  36. constricter/fix/index/modules.py +333 -156
  37. constricter/fix/index/offers.py +84 -16
  38. constricter/fix/index/order.py +6 -10
  39. constricter/fix/index/origins.py +33 -2
  40. constricter/fix/index/own_methods.py +59 -11
  41. constricter/fix/index/own_overloads.py +34 -10
  42. constricter/fix/index/own_stubs.py +95 -0
  43. constricter/fix/index/own_types.py +32 -14
  44. constricter/fix/index/params.py +6 -5
  45. constricter/fix/index/plain.py +139 -27
  46. constricter/fix/index/project.py +157 -703
  47. constricter/fix/index/sides.py +10 -74
  48. constricter/fix/index/spelling.py +702 -0
  49. constricter/fix/index/strings.py +1 -1
  50. constricter/fix/index/stubs/atoms.py +42 -26
  51. constricter/fix/index/stubs/chained.py +2 -2
  52. constricter/fix/index/stubs/classnames.py +9 -5
  53. constricter/fix/index/stubs/declared.py +57 -6
  54. constricter/fix/index/stubs/installed.py +253 -26
  55. constricter/fix/index/stubs/names.py +8 -10
  56. constricter/fix/index/stubs/read.py +6 -6
  57. constricter/fix/index/stubs/signatures.py +63 -21
  58. constricter/fix/index/stubs/stubbed.py +6 -9
  59. constricter/fix/index/taken.py +10 -6
  60. constricter/fix/index/tuples.py +7 -10
  61. constricter/fix/index/used.py +130 -0
  62. constricter/fix/libraries/library.py +10 -10
  63. constricter/fix/libraries/operators.py +185 -0
  64. constricter/fix/libraries/overloads.py +11 -175
  65. constricter/fix/libraries/receivers.py +2 -10
  66. constricter/fix/libraries/stdlib.py +62 -18
  67. constricter/fix/tables/attributes.json +151 -56
  68. constricter/fix/tables/awaited.json +1 -0
  69. constricter/fix/tables/classes.json +4 -0
  70. constricter/fix/tables/method_overloads.json +7 -6
  71. constricter/fix/tables/method_signatures.json +14 -0
  72. constricter/fix/tables/methods.json +68 -58
  73. constricter/fix/tables/returns.json +3 -0
  74. constricter/fix/tables/source.json +1 -1
  75. constricter/fix/values/aliased.py +8 -18
  76. constricter/fix/values/bodies.py +41 -4
  77. constricter/fix/values/callables.py +14 -21
  78. constricter/fix/values/called.py +20 -23
  79. constricter/fix/values/classvars.py +357 -46
  80. constricter/fix/values/decided.py +2 -2
  81. constricter/fix/values/documented.py +440 -0
  82. constricter/fix/values/doubts.py +85 -31
  83. constricter/fix/values/entered.py +12 -18
  84. constricter/fix/values/fills.py +20 -22
  85. constricter/fix/values/guesses.py +102 -31
  86. constricter/fix/values/hinted.py +7 -7
  87. constricter/fix/values/inference.py +133 -58
  88. constricter/fix/values/loops.py +3 -5
  89. constricter/fix/values/members.py +108 -35
  90. constricter/fix/values/narrowed.py +14 -12
  91. constricter/fix/values/ordered.py +1 -1
  92. constricter/fix/values/reads.py +72 -25
  93. constricter/fix/values/refused.py +3 -2
  94. constricter/fix/values/returned.py +153 -57
  95. constricter/fix/values/returns.py +135 -5
  96. constricter/fix/values/shapes.py +13 -14
  97. constricter/fix/values/stored.py +25 -23
  98. constricter/{rules → fix/values}/tables.py +67 -20
  99. constricter/fix/values/targets.py +18 -5
  100. constricter/fix/values/unchecked.py +2 -2
  101. constricter/notebook.py +2 -2
  102. constricter/offences.py +94 -44
  103. constricter/plugins/flake8.py +31 -47
  104. constricter/plugins/options.py +41 -0
  105. constricter/plugins/pylint.py +29 -63
  106. constricter/recording.py +63 -22
  107. constricter/rules/attributes.py +19 -4
  108. constricter/rules/binding.py +32 -24
  109. constricter/rules/calls.py +44 -16
  110. constricter/rules/checker.py +118 -32
  111. constricter/rules/late.py +20 -305
  112. constricter/rules/narrowing.py +3 -3
  113. constricter/rules/parameters.py +94 -0
  114. constricter/rules/rebinding.py +1 -1
  115. constricter/rules/rebound.py +296 -0
  116. constricter/rules/recorded.py +2 -2
  117. constricter/rules/redundant.py +2 -2
  118. constricter/rules/returning.py +49 -29
  119. constricter/rules/scope.py +54 -19
  120. constricter/rules/scoped.py +4 -4
  121. constricter/rules/scoping.py +13 -10
  122. constricter/rules/settings.py +45 -45
  123. constricter/rules/signatures.py +77 -0
  124. constricter/rules/widened.py +10 -7
  125. constricter/source/__init__.py +1 -0
  126. constricter/{rules → source}/annotations.py +34 -10
  127. constricter/source/concrete.py +247 -0
  128. constricter/{rules → source}/declarations.py +199 -100
  129. constricter/{rules → source}/decorators.py +11 -29
  130. constricter/{rules → source}/flow.py +1 -1
  131. constricter/source/inert.py +194 -0
  132. constricter/{rules → source}/keys.py +1 -1
  133. constricter/{rules → source}/parsed.py +1 -1
  134. constricter/{rules → source}/syntax.py +99 -28
  135. constricter/source/usable.py +63 -0
  136. constricter/trace.py +10 -7
  137. constricter/{cli/traced.py → traced.py} +86 -14
  138. {python_constricter-0.4.3.dist-info → python_constricter-0.4.5.dist-info}/METADATA +19 -8
  139. python_constricter-0.4.5.dist-info/RECORD +183 -0
  140. python_constricter-0.4.3.dist-info/RECORD +0 -169
  141. /constricter/{rules → source}/quoted.py +0 -0
  142. /constricter/{rules → source}/walked.py +0 -0
  143. {python_constricter-0.4.3.dist-info → python_constricter-0.4.5.dist-info}/WHEEL +0 -0
  144. {python_constricter-0.4.3.dist-info → python_constricter-0.4.5.dist-info}/entry_points.txt +0 -0
  145. {python_constricter-0.4.3.dist-info → python_constricter-0.4.5.dist-info}/licenses/LICENSE.md +0 -0
constricter/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """The version this was built as, from its git tag (written by the build, not tracked)."""
2
2
 
3
- __version__ = "0.4.3"
3
+ __version__ = "0.4.5"
@@ -17,7 +17,7 @@ from constricter.cli import baseline, collecting, schedule
17
17
  from constricter.cli.lsp.protocol import HintError
18
18
  from constricter.cli.options import Mode, Options, Output
19
19
  from constricter.cli.paths import STDIN, python_files
20
- from constricter.cli.reading import encoding_of, read_text
20
+ from constricter.cli.reading import READ_ERRORS, encoding_of, read_text
21
21
  from constricter.cli.report import Format, Result, fix_reasons, render, statistics
22
22
  from constricter.cli.rounds import checked_all, settled
23
23
  from constricter.cli.runs import Added, BaselineRun, CheckRun, CoverageRun, FileRun
@@ -31,8 +31,8 @@ from constricter.offences import (
31
31
  Edit,
32
32
  Offence,
33
33
  )
34
- from constricter.rules import parsed
35
34
  from constricter.rules.checker import Checked, Coverage, annotation_coverage, checked_source
35
+ from constricter.source import parsed
36
36
 
37
37
  if TYPE_CHECKING: # slow to import, and only needed for `--infer-with`
38
38
  from types import ModuleType
@@ -42,7 +42,6 @@ if TYPE_CHECKING: # slow to import, and only needed for `--infer-with`
42
42
  EXIT_CLEAN: Final = 0
43
43
  EXIT_FOUND: Final = 1
44
44
  EXIT_ERROR: Final = 2
45
- _ALL: Final = 100 # percent
46
45
 
47
46
 
48
47
  def _source(raw: str, name: Path) -> tuple[str, list[notebook.Line]]:
@@ -199,11 +198,21 @@ def _read_checked(
199
198
  checked: Checked
200
199
  try:
201
200
  raw, checked = _checked(path, name, checks, outside)
202
- except (OSError, ValueError, SyntaxError) as error: # UnicodeDecodeError is a ValueError
203
- return "", Checked([], Returns()), f"{name}: error: {error}"
201
+ except READ_ERRORS as error:
202
+ return "", Checked([], Returns()), _error(name, error)
204
203
  return raw, checked, ""
205
204
 
206
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
+
207
216
  def _results(raw: str, name: Path, offences: Sequence[Offence], options: Options) -> list[Result]:
208
217
  """Turn the offences in `raw`, the text of `name`, into the results to report.
209
218
 
@@ -326,8 +335,8 @@ def _cover_path(path: Path, _outside: Outside, options: Options) -> CoverageRun:
326
335
  name: Path = options.input.name(path)
327
336
  try:
328
337
  return CoverageRun(annotation_coverage(_source(read_text(path), name)[0], options.checks))
329
- except (OSError, ValueError, SyntaxError) as error:
330
- return CoverageRun(error=f"{name}: error: {error}")
338
+ except READ_ERRORS as error:
339
+ return CoverageRun(error=_error(name, error))
331
340
 
332
341
 
333
342
  def _check_all(options: Options) -> tuple[list[Path], list[FileRun]]:
constricter/cli/config.py CHANGED
@@ -220,11 +220,13 @@ def _checkers(value: _Toml) -> list[str] | None:
220
220
  """Read `infer-with`: a checker, or a list of them, each one it knows.
221
221
 
222
222
  Returns:
223
- 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.
224
225
 
225
226
  """
226
227
  checkers: list[str] | None = [value] if isinstance(value, str) else _strings(value)
227
- 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
228
230
 
229
231
 
230
232
  def _levels(value: _Toml) -> dict[str, str] | None:
@@ -247,7 +249,7 @@ def _lists(value: _Toml, read: Callable[[_Toml], list[str] | None]) -> dict[str,
247
249
  return None if None in lists.values() else {key: list(item or []) for key, item in lists.items()}
248
250
 
249
251
 
250
- def _baseline(value: _Toml, root: Path) -> str | None:
252
+ def _path(value: _Toml, root: Path) -> str | None:
251
253
  """Read a baseline's or a trace's path, relative to `root` (the pyproject.toml that names it).
252
254
 
253
255
  Returns:
@@ -302,8 +304,8 @@ def config_defaults(start: Path) -> dict[str, Default]:
302
304
  return {}
303
305
  readers: dict[str, Callable[[_Toml], Default | None]] = {
304
306
  **_READERS,
305
- "baseline": partial(_baseline, root=path.parent),
306
- "infer-from": partial(_baseline, root=path.parent),
307
+ "baseline": partial(_path, root=path.parent),
308
+ "infer-from": partial(_path, root=path.parent),
307
309
  }
308
310
  defaults: dict[str, Default] = {}
309
311
  key: str
@@ -15,6 +15,7 @@ from constricter.offences import (
15
15
  UNANNOTATED,
16
16
  UNANNOTATED_ATTRIBUTE,
17
17
  UNANNOTATED_MEMBER,
18
+ UNANNOTATED_PARAMETER,
18
19
  UNANNOTATED_RETURN,
19
20
  UNTYPED_TARGET,
20
21
  UNUSED_UNION_MEMBER,
@@ -95,6 +96,13 @@ _WHY: Final = {
95
96
  "returns nothing. Off unless selected by its full code (`--extend-select LVA014`): ruff's ANN201\n"
96
97
  "reports it too; this is for the fix."
97
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
+ ),
98
106
  }
99
107
 
100
108
 
@@ -5,7 +5,6 @@ The files it has open, the hint requests sent and not yet answered, and what it
5
5
 
6
6
  import contextlib
7
7
  import itertools
8
- import json
9
8
  import os
10
9
  import queue
11
10
  import re
@@ -329,7 +328,7 @@ class Connection:
329
328
 
330
329
  """
331
330
  self.last += 1
332
- self._send({"jsonrpc": "2.0", _ID: self.last, _METHOD: method, "params": params})
331
+ self._send({_ID: self.last, _METHOD: method, "params": params})
333
332
  return self.last
334
333
 
335
334
  def _collect(self, numbers: set[int], method: str) -> dict[int, _Json]:
@@ -444,7 +443,7 @@ class Connection:
444
443
  def _answer(self, message: _Object) -> None:
445
444
  """Answer one of the server's own requests: its settings, and nothing else it asks for."""
446
445
  method: str = str(message[_METHOD])
447
- reply: _Object = {"jsonrpc": "2.0", _ID: message[_ID]}
446
+ reply: _Object = {_ID: message[_ID]}
448
447
  if method == _CONFIGURATION:
449
448
  items: list[_Json] = cast("list[_Json]", cast("_Object", message["params"])["items"])
450
449
  reply["result"] = [protocol.settings(cast("_Object", item).get("section")) for item in items]
@@ -456,22 +455,18 @@ class Connection:
456
455
 
457
456
  def notify(self, method: str, params: _Json) -> None:
458
457
  """Send a notification: no answer comes."""
459
- self._send({"jsonrpc": "2.0", "method": method, "params": params})
458
+ self._send({"method": method, "params": params})
460
459
 
461
460
  def _send(self, message: _Object) -> None:
462
- """Frame and send one message.
461
+ """Frame and send one message (see `protocol.write`).
463
462
 
464
463
  Raises:
465
464
  ExitedError: The server has exited.
466
465
 
467
466
  """
468
- body: bytes = json.dumps(message).encode()
469
467
  with self.writing: # one message at a time: the reader answers the server's own meanwhile
470
468
  try:
471
- protocol.write(
472
- cast("IO[bytes]", self.process.stdin),
473
- f"Content-Length: {len(body)}\r\n\r\n".encode() + body,
474
- )
469
+ protocol.write(cast("IO[bytes]", self.process.stdin), message)
475
470
  except (OSError, ValueError) as error: # it has gone, or its input was closed to stop it
476
471
  failure: str = f"{self.name} exited: {error}"
477
472
  raise ExitedError(failure) from error
@@ -19,7 +19,7 @@ from urllib.request import url2pathname
19
19
  from constricter.cli.lsp.protocol import Json, Object
20
20
  from constricter.fix.core.known import Offered
21
21
  from constricter.fix.index.modules import SUFFIX, module_name
22
- from constricter.rules import parsed
22
+ from constricter.source import parsed
23
23
 
24
24
  _Where: TypeAlias = tuple[int, int] # a line (from 1) and a UTF-8 byte column, as `ast` counts
25
25
  _Locate: TypeAlias = Callable[[Object], _Where | None] # a position's, or `None` past the file's end
@@ -1,6 +1,6 @@
1
1
  """Run a type checker's language server for `--infer-with`, so it can't outlive constricter.
2
2
 
3
- python .../constricter/cli/guard.py GRACE EXECUTABLE [ARGUMENT ...]
3
+ python .../constricter/cli/lsp/guard.py GRACE EXECUTABLE [ARGUMENT ...]
4
4
 
5
5
  It imports only the standard library, so it runs by its path whatever the Python can import.
6
6
 
@@ -65,20 +65,23 @@ def main(argv: Sequence[str], stdin: IO[bytes] | None = None, stdout: IO[bytes]
65
65
 
66
66
  def _pass_on(source: IO[bytes], target: IO[bytes]) -> None:
67
67
  """Pass the server's output on until it ends (or constricter's end of it has gone)."""
68
- chunk: bytes
69
68
  with contextlib.suppress(OSError):
70
- for chunk in iter(lambda: _read(source), b""):
71
- _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)
72
77
 
73
78
 
74
79
  def relay_to(source: IO[bytes], server: "subprocess.Popen[bytes]", grace: float) -> None:
75
80
  """Pass `source` on to the server's input; when it ends, end the server's, and give it `grace` seconds."""
76
81
  target: IO[bytes] = cast("IO[bytes]", server.stdin)
77
- chunk: bytes
78
82
  # The server has gone (or this is exiting, its input closed): nothing more to pass on.
79
83
  with contextlib.suppress(OSError, ValueError):
80
- for chunk in iter(lambda: _read(source), b""):
81
- _write(target, chunk)
84
+ _copied(source, target)
82
85
  with contextlib.suppress(OSError): # what's left for a server that has gone is dropped
83
86
  target.close()
84
87
  try:
@@ -94,11 +94,7 @@ class Session:
94
94
 
95
95
  def __exit__(self, *_details: object) -> None:
96
96
  """Shut every server down, at once."""
97
- running: list[Connection]
98
- if running := [server for checker in self.checkers for server in checker.servers]:
99
- pool: ThreadPoolExecutor
100
- with ThreadPoolExecutor(len(running)) as pool:
101
- _ = list(pool.map(Connection.close, running))
97
+ _closed([server for checker in self.checkers for server in checker.servers])
102
98
 
103
99
  def hints(self, files: Mapping[Path, str]) -> dict[Path, tuple[Hints, ...]]:
104
100
  """Ask every checker for the variable-type hints in `files` (each path's current text).
@@ -306,7 +302,7 @@ class Checker: # pylint: disable=too-many-instance-attributes
306
302
  batches: queue.SimpleQueue[dict[Path, str]] = queue.SimpleQueue()
307
303
  start: int
308
304
  for start in range(0, len(fresh), _BATCH):
309
- batch: list[Path] = fresh[start:][:_BATCH]
305
+ batch: list[Path] = fresh[start : start + _BATCH]
310
306
  batches.put({path: files[path] for path in batch})
311
307
  return [partial(self._work, index, own.get(index, {}), batches) for index in range(len(self.servers))]
312
308
 
@@ -337,11 +333,7 @@ class Checker: # pylint: disable=too-many-instance-attributes
337
333
  except HintError as failure: # it has gone since its last answers: past its cap, say
338
334
  if not self._restartable(failure):
339
335
  raise
340
- unanswered: dict[Path, str] = {
341
- path: text for sent, _ in pending for path, text in sent.items()
342
- }
343
- pending.clear()
344
- found.update(self._alone(index, unanswered | batch))
336
+ found.update(self._requeued(index, pending, batch))
345
337
  continue
346
338
  if len(pending) > 1:
347
339
  found.update(self._answers(index, pending))
@@ -367,12 +359,21 @@ class Checker: # pylint: disable=too-many-instance-attributes
367
359
  except HintError as failure:
368
360
  if not self._restartable(failure):
369
361
  raise
370
- unanswered: dict[Path, str] = {path: text for batch, _ in pending for path, text in batch.items()}
371
- pending.clear()
372
- return self._alone(index, unanswered)
362
+ return self._requeued(index, pending, {})
373
363
  del pending[0]
374
364
  return found
375
365
 
366
+ def _requeued(self, index: int, pending: list[Sent], more: Mapping[Path, str]) -> Found:
367
+ """Ask server `index`, which failed, about each pending file and each of `more` alone (see `_alone`).
368
+
369
+ Returns:
370
+ Their hints.
371
+
372
+ """
373
+ unanswered: dict[Path, str] = {path: text for batch, _ in pending for path, text in batch.items()}
374
+ pending.clear()
375
+ return self._alone(index, unanswered | dict(more))
376
+
376
377
  def _alone(self, index: int, files: Mapping[Path, str]) -> Found:
377
378
  """Restart server `index`, which hung or exited past its cap, and ask it about each of `files` alone.
378
379
 
@@ -410,10 +411,7 @@ class Checker: # pylint: disable=too-many-instance-attributes
410
411
 
411
412
  def stop(self) -> None:
412
413
  """Shut its servers down, at once: the next round starts others, which open each file anew."""
413
- pool: ThreadPoolExecutor
414
- # None where its turn had no file to ask about (a round with none, or every one abandoned).
415
- with ThreadPoolExecutor(max(1, len(self.servers))) as pool:
416
- _ = list(pool.map(Connection.close, self.servers))
414
+ _closed(self.servers)
417
415
  self.servers.clear()
418
416
  self.assigned.clear()
419
417
 
@@ -496,3 +494,10 @@ def command(checker: str) -> list[str]:
496
494
  )
497
495
  raise HintError(message)
498
496
  return [found, *server.args]
497
+
498
+
499
+ def _closed(servers: Sequence[Connection]) -> None:
500
+ """Shut `servers` down, at once: none where a turn had no file to ask about."""
501
+ pool: ThreadPoolExecutor
502
+ with ThreadPoolExecutor(max(1, len(servers))) as pool:
503
+ _ = list(pool.map(Connection.close, servers))
@@ -15,6 +15,7 @@ Json: TypeAlias = dict[str, "Json"] | list["Json"] | str | int | float | bool |
15
15
  Object: TypeAlias = dict[str, Json]
16
16
  _HEADER_END: Final = b"\r\n"
17
17
  _LENGTH: Final = "content-length"
18
+ _JSONRPC: Final = "2.0" # the version every message names, first
18
19
  _TYPE_HINT: Final = 1 # `InlayHintKind.Type`
19
20
  # Only variable types: argument names and return types are hints too, but nothing here.
20
21
  _BASEDPYRIGHT_SECTION: Final = "basedpyright.analysis"
@@ -91,14 +92,7 @@ def executable(checker: str) -> str | None:
91
92
  Its path, or `None` if it isn't installed.
92
93
 
93
94
  """
94
- name: str = SERVERS[checker].executable
95
- found: list[str] = list(
96
- dict.fromkeys(
97
- path
98
- for path in (shutil.which(name), shutil.which(name, path=str(Path(sys.executable).parent)))
99
- if path is not None
100
- ),
101
- )
95
+ found: list[str] = _found(checker)
102
96
  return next((path for path in found if _runs(path)), found[0] if found else None)
103
97
 
104
98
 
@@ -106,11 +100,27 @@ def runs(checker: str) -> bool:
106
100
  """Check that `checker` is installed, and runs.
107
101
 
108
102
  Returns:
109
- Whether it does.
103
+ Whether it does: any of its servers found runs.
110
104
 
111
105
  """
112
- found: str | None = executable(checker)
113
- return found is not None and _runs(found)
106
+ return any(_runs(path) for path in _found(checker))
107
+
108
+
109
+ def _found(checker: str) -> list[str]:
110
+ """Find `checker`'s language servers: on `PATH`, then beside this Python.
111
+
112
+ Returns:
113
+ Their paths, each once.
114
+
115
+ """
116
+ name: str = SERVERS[checker].executable
117
+ return list(
118
+ dict.fromkeys(
119
+ path
120
+ for path in (shutil.which(name), shutil.which(name, path=str(Path(sys.executable).parent)))
121
+ if path is not None
122
+ ),
123
+ )
114
124
 
115
125
 
116
126
  def _runs(path: str) -> bool:
@@ -144,9 +154,10 @@ class ExitedError(HintError):
144
154
  """The type checker's server exited while it was being asked: past its heap's cap, say."""
145
155
 
146
156
 
147
- def write(stream: IO[bytes], data: bytes) -> None:
148
- """Write `data` to `stream`, and flush it: the server reads it now."""
149
- _ = stream.write(data)
157
+ def write(stream: IO[bytes], sent: Object) -> None:
158
+ """Frame `sent` (see `message`), write it to `stream`, and flush it: the server reads it now."""
159
+ body: bytes = json.dumps({"jsonrpc": _JSONRPC, **sent}).encode()
160
+ _ = stream.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
150
161
  stream.flush()
151
162
 
152
163
 
@@ -29,9 +29,7 @@ def _marker(checker: str, command: Sequence[str], named: str) -> Path:
29
29
  Its path in the cache.
30
30
 
31
31
  """
32
- made: str = ""
33
- with contextlib.suppress(OSError):
34
- made = build(checker, Path(command[0]))
32
+ made: str = build(checker, Path(command[0]))
35
33
  key: str = "\0".join((checker, command[0], made, named))
36
34
  return cache_directory().with_name("hung") / hashlib.sha256(key.encode("utf-8", "replace")).hexdigest()
37
35
 
@@ -43,15 +41,17 @@ def build(checker: str, executable: Path) -> str:
43
41
 
44
42
  Returns:
45
43
  The distribution's folder's name, which holds its version; else the executable's size and when
46
- it changed.
44
+ it changed; empty where neither can be read.
47
45
 
48
46
  """
49
47
  pattern: str
50
- for pattern in _DISTRIBUTIONS:
51
- found: list[Path]
52
- if found := sorted(executable.parent.parent.glob(f"{pattern}/{checker}-*.dist-info")):
53
- return found[0].name
54
- return f"{executable.stat().st_size}:{executable.stat().st_mtime_ns}"
48
+ with contextlib.suppress(OSError):
49
+ for pattern in _DISTRIBUTIONS:
50
+ found: list[Path]
51
+ if found := sorted(executable.parent.parent.glob(f"{pattern}/{checker}-*.dist-info")):
52
+ return found[0].name
53
+ return f"{executable.stat().st_size}:{executable.stat().st_mtime_ns}"
54
+ return ""
55
55
 
56
56
 
57
57
  def hung(checker: str, command: Sequence[str], text: str, path: Path | None = None) -> bool:
@@ -24,7 +24,7 @@ from collections.abc import Iterable, Mapping, Sequence
24
24
  from pathlib import Path
25
25
  from typing import Final, TypeAlias, TypedDict, cast
26
26
 
27
- from constricter.cli.lsp import connection, protocol, remembered
27
+ from constricter.cli.lsp import connection, edits, protocol, remembered
28
28
  from constricter.cli.lsp.connection import FileHints, Found
29
29
  from constricter.fix.core.known import Offered
30
30
  from constricter.fix.index.stubs.installed import cache_directory, search_path
@@ -56,11 +56,12 @@ def key(checker: str, command: Sequence[str], root: Path, files: Mapping[Path, s
56
56
  A digest of everything they rest on (see the module docstring).
57
57
 
58
58
  """
59
- build: str = ""
60
- with contextlib.suppress(OSError):
61
- build = remembered.build(checker, Path(command[0]))
59
+ build: str = remembered.build(checker, Path(command[0]))
62
60
  texts: list[str] = [checker, *command, build, *(os.environ.get(name, "") for name in _VARIABLES)]
63
- parts: list[bytes] = [Path(cast("str", code.__file__)).read_bytes() for code in (connection, protocol)]
61
+ # The code that asks and reads them, `edits` the one that writes what the store keeps.
62
+ parts: list[bytes] = [
63
+ Path(cast("str", code.__file__)).read_bytes() for code in (connection, protocol, edits)
64
+ ]
64
65
  asked: dict[str, str] = {_relative(path, root): text for path, text in files.items()}
65
66
  name: str
66
67
  for name in sorted(asked):
@@ -7,10 +7,10 @@ from collections.abc import Callable, Sequence
7
7
  from dataclasses import dataclass, field
8
8
  from enum import Enum
9
9
  from pathlib import Path
10
- from typing import Final, cast
10
+ from typing import Final, Self, cast
11
11
 
12
- from constricter import __version__
13
- from constricter.cli import baseline, traced
12
+ from constricter import __version__, traced
13
+ from constricter.cli import baseline
14
14
  from constricter.cli.config import (
15
15
  DEFAULT_BASELINE,
16
16
  config_defaults,
@@ -33,6 +33,7 @@ from constricter.offences import (
33
33
  OPT_IN,
34
34
  PLAIN_BASES,
35
35
  UNANNOTATED_ATTRIBUTE,
36
+ UNANNOTATED_PARAMETER,
36
37
  UNANNOTATED_RETURN,
37
38
  VAGUE,
38
39
  Checks,
@@ -637,7 +638,7 @@ class Options: # pylint: disable=too-many-instance-attributes
637
638
  infer_in_turn: bool = False # ask one checker at a time, not all at once (`--infer-in-turn`)
638
639
 
639
640
  @classmethod
640
- def parse(cls, argv: Sequence[str] | None) -> "Options":
641
+ def parse(cls, argv: Sequence[str] | None) -> Self:
641
642
  """Parse `argv` over the defaults `pyproject.toml` sets; `--explain` prints and exits.
642
643
 
643
644
  Returns:
@@ -679,6 +680,7 @@ class Options: # pylint: disable=too-many-instance-attributes
679
680
  final=CAN_BE_FINAL in {*_select(args), *cast("list[str]", args.extend_select)},
680
681
  attributes=UNANNOTATED_ATTRIBUTE in {*_select(args), *cast("list[str]", args.extend_select)},
681
682
  returns=UNANNOTATED_RETURN in {*_select(args), *cast("list[str]", args.extend_select)},
683
+ parameters=UNANNOTATED_PARAMETER in {*_select(args), *cast("list[str]", args.extend_select)},
682
684
  fixes=FixPolicy(
683
685
  frozenset(cast("list[str]", args.fix_select)),
684
686
  frozenset(cast("list[str]", args.fix_ignore)),
@@ -744,7 +746,10 @@ def _maximal(args: argparse.Namespace) -> argparse.Namespace:
744
746
  args.per_path_levels = {}
745
747
  args.all_scopes = True
746
748
  # LVA014 only selected by itself: it's ruff's ANN201 again, for its fix.
747
- args.extend_select = [*cast("list[str]", args.extend_select), *sorted(OPT_IN - {UNANNOTATED_RETURN})]
749
+ args.extend_select = [
750
+ *cast("list[str]", args.extend_select),
751
+ *sorted(OPT_IN - {UNANNOTATED_RETURN, UNANNOTATED_PARAMETER}),
752
+ ]
748
753
  return args
749
754
 
750
755
 
@@ -4,10 +4,15 @@ import io
4
4
  import sys
5
5
  import tokenize
6
6
  from pathlib import Path
7
+ from typing import Final
7
8
 
8
9
  from constricter import notebook
9
10
  from constricter.cli.paths import STDIN
10
11
 
12
+ # What reading or parsing a file raises: UnicodeDecodeError is a ValueError; a bad encoding
13
+ # declaration, a SyntaxError.
14
+ READ_ERRORS: Final = (OSError, ValueError, SyntaxError)
15
+
11
16
 
12
17
  def encoding_of(path: Path, data: bytes) -> str:
13
18
  """Find the encoding of `path`, whose bytes are `data`: a notebook's is UTF-8 (it's JSON).
constricter/cli/report.py CHANGED
@@ -166,12 +166,21 @@ def _sarif(results: Sequence[Result]) -> dict[str, _Json]:
166
166
  }
167
167
 
168
168
 
169
+ def _located(r: Result) -> str:
170
+ """Say where `r` is: its path, its cell in a notebook, its line and its column (from 1).
171
+
172
+ Returns:
173
+ It.
174
+
175
+ """
176
+ cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
177
+ return f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
178
+
179
+
169
180
  def _text(results: Sequence[Result]) -> Iterator[str]:
170
181
  r: Result
171
182
  for r in results:
172
- cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
173
- where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
174
- yield f"{where}: {r.severity}: {r.offence.code} {r.offence.message}"
183
+ yield f"{_located(r)}: {r.severity}: {r.offence.code} {r.offence.message}"
175
184
 
176
185
 
177
186
  def _full(results: Sequence[Result]) -> Iterator[str]:
@@ -186,8 +195,7 @@ def _full(results: Sequence[Result]) -> Iterator[str]:
186
195
  for r in results:
187
196
  yield from _text([r])
188
197
  source: str = r.source
189
- # `col` counts UTF-8 bytes, as `ast` does; the caret goes under that many characters.
190
- start: int = len(source.encode()[: r.offence.col].decode(errors="ignore"))
198
+ start: int = _column(r) # the caret goes under the name's first character
191
199
  width: int = len(r.offence.name) if source[start:].startswith(r.offence.name) else 1
192
200
  number: str = str(r.offence.line)
193
201
  gutter: str = " " * len(number)
@@ -374,8 +382,7 @@ def fix_reasons(results: Sequence[Result]) -> Iterator[str]:
374
382
  r: Result
375
383
  for r in results:
376
384
  if r.offence.fix is not None:
377
- cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
378
- where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
385
+ where: str = _located(r)
379
386
  flag: str = "--likely" if r.offence.likely else "--unsafe-fixes"
380
387
  guess: str = f" (a guess: {flag})" if r.offence.unsafe and not r.fixed else ""
381
388
  kinds: str = ", ".join(sorted(r.offence.edit.kinds if r.offence.edit else ()))