python-constricter 0.4.1__cp311-cp311-win_amd64.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 (261) hide show
  1. constricter/__init__.py +55 -0
  2. constricter/__main__.py +26 -0
  3. constricter/_version.py +4 -0
  4. constricter/cli/__init__.py +2 -0
  5. constricter/cli/baseline.py +107 -0
  6. constricter/cli/collecting.py +54 -0
  7. constricter/cli/command.py +547 -0
  8. constricter/cli/config.py +322 -0
  9. constricter/cli/explain.py +104 -0
  10. constricter/cli/lsp/__init__.py +2 -0
  11. constricter/cli/lsp/connection.py +475 -0
  12. constricter/cli/lsp/edits.py +188 -0
  13. constricter/cli/lsp/guard.py +133 -0
  14. constricter/cli/lsp/hints.py +391 -0
  15. constricter/cli/lsp/protocol.py +192 -0
  16. constricter/cli/lsp/remembered.py +81 -0
  17. constricter/cli/lsp/stored.py +200 -0
  18. constricter/cli/options.py +799 -0
  19. constricter/cli/paths.py +151 -0
  20. constricter/cli/reading.py +36 -0
  21. constricter/cli/report.py +397 -0
  22. constricter/cli/rounds.py +328 -0
  23. constricter/cli/runs.py +90 -0
  24. constricter/cli/schedule.py +463 -0
  25. constricter/cli/traced.py +401 -0
  26. constricter/cli/workers.py +286 -0
  27. constricter/empty.py +66 -0
  28. constricter/fix/__init__.cp311-win_amd64.pyd +0 -0
  29. constricter/fix/__init__.py +2 -0
  30. constricter/fix/core/__init__.cp311-win_amd64.pyd +0 -0
  31. constricter/fix/core/__init__.py +2 -0
  32. constricter/fix/core/asked.cp311-win_amd64.pyd +0 -0
  33. constricter/fix/core/asked.py +56 -0
  34. constricter/fix/core/fixes.cp311-win_amd64.pyd +0 -0
  35. constricter/fix/core/fixes.py +317 -0
  36. constricter/fix/core/imports.cp311-win_amd64.pyd +0 -0
  37. constricter/fix/core/imports.py +580 -0
  38. constricter/fix/core/inherited.cp311-win_amd64.pyd +0 -0
  39. constricter/fix/core/inherited.py +311 -0
  40. constricter/fix/core/known.cp311-win_amd64.pyd +0 -0
  41. constricter/fix/core/known.py +730 -0
  42. constricter/fix/core/signatures.cp311-win_amd64.pyd +0 -0
  43. constricter/fix/core/signatures.py +132 -0
  44. constricter/fix/index/__init__.cp311-win_amd64.pyd +0 -0
  45. constricter/fix/index/__init__.py +2 -0
  46. constricter/fix/index/awaits.cp311-win_amd64.pyd +0 -0
  47. constricter/fix/index/awaits.py +90 -0
  48. constricter/fix/index/beyond.cp311-win_amd64.pyd +0 -0
  49. constricter/fix/index/beyond.py +149 -0
  50. constricter/fix/index/callers.cp311-win_amd64.pyd +0 -0
  51. constricter/fix/index/callers.py +155 -0
  52. constricter/fix/index/crossed.cp311-win_amd64.pyd +0 -0
  53. constricter/fix/index/crossed.py +327 -0
  54. constricter/fix/index/decorated.cp311-win_amd64.pyd +0 -0
  55. constricter/fix/index/decorated.py +140 -0
  56. constricter/fix/index/fixtures.cp311-win_amd64.pyd +0 -0
  57. constricter/fix/index/fixtures.py +176 -0
  58. constricter/fix/index/linked.cp311-win_amd64.pyd +0 -0
  59. constricter/fix/index/linked.py +142 -0
  60. constricter/fix/index/loose.cp311-win_amd64.pyd +0 -0
  61. constricter/fix/index/loose.py +103 -0
  62. constricter/fix/index/managed.cp311-win_amd64.pyd +0 -0
  63. constricter/fix/index/managed.py +63 -0
  64. constricter/fix/index/modules.cp311-win_amd64.pyd +0 -0
  65. constricter/fix/index/modules.py +741 -0
  66. constricter/fix/index/offers.cp311-win_amd64.pyd +0 -0
  67. constricter/fix/index/offers.py +252 -0
  68. constricter/fix/index/order.cp311-win_amd64.pyd +0 -0
  69. constricter/fix/index/order.py +155 -0
  70. constricter/fix/index/origins.cp311-win_amd64.pyd +0 -0
  71. constricter/fix/index/origins.py +238 -0
  72. constricter/fix/index/own_methods.cp311-win_amd64.pyd +0 -0
  73. constricter/fix/index/own_methods.py +91 -0
  74. constricter/fix/index/own_overloads.cp311-win_amd64.pyd +0 -0
  75. constricter/fix/index/own_overloads.py +70 -0
  76. constricter/fix/index/own_types.cp311-win_amd64.pyd +0 -0
  77. constricter/fix/index/own_types.py +330 -0
  78. constricter/fix/index/params.cp311-win_amd64.pyd +0 -0
  79. constricter/fix/index/params.py +81 -0
  80. constricter/fix/index/plain.cp311-win_amd64.pyd +0 -0
  81. constricter/fix/index/plain.py +221 -0
  82. constricter/fix/index/project.cp311-win_amd64.pyd +0 -0
  83. constricter/fix/index/project.py +797 -0
  84. constricter/fix/index/sides.cp311-win_amd64.pyd +0 -0
  85. constricter/fix/index/sides.py +160 -0
  86. constricter/fix/index/strings.cp311-win_amd64.pyd +0 -0
  87. constricter/fix/index/strings.py +46 -0
  88. constricter/fix/index/stubs/__init__.cp311-win_amd64.pyd +0 -0
  89. constricter/fix/index/stubs/__init__.py +2 -0
  90. constricter/fix/index/stubs/atoms.cp311-win_amd64.pyd +0 -0
  91. constricter/fix/index/stubs/atoms.py +425 -0
  92. constricter/fix/index/stubs/chained.cp311-win_amd64.pyd +0 -0
  93. constricter/fix/index/stubs/chained.py +69 -0
  94. constricter/fix/index/stubs/classnames.cp311-win_amd64.pyd +0 -0
  95. constricter/fix/index/stubs/classnames.py +124 -0
  96. constricter/fix/index/stubs/declared.cp311-win_amd64.pyd +0 -0
  97. constricter/fix/index/stubs/declared.py +508 -0
  98. constricter/fix/index/stubs/installed.cp311-win_amd64.pyd +0 -0
  99. constricter/fix/index/stubs/installed.py +425 -0
  100. constricter/fix/index/stubs/names.cp311-win_amd64.pyd +0 -0
  101. constricter/fix/index/stubs/names.py +200 -0
  102. constricter/fix/index/stubs/read.cp311-win_amd64.pyd +0 -0
  103. constricter/fix/index/stubs/read.py +70 -0
  104. constricter/fix/index/stubs/signatures.cp311-win_amd64.pyd +0 -0
  105. constricter/fix/index/stubs/signatures.py +656 -0
  106. constricter/fix/index/stubs/stubbed.cp311-win_amd64.pyd +0 -0
  107. constricter/fix/index/stubs/stubbed.py +208 -0
  108. constricter/fix/index/taken.cp311-win_amd64.pyd +0 -0
  109. constricter/fix/index/taken.py +45 -0
  110. constricter/fix/index/tuples.cp311-win_amd64.pyd +0 -0
  111. constricter/fix/index/tuples.py +112 -0
  112. constricter/fix/libraries/__init__.cp311-win_amd64.pyd +0 -0
  113. constricter/fix/libraries/__init__.py +2 -0
  114. constricter/fix/libraries/library.cp311-win_amd64.pyd +0 -0
  115. constricter/fix/libraries/library.py +420 -0
  116. constricter/fix/libraries/opened.cp311-win_amd64.pyd +0 -0
  117. constricter/fix/libraries/opened.py +147 -0
  118. constricter/fix/libraries/overloads.cp311-win_amd64.pyd +0 -0
  119. constricter/fix/libraries/overloads.py +960 -0
  120. constricter/fix/libraries/receivers.cp311-win_amd64.pyd +0 -0
  121. constricter/fix/libraries/receivers.py +274 -0
  122. constricter/fix/libraries/stdlib.cp311-win_amd64.pyd +0 -0
  123. constricter/fix/libraries/stdlib.py +752 -0
  124. constricter/fix/libraries/walks.cp311-win_amd64.pyd +0 -0
  125. constricter/fix/libraries/walks.py +49 -0
  126. constricter/fix/tables/aliases.json +147 -0
  127. constricter/fix/tables/attributes.json +700 -0
  128. constricter/fix/tables/awaited.json +69 -0
  129. constricter/fix/tables/awaited_overloads.json +14 -0
  130. constricter/fix/tables/bases.json +888 -0
  131. constricter/fix/tables/classes.json +1941 -0
  132. constricter/fix/tables/elements.json +156 -0
  133. constricter/fix/tables/functions.json +58 -0
  134. constricter/fix/tables/generic_attributes.json +254 -0
  135. constricter/fix/tables/method_overloads.json +461 -0
  136. constricter/fix/tables/method_signatures.json +954 -0
  137. constricter/fix/tables/methods.json +954 -0
  138. constricter/fix/tables/overloads.json +540 -0
  139. constricter/fix/tables/platform.json +37 -0
  140. constricter/fix/tables/returns.json +1362 -0
  141. constricter/fix/tables/scalar_members.json +16 -0
  142. constricter/fix/tables/scalars.json +2707 -0
  143. constricter/fix/tables/source.json +28 -0
  144. constricter/fix/tables/subscriptable.json +77 -0
  145. constricter/fix/tables/type_parameters.json +178 -0
  146. constricter/fix/tables/variables.json +2962 -0
  147. constricter/fix/values/__init__.cp311-win_amd64.pyd +0 -0
  148. constricter/fix/values/__init__.py +2 -0
  149. constricter/fix/values/aliased.cp311-win_amd64.pyd +0 -0
  150. constricter/fix/values/aliased.py +195 -0
  151. constricter/fix/values/bodies.cp311-win_amd64.pyd +0 -0
  152. constricter/fix/values/bodies.py +76 -0
  153. constricter/fix/values/callables.cp311-win_amd64.pyd +0 -0
  154. constricter/fix/values/callables.py +323 -0
  155. constricter/fix/values/called.cp311-win_amd64.pyd +0 -0
  156. constricter/fix/values/called.py +194 -0
  157. constricter/fix/values/classvars.cp311-win_amd64.pyd +0 -0
  158. constricter/fix/values/classvars.py +629 -0
  159. constricter/fix/values/decided.cp311-win_amd64.pyd +0 -0
  160. constricter/fix/values/decided.py +429 -0
  161. constricter/fix/values/displays.cp311-win_amd64.pyd +0 -0
  162. constricter/fix/values/displays.py +101 -0
  163. constricter/fix/values/doubts.cp311-win_amd64.pyd +0 -0
  164. constricter/fix/values/doubts.py +677 -0
  165. constricter/fix/values/entered.cp311-win_amd64.pyd +0 -0
  166. constricter/fix/values/entered.py +332 -0
  167. constricter/fix/values/fills.cp311-win_amd64.pyd +0 -0
  168. constricter/fix/values/fills.py +446 -0
  169. constricter/fix/values/guesses.cp311-win_amd64.pyd +0 -0
  170. constricter/fix/values/guesses.py +376 -0
  171. constricter/fix/values/hinted.cp311-win_amd64.pyd +0 -0
  172. constricter/fix/values/hinted.py +811 -0
  173. constricter/fix/values/inference.cp311-win_amd64.pyd +0 -0
  174. constricter/fix/values/inference.py +698 -0
  175. constricter/fix/values/loops.cp311-win_amd64.pyd +0 -0
  176. constricter/fix/values/loops.py +335 -0
  177. constricter/fix/values/members.cp311-win_amd64.pyd +0 -0
  178. constricter/fix/values/members.py +578 -0
  179. constricter/fix/values/narrowed.cp311-win_amd64.pyd +0 -0
  180. constricter/fix/values/narrowed.py +220 -0
  181. constricter/fix/values/operated.cp311-win_amd64.pyd +0 -0
  182. constricter/fix/values/operated.py +202 -0
  183. constricter/fix/values/ordered.cp311-win_amd64.pyd +0 -0
  184. constricter/fix/values/ordered.py +74 -0
  185. constricter/fix/values/returned.cp311-win_amd64.pyd +0 -0
  186. constricter/fix/values/returned.py +479 -0
  187. constricter/fix/values/returns.cp311-win_amd64.pyd +0 -0
  188. constricter/fix/values/returns.py +237 -0
  189. constricter/fix/values/shapes.cp311-win_amd64.pyd +0 -0
  190. constricter/fix/values/shapes.py +654 -0
  191. constricter/fix/values/stored.cp311-win_amd64.pyd +0 -0
  192. constricter/fix/values/stored.py +564 -0
  193. constricter/fix/values/targets.cp311-win_amd64.pyd +0 -0
  194. constricter/fix/values/targets.py +419 -0
  195. constricter/fix/values/unchecked.cp311-win_amd64.pyd +0 -0
  196. constricter/fix/values/unchecked.py +278 -0
  197. constricter/jsonc.py +50 -0
  198. constricter/noqa.py +47 -0
  199. constricter/notebook.py +115 -0
  200. constricter/offences.py +436 -0
  201. constricter/overrides.py +24 -0
  202. constricter/plugins/__init__.py +2 -0
  203. constricter/plugins/flake8.py +133 -0
  204. constricter/plugins/pylint.py +186 -0
  205. constricter/py.typed +0 -0
  206. constricter/recording.py +660 -0
  207. constricter/rules/__init__.cp311-win_amd64.pyd +0 -0
  208. constricter/rules/__init__.py +2 -0
  209. constricter/rules/annotations.cp311-win_amd64.pyd +0 -0
  210. constricter/rules/annotations.py +296 -0
  211. constricter/rules/binding.cp311-win_amd64.pyd +0 -0
  212. constricter/rules/binding.py +652 -0
  213. constricter/rules/calls.cp311-win_amd64.pyd +0 -0
  214. constricter/rules/calls.py +460 -0
  215. constricter/rules/checker.cp311-win_amd64.pyd +0 -0
  216. constricter/rules/checker.py +536 -0
  217. constricter/rules/declarations.cp311-win_amd64.pyd +0 -0
  218. constricter/rules/declarations.py +730 -0
  219. constricter/rules/decorators.cp311-win_amd64.pyd +0 -0
  220. constricter/rules/decorators.py +351 -0
  221. constricter/rules/flow.cp311-win_amd64.pyd +0 -0
  222. constricter/rules/flow.py +502 -0
  223. constricter/rules/keys.cp311-win_amd64.pyd +0 -0
  224. constricter/rules/keys.py +79 -0
  225. constricter/rules/late.cp311-win_amd64.pyd +0 -0
  226. constricter/rules/late.py +969 -0
  227. constricter/rules/narrowing.cp311-win_amd64.pyd +0 -0
  228. constricter/rules/narrowing.py +98 -0
  229. constricter/rules/parsed.cp311-win_amd64.pyd +0 -0
  230. constricter/rules/parsed.py +136 -0
  231. constricter/rules/quoted.cp311-win_amd64.pyd +0 -0
  232. constricter/rules/quoted.py +141 -0
  233. constricter/rules/rebinding.cp311-win_amd64.pyd +0 -0
  234. constricter/rules/rebinding.py +169 -0
  235. constricter/rules/recorded.cp311-win_amd64.pyd +0 -0
  236. constricter/rules/recorded.py +168 -0
  237. constricter/rules/redundant.cp311-win_amd64.pyd +0 -0
  238. constricter/rules/redundant.py +160 -0
  239. constricter/rules/scope.cp311-win_amd64.pyd +0 -0
  240. constricter/rules/scope.py +802 -0
  241. constricter/rules/scoped.cp311-win_amd64.pyd +0 -0
  242. constricter/rules/scoped.py +232 -0
  243. constricter/rules/scoping.cp311-win_amd64.pyd +0 -0
  244. constricter/rules/scoping.py +346 -0
  245. constricter/rules/settings.cp311-win_amd64.pyd +0 -0
  246. constricter/rules/settings.py +189 -0
  247. constricter/rules/syntax.cp311-win_amd64.pyd +0 -0
  248. constricter/rules/syntax.py +472 -0
  249. constricter/rules/tables.cp311-win_amd64.pyd +0 -0
  250. constricter/rules/tables.py +169 -0
  251. constricter/rules/walked.cp311-win_amd64.pyd +0 -0
  252. constricter/rules/walked.py +127 -0
  253. constricter/rules/widened.cp311-win_amd64.pyd +0 -0
  254. constricter/rules/widened.py +322 -0
  255. constricter/trace.py +374 -0
  256. e45ce06b2fdd5c43f782__mypyc.cp311-win_amd64.pyd +0 -0
  257. python_constricter-0.4.1.dist-info/METADATA +400 -0
  258. python_constricter-0.4.1.dist-info/RECORD +261 -0
  259. python_constricter-0.4.1.dist-info/WHEEL +4 -0
  260. python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
  261. python_constricter-0.4.1.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Run a type checker's language server for `--infer-with`, so it can't outlive constricter.
