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.
- constricter/__init__.py +55 -0
- constricter/__main__.py +26 -0
- constricter/_version.py +4 -0
- constricter/cli/__init__.py +2 -0
- constricter/cli/baseline.py +107 -0
- constricter/cli/collecting.py +54 -0
- constricter/cli/command.py +547 -0
- constricter/cli/config.py +322 -0
- constricter/cli/explain.py +104 -0
- constricter/cli/lsp/__init__.py +2 -0
- constricter/cli/lsp/connection.py +475 -0
- constricter/cli/lsp/edits.py +188 -0
- constricter/cli/lsp/guard.py +133 -0
- constricter/cli/lsp/hints.py +391 -0
- constricter/cli/lsp/protocol.py +192 -0
- constricter/cli/lsp/remembered.py +81 -0
- constricter/cli/lsp/stored.py +200 -0
- constricter/cli/options.py +799 -0
- constricter/cli/paths.py +151 -0
- constricter/cli/reading.py +36 -0
- constricter/cli/report.py +397 -0
- constricter/cli/rounds.py +328 -0
- constricter/cli/runs.py +90 -0
- constricter/cli/schedule.py +463 -0
- constricter/cli/traced.py +401 -0
- constricter/cli/workers.py +286 -0
- constricter/empty.py +66 -0
- constricter/fix/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp311-win_amd64.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp311-win_amd64.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp311-win_amd64.pyd +0 -0
- constricter/fix/libraries/walks.py +49 -0
- constricter/fix/tables/aliases.json +147 -0
- constricter/fix/tables/attributes.json +700 -0
- constricter/fix/tables/awaited.json +69 -0
- constricter/fix/tables/awaited_overloads.json +14 -0
- constricter/fix/tables/bases.json +888 -0
- constricter/fix/tables/classes.json +1941 -0
- constricter/fix/tables/elements.json +156 -0
- constricter/fix/tables/functions.json +58 -0
- constricter/fix/tables/generic_attributes.json +254 -0
- constricter/fix/tables/method_overloads.json +461 -0
- constricter/fix/tables/method_signatures.json +954 -0
- constricter/fix/tables/methods.json +954 -0
- constricter/fix/tables/overloads.json +540 -0
- constricter/fix/tables/platform.json +37 -0
- constricter/fix/tables/returns.json +1362 -0
- constricter/fix/tables/scalar_members.json +16 -0
- constricter/fix/tables/scalars.json +2707 -0
- constricter/fix/tables/source.json +28 -0
- constricter/fix/tables/subscriptable.json +77 -0
- constricter/fix/tables/type_parameters.json +178 -0
- constricter/fix/tables/variables.json +2962 -0
- constricter/fix/values/__init__.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp311-win_amd64.pyd +0 -0
- constricter/fix/values/unchecked.py +278 -0
- constricter/jsonc.py +50 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +115 -0
- constricter/offences.py +436 -0
- constricter/overrides.py +24 -0
- constricter/plugins/__init__.py +2 -0
- constricter/plugins/flake8.py +133 -0
- constricter/plugins/pylint.py +186 -0
- constricter/py.typed +0 -0
- constricter/recording.py +660 -0
- constricter/rules/__init__.cp311-win_amd64.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp311-win_amd64.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp311-win_amd64.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp311-win_amd64.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp311-win_amd64.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp311-win_amd64.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp311-win_amd64.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp311-win_amd64.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp311-win_amd64.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp311-win_amd64.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp311-win_amd64.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp311-win_amd64.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp311-win_amd64.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp311-win_amd64.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp311-win_amd64.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp311-win_amd64.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp311-win_amd64.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp311-win_amd64.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp311-win_amd64.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp311-win_amd64.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp311-win_amd64.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp311-win_amd64.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp311-win_amd64.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp311-win_amd64.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp311-win_amd64.pyd +0 -0
- python_constricter-0.4.1.dist-info/METADATA +400 -0
- python_constricter-0.4.1.dist-info/RECORD +261 -0
- python_constricter-0.4.1.dist-info/WHEEL +4 -0
- python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
- 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]
|