3
+
4
+ python .../constricter/cli/guard.py GRACE EXECUTABLE [ARGUMENT ...]
5
+
6
+ It imports only the standard library, so it runs by its path whatever the Python can import.
7
+
8
+ The server's input and output both come through here, so that only this holds constricter's end of
9
+ them: if this is killed, constricter's reading ends at once, and the server's input and output are
10
+ gone. When constricter's input ends, constricter has closed it or is gone (however it went: killed
11
+ outright too, when it can't shut anything down itself), and the server is given GRACE seconds to
12
+ exit as it's been asked to, then killed. A server doesn't always notice on its own: one waiting for
13
+ an answer from constricter reads nothing more, and would wait forever. This exits with the
14
+ server's status.
15
+ """
16
+
17
+ import contextlib
18
+ import os
19
+ import signal
20
+ import subprocess # the language server
21
+ import sys
22
+ import threading
23
+ from collections.abc import Callable, Sequence
24
+ from pathlib import Path
25
+ from types import FrameType
26
+ from typing import IO, Final, cast
27
+
28
+ GRACE: Final = 5.0 # seconds a server has to exit once its input ends, by default
29
+ _CHUNK: Final = 1 << 16
30
+ # Windows' own `taskkill`, by its full path: never one earlier on `PATH`.
31
+ _TASKKILL: Final = str(Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32" / "taskkill.exe")
32
+
33
+
34
+ def main(argv: Sequence[str], stdin: IO[bytes] | None = None, stdout: IO[bytes] | None = None) -> int:
35
+ """Run the server (`argv`: the grace, then its command), between `stdin` and `stdout` (this process's).
36
+
37
+ Returns:
38
+ The server's exit status, once all its output is passed on.
39
+
40
+ """
41
+ grace: float = float(argv[0])
42
+ server: subprocess.Popen[bytes]
43
+ # Its own process group (POSIX), so it's killed with whatever it starts: a venv's
44
+ # `basedpyright-langserver` is a Python script that starts `node`.
45
+ with subprocess.Popen(
46
+ list(argv[1:]),
47
+ stdin=subprocess.PIPE,
48
+ stdout=subprocess.PIPE,
49
+ start_new_session=True,
50
+ ) as server:
51
+ relay: threading.Thread = threading.Thread(
52
+ target=relay_to,
53
+ args=(stdin or sys.stdin.buffer, server, grace),
54
+ daemon=True, # blocked reading a constricter that's still there: it goes when this does
55
+ )
56
+ relay.start()
57
+ output: threading.Thread = threading.Thread(
58
+ target=_pass_on,
59
+ args=(cast("IO[bytes]", server.stdout), stdout or sys.stdout.buffer),
60
+ )
61
+ output.start()
62
+ status: int = server.wait()
63
+ output.join()
64
+ return status
65
+
66
+
67
+ def _pass_on(source: IO[bytes], target: IO[bytes]) -> None:
68
+ """Pass the server's output on until it ends (or constricter's end of it has gone)."""
69
+ chunk: bytes
70
+ with contextlib.suppress(OSError):
71
+ for chunk in iter(lambda: _read(source), b""):
72
+ _write(target, chunk)
73
+
74
+
75
+ def relay_to(source: IO[bytes], server: "subprocess.Popen[bytes]", grace: float) -> None:
76
+ """Pass `source` on to the server's input; when it ends, end the server's, and give it `grace` seconds."""
77
+ target: IO[bytes] = cast("IO[bytes]", server.stdin)
78
+ chunk: bytes
79
+ # The server has gone (or this is exiting, its input closed): nothing more to pass on.
80
+ with contextlib.suppress(OSError, ValueError):
81
+ for chunk in iter(lambda: _read(source), b""):
82
+ _write(target, chunk)
83
+ with contextlib.suppress(OSError): # what's left for a server that has gone is dropped
84
+ target.close()
85
+ try:
86
+ _ = server.wait(timeout=grace)
87
+ except subprocess.TimeoutExpired:
88
+ kill(server)
89
+
90
+
91
+ def kill(server: "subprocess.Popen[bytes]") -> None:
92
+ """Kill the server and everything it started: its process group (POSIX), or its tree (Windows).
93
+
94
+ A Windows console script (`basedpyright-langserver.exe`) is a launcher that starts Python, which
95
+ starts `node`: `taskkill /T` ends them all, and if it can't be run, the server itself is killed.
96
+ """
97
+ killpg: Callable[[int, int], None] | None
98
+ if (killpg := getattr(os, "killpg", None)) is None:
99
+ with contextlib.suppress(OSError): # no `taskkill`: only the server itself goes
100
+ _ = subprocess.run(
101
+ [_TASKKILL, "/F", "/T", "/PID", str(server.pid)],
102
+ capture_output=True,
103
+ check=False,
104
+ )
105
+ server.kill()
106
+ return
107
+ with contextlib.suppress(ProcessLookupError): # it has gone, with all it started
108
+ killpg(server.pid, signal.SIGKILL)
109
+
110
+
111
+ def _write(target: IO[bytes], chunk: bytes) -> None:
112
+ """Write `chunk`, now."""
113
+ _ = target.write(chunk)
114
+ target.flush()
115
+
116
+
117
+ def _read(source: IO[bytes]) -> bytes:
118
+ """Read what's there of `source`, as soon as there's any.
119
+
120
+ Returns:
121
+ It, or nothing at its end.
122
+
123
+ """
124
+ return cast("IO[bytes]", getattr(source, "raw", source)).read(_CHUNK) or b""
125
+
126
+
127
+ if __name__ == "__main__":
128
+ # Interrupted at a terminal, the whole group is: constricter shuts the server down itself.
129
+ _: Callable[[int, FrameType | None], None] | int | signal.Handlers | None = signal.signal(
130
+ signal.SIGINT,
131
+ signal.SIG_IGN,
132
+ )
133
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,391 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-with`: ask a type checker's language server what type each untyped name has.
3
+
4
+ Each checker's servers are started once, over standard input and output, with the project root as
5
+ their workspace, and work at once (see `Session`); each file is opened with the text constricter
6
+ read, and its inlay hints asked for over the whole file. A variable-type hint (`: int`) sits just
7
+ after the name it types: that end is its key, as the line (from 1) and the UTF-8 byte column `ast`
8
+ gives the name's end. What the hint says is `constricter.fix.values.hinted`'s to judge: here it's only
9
+ text.
10
+
11
+ A server that can't be started, exits or answers an error stops the run: the option asked for its
12
+ types, and silently offering none would look like a checker that found nothing. One that hangs is
13
+ restarted and asked about each of its unanswered files alone; a file it hangs on again gets no hints
14
+ (`Session.abandoned`, which the command reports), and a checker that hangs on more than
15
+ `_MOST_ABANDONED` files stops the run. A round of files whose hints are kept from an earlier run,
16
+ nothing they rest on changed since, is read from the store instead (see `stored`).
17
+ """
18
+
19
+ import contextlib
20
+ import itertools
21
+ import os
22
+ import queue
23
+ from collections.abc import Callable, Mapping, Sequence
24
+ from concurrent.futures import Future, ThreadPoolExecutor
25
+ from functools import partial
26
+ from pathlib import Path
27
+ from typing import Final, Self, TypeAlias
28
+
29
+ from constricter.cli.lsp import protocol, remembered, stored
30
+ from constricter.cli.lsp.connection import Connection, FileHints, Found, Sent
31
+ from constricter.cli.lsp.protocol import SERVERS, HintError, HungError, Server
32
+ from constricter.fix.core.known import Hints
33
+
34
+ _Task: TypeAlias = Callable[[], Found] # one server's work: the hints of each file it's asked about
35
+
36
+
37
+ _BATCH: Final = 8 # files a free server takes at a time: few, so none is left with the slow ones
38
+ MEMORY: Final = 8 << 30 # the most a checker's servers use together, by default (`--infer-memory`)
39
+ _MEMINFO: Final = Path("/proc/meminfo")
40
+ _FILES_PER_SERVER: Final = 32 # fewer files than this each don't pay for another server's start
41
+ _MOST_ABANDONED: Final = 3 # files a checker may hang on twice before the run stops
42
+
43
+
44
+ class Session:
45
+ """The language servers of one or more type checkers, running for one command's files.
46
+
47
+ Each checker's files are shared among its servers (see `Checker`), each sent all its files'
48
+ requests at once, and every server works at the same time as the others. Raises `HintError`
49
+ (as `hints` does) if a checker isn't installed.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ checkers: Sequence[str],
55
+ root: Path,
56
+ servers: int = 1,
57
+ memory: int | None = None,
58
+ *,
59
+ kept: bool = True,
60
+ ) -> None:
61
+ """Get ready to start `checkers`' servers, with `root` as their workspace.
62
+
63
+ Each checker gets up to `servers`, within `memory` bytes (see `Checker`). With `kept`, a round's
64
+ hints are read from the store where it has them, and kept there where it doesn't.
65
+ """
66
+ self.checkers: list[Checker] = [Checker(checker, root, servers, memory) for checker in checkers]
67
+ self.kept: bool = kept
68
+
69
+ def __enter__(self) -> Self:
70
+ """Use the servers.
71
+
72
+ Returns:
73
+ The session.
74
+
75
+ """
76
+ return self
77
+
78
+ def __exit__(self, *_details: object) -> None:
79
+ """Shut every server down, at once."""
80
+ running: list[Connection]
81
+ if running := [server for checker in self.checkers for server in checker.servers]:
82
+ pool: ThreadPoolExecutor
83
+ with ThreadPoolExecutor(len(running)) as pool:
84
+ _ = list(pool.map(Connection.close, running))
85
+
86
+ def hints(self, files: Mapping[Path, str]) -> dict[Path, tuple[Hints, ...]]:
87
+ """Ask every checker for the variable-type hints in `files` (each path's current text).
88
+
89
+ Raises `HintError` if a server can't be started, or fails.
90
+
91
+ Returns:
92
+ Each file's hints, one per checker, in the order the checkers were named.
93
+
94
+ """
95
+ names: dict[str, str] = {}
96
+ found: dict[str, Found] = {}
97
+ checker: Checker
98
+ for checker in self.checkers if self.kept else ():
99
+ names[checker.name] = stored.key(checker.name, checker.command, checker.root, files)
100
+ read: tuple[Found, frozenset[Path]] | None
101
+ if (read := stored.load(names[checker.name], checker.root, files)) is not None:
102
+ found[checker.name] = read[0]
103
+ checker.abandoned |= read[1]
104
+ asking: list[Checker] = [checker for checker in self.checkers if checker.name not in found]
105
+ _start(files, asking)
106
+ work: list[tuple[Checker, _Task]] = [
107
+ (checker, task) for checker in asking for task in checker.tasks(files)
108
+ ]
109
+ found |= {checker.name: {path: {} for path in checker.abandoned & files.keys()} for checker in asking}
110
+ if work:
111
+ pool: ThreadPoolExecutor
112
+ with ThreadPoolExecutor(len(work)) as pool:
113
+ futures: list[Future[Found]] = [pool.submit(task) for _, task in work]
114
+ future: Future[Found]
115
+ for (checker, _), future in zip(work, futures, strict=True):
116
+ found[checker.name].update(future.result())
117
+ for checker in asking if self.kept else ():
118
+ stored.store(
119
+ names[checker.name],
120
+ checker.root,
121
+ found[checker.name],
122
+ frozenset(checker.abandoned & files.keys()),
123
+ )
124
+ return {
125
+ path: tuple(_hints(checker.name, found[checker.name][path]) for checker in self.checkers)
126
+ for path in files
127
+ }
128
+
129
+ @property
130
+ def abandoned(self) -> list[tuple[str, Path]]:
131
+ """List the files a checker's server hung on twice, which it has no hints for.
132
+
133
+ Returns:
134
+ Each checker's name and file, in the order the checkers were named.
135
+
136
+ """
137
+ return [(checker.name, path) for checker in self.checkers for path in sorted(checker.abandoned)]
138
+
139
+
140
+ def _start(files: Mapping[Path, str], checkers: Sequence["Checker"]) -> None:
141
+ """Start the servers `checkers` need for `files` that aren't running yet, all at once.
142
+
143
+ Raises `HintError` if one can't be started; those that could be are shut down with the session.
144
+ """
145
+ starting: list[Checker] = [
146
+ checker for checker in checkers for _ in range(checker.wanted(files) - len(checker.servers))
147
+ ]
148
+ if not starting:
149
+ return
150
+ pool: ThreadPoolExecutor
151
+ with ThreadPoolExecutor(len(starting)) as pool:
152
+ futures: list[Future[Connection]] = [
153
+ pool.submit(Connection, checker.command, checker.root) for checker in starting
154
+ ]
155
+ failures: list[HintError] = []
156
+ checker: Checker
157
+ future: Future[Connection]
158
+ for checker, future in zip(starting, futures, strict=True):
159
+ try:
160
+ checker.servers.append(future.result())
161
+ except HintError as failure:
162
+ failures.append(failure)
163
+ if failures:
164
+ raise failures[0]
165
+
166
+
167
+ def _hints(checker: str, found: FileHints) -> Hints:
168
+ """Gather one file's hints from `checker` as `--fix` reads them.
169
+
170
+ Returns:
171
+ Them.
172
+
173
+ """
174
+ return Hints(
175
+ checker,
176
+ {where: text for where, (text, _) in found.items()},
177
+ {where: offered for where, (_, offered) in found.items() if offered is not None},
178
+ )
179
+
180
+
181
+ class Checker:
182
+ """One checker's servers, and which of them has each file.
183
+
184
+ It has as many as its files need (one per `_FILES_PER_SERVER`), up to the command's `--jobs`, the
185
+ checker's own most (`Server.most`), and as many as fit in its memory: `--infer-memory` (never
186
+ more than is available), or by default `MEMORY` (never more than half what's available). Each
187
+ server loads the whole program it checks, or its share of it, by its checker's own measure (see
188
+ `Server.fitting`). One server it always has, whatever the memory.
189
+
190
+ The files are handed out a few at a time (`_BATCH`), the biggest first, to whichever server is
191
+ free: one file's analysis can take far longer than another's, and none waits on a busy one. A
192
+ file stays with the server that took it, which has it open, however many times it's asked about.
193
+ """
194
+
195
+ def __init__(self, name: str, root: Path, servers: int, memory: int | None = None) -> None:
196
+ """Get ready to start checker `name`'s servers, with `root` as their workspace, in `memory` bytes."""
197
+ self.name: str = name
198
+ self.root: Path = root
199
+ self.command: list[str] = command(name)
200
+ self.server: Server = SERVERS[name]
201
+ self.most: int = max(1, min(servers, self.server.most))
202
+ self.memory: int = budget(memory, available_memory())
203
+ self.servers: list[Connection] = []
204
+ self.assigned: dict[Path, int] = {} # each file's server, by its place in `servers`
205
+ self.abandoned: set[Path] = set() # the files a server hung on twice: never asked about again
206
+
207
+ def wanted(self, files: Mapping[Path, str]) -> int:
208
+ """Count the servers `files` want, as many as fit in memory (never fewer than it has).
209
+
210
+ Returns:
211
+ It.
212
+
213
+ """
214
+ size: int = sum(len(text) for text in files.values())
215
+ fit: int = max(1, self.server.fitting(self.memory, size))
216
+ return max(len(self.servers), min(self.most, -(-len(files) // _FILES_PER_SERVER), fit))
217
+
218
+ def tasks(self, files: Mapping[Path, str]) -> list[_Task]:
219
+ """Make each server's work on `files`: its own files first, then new ones as it's free for them.
220
+
221
+ A file abandoned (see `_alone`), in this run or an earlier one (see `remembered`), isn't
222
+ asked about again.
223
+
224
+ Returns:
225
+ A task per server, giving the hints of every file it was sent.
226
+
227
+ """
228
+ own: dict[int, dict[Path, str]] = {}
229
+ path: Path
230
+ for path in files.keys() & self.assigned.keys() - self.abandoned:
231
+ own.setdefault(self.assigned[path], {})[path] = files[path]
232
+ # One it hung on in an earlier run, unchanged since: left without hints from the start.
233
+ self.abandoned.update(
234
+ path
235
+ for path in files.keys() - self.assigned.keys() - self.abandoned
236
+ if remembered.hung(self.name, self.command, files[path], path)
237
+ )
238
+ fresh: list[Path] = sorted(
239
+ files.keys() - self.assigned.keys() - self.abandoned,
240
+ key=lambda new: (-len(files[new]), new),
241
+ )
242
+ batches: queue.SimpleQueue[dict[Path, str]] = queue.SimpleQueue()
243
+ start: int
244
+ for start in range(0, len(fresh), _BATCH):
245
+ batch: list[Path] = fresh[start:][:_BATCH]
246
+ batches.put({path: files[path] for path in batch})
247
+ return [partial(self._work, index, own.get(index, {}), batches) for index in range(len(self.servers))]
248
+
249
+ def _work(
250
+ self,
251
+ index: int,
252
+ own: dict[Path, str],
253
+ batches: "queue.SimpleQueue[dict[Path, str]]",
254
+ ) -> Found:
255
+ """Ask server `index` about its own files, then take batches of new ones until there are none.
256
+
257
+ Returns:
258
+ Each file's hints.
259
+
260
+ """
261
+ found: Found = {}
262
+ pending: list[Sent] = []
263
+ batch: dict[Path, str]
264
+ # Two batches in flight: the next is asked before the last's answers are read, so the server
265
+ # always has work while they're read and the next batch is taken.
266
+ for batch in itertools.chain([own] if own else [], iter(partial(_taken, batches), None)):
267
+ pending.append((batch, self.servers[index].ask(batch)))
268
+ self.assigned.update(dict.fromkeys(batch, index)) # each file's taken once: no two write one
269
+ if len(pending) > 1:
270
+ found.update(self._answers(index, pending))
271
+ if pending:
272
+ found.update(self._answers(index, pending))
273
+ return found
274
+
275
+ def _answers(self, index: int, pending: list[Sent]) -> Found:
276
+ """Collect the answers to the first of `pending`'s batches from server `index`, and take it off.
277
+
278
+ If the server hangs, every pending file is asked about again alone (see `_alone`).
279
+
280
+ Returns:
281
+ Each file's hints: the batch's, or every pending file's.
282
+
283
+ """
284
+ try:
285
+ found: Found = self.servers[index].answers(pending[0][1])
286
+ except HungError:
287
+ unanswered: dict[Path, str] = {path: text for batch, _ in pending for path, text in batch.items()}
288
+ pending.clear()
289
+ return self._alone(index, unanswered)
290
+ del pending[0]
291
+ return found
292
+
293
+ def _alone(self, index: int, files: Mapping[Path, str]) -> Found:
294
+ """Restart server `index`, which hung, and ask it about each of `files` alone.
295
+
296
+ A file it hangs on again is abandoned, with no hints, and the server restarted for the rest:
297
+ one file's analysis needn't cost every other file's hints.
298
+
299
+ Returns:
300
+ Each file's hints.
301
+
302
+ Raises:
303
+ HintError: It hung on more than `_MOST_ABANDONED` files, or can't be restarted.
304
+
305
+ """
306
+ self._restart(index)
307
+ found: Found = {}
308
+ path: Path
309
+ text: str
310
+ for path, text in files.items():
311
+ server: Connection = self.servers[index]
312
+ try:
313
+ found.update(server.answers(server.ask({path: text})))
314
+ except HungError as hung:
315
+ self.abandoned.add(path)
316
+ remembered.remember(self.name, self.command, text, path)
317
+ if len(self.abandoned) > _MOST_ABANDONED:
318
+ error: str = f"{self.name} hung on {len(self.abandoned)} file(s), each asked about alone"
319
+ raise HintError(error) from hung
320
+ found[path] = {}
321
+ self._restart(index)
322
+ return found
323
+
324
+ def _restart(self, index: int) -> None:
325
+ """Stop server `index` and start another in its place, which opens each file it's asked about."""
326
+ self.servers[index].close(graceful=False)
327
+ self.servers[index] = Connection(self.command, self.root)
328
+
329
+
330
+ def _taken(batches: "queue.SimpleQueue[dict[Path, str]]") -> dict[Path, str] | None:
331
+ """Take the next batch of files, if there's one left.
332
+
333
+ Returns:
334
+ It, or `None`.
335
+
336
+ """
337
+ try:
338
+ return batches.get_nowait()
339
+ except queue.Empty:
340
+ return None
341
+
342
+
343
+ def budget(asked: int | None, available: int | None) -> int:
344
+ """Settle the memory a checker's servers may use: what's `asked`, or `MEMORY`, within what's `available`.
345
+
346
+ Returns:
347
+ It, in bytes: asked for, no more than is available; by default, no more than half of it
348
+ (none, where that can't be read: one server).
349
+
350
+ """
351
+ if asked is not None:
352
+ return asked if available is None else min(asked, available)
353
+ return min(MEMORY, (available or 0) // 2)
354
+
355
+
356
+ def available_memory() -> int | None:
357
+ """Find how much memory is free for more servers.
358
+
359
+ What the system says is available (Linux's `MemAvailable`), or else half of all it has.
360
+
361
+ Returns:
362
+ It, in bytes, or `None` where neither can be read (Windows): one server, then.
363
+
364
+ """
365
+ with contextlib.suppress(OSError, ValueError, StopIteration):
366
+ lines: list[str] = _MEMINFO.read_text(encoding="ascii").splitlines()
367
+ return 1024 * int(next(line for line in lines if line.startswith("MemAvailable:")).split()[1])
368
+ with contextlib.suppress(OSError, ValueError, AttributeError):
369
+ return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") // 2
370
+ return None
371
+
372
+
373
+ def command(checker: str) -> list[str]:
374
+ """Find `checker`'s language server: on `PATH`, or beside this Python (an unactivated venv).
375
+
376
+ Returns:
377
+ Its command line.
378
+
379
+ Raises:
380
+ HintError: It isn't installed.
381
+
382
+ """
383
+ server: Server = SERVERS[checker]
384
+ found: str | None
385
+ if (found := protocol.executable(checker)) is None:
386
+ install: str = f"`pip install {checker}`"
387
+ message: str = (
388
+ f"--infer-with={checker} needs `{server.executable}`, which isn't installed ({install})"
389
+ )
390
+ raise HintError(message)
391
+ return [found, *server.args]