python-constricter 0.4.1__cp314-cp314-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__.cp314-win_amd64.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp314-win_amd64.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp314-win_amd64.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp314-win_amd64.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp314-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__.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp314-win_amd64.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp314-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__.cp314-win_amd64.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp314-win_amd64.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp314-win_amd64.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp314-win_amd64.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp314-win_amd64.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp314-win_amd64.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp314-win_amd64.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp314-win_amd64.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp314-win_amd64.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp314-win_amd64.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp314-win_amd64.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp314-win_amd64.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp314-win_amd64.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp314-win_amd64.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp314-win_amd64.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp314-win_amd64.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp314-win_amd64.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp314-win_amd64.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp314-win_amd64.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp314-win_amd64.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp314-win_amd64.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp314-win_amd64.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp314-win_amd64.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp314-win_amd64.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp314-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,192 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The language server protocol's wire format, as `--infer-with`'s connections speak it (see `hints`).
|
|
3
|
+
|
|
4
|
+
Each message is framed by headers and a blank line, then `Content-Length` bytes of JSON. A hint's
|
|
5
|
+
type is its label after its `:`; basedpyright is asked for variable types only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess # asks a checker whether it runs
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Final, NamedTuple, TypeAlias, cast
|
|
14
|
+
|
|
15
|
+
Json: TypeAlias = dict[str, "Json"] | list["Json"] | str | int | float | bool | None
|
|
16
|
+
Object: TypeAlias = dict[str, Json]
|
|
17
|
+
_HEADER_END: Final = b"\r\n"
|
|
18
|
+
_LENGTH: Final = "content-length"
|
|
19
|
+
_TYPE_HINT: Final = 1 # `InlayHintKind.Type`
|
|
20
|
+
# Only variable types: argument names and return types are hints too, but nothing here.
|
|
21
|
+
_BASEDPYRIGHT_SECTION: Final = "basedpyright.analysis"
|
|
22
|
+
_BASEDPYRIGHT: Final[Object] = {
|
|
23
|
+
"inlayHints": {
|
|
24
|
+
"variableTypes": True,
|
|
25
|
+
"callArgumentNames": False,
|
|
26
|
+
"functionReturnTypes": False,
|
|
27
|
+
"genericTypes": False,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Server(NamedTuple):
|
|
33
|
+
"""A checker's language server: how it's started, and how many are worth running at once.
|
|
34
|
+
|
|
35
|
+
Its executable, the arguments that start it over stdio, and the most of it to run (up to
|
|
36
|
+
`MAX_SERVERS`). And the memory its servers take, as measured: `memory` bytes each before the
|
|
37
|
+
files, and `per_byte` more for each byte of them: each server's again where each holds the
|
|
38
|
+
`whole` program, once among them all where each holds what it's asked about.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
executable: str
|
|
42
|
+
args: tuple[str, ...]
|
|
43
|
+
most: int
|
|
44
|
+
memory: int
|
|
45
|
+
per_byte: int
|
|
46
|
+
whole: bool = False
|
|
47
|
+
|
|
48
|
+
def fitting(self, budget: int, size: int) -> int:
|
|
49
|
+
"""Count the servers that fit in `budget` bytes, for files of `size` bytes together.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
It: none where one doesn't fit.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
if self.whole:
|
|
56
|
+
return budget // (self.memory + self.per_byte * size)
|
|
57
|
+
return max(0, budget - self.per_byte * size) // self.memory
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
MAX_SERVERS: Final = 4 # a checker's, at most: each holds its own copy of the program it checks
|
|
61
|
+
SERVERS: Final = {
|
|
62
|
+
# One thread each: more servers check more at once (sqlalchemy's hints: 10.8s with one, 7.2s with four).
|
|
63
|
+
# Each holds the whole program: 1.2 GB for sqlalchemy's 8 MB, 3.4 for pandas's 21.
|
|
64
|
+
"basedpyright": Server("basedpyright-langserver", ("--stdio",), MAX_SERVERS, 600 << 20, 130, whole=True),
|
|
65
|
+
# Parallel itself, and still quicker shared out: a hinted check of pandas takes 36s with one, 25s
|
|
66
|
+
# with two, 22s with three and 21s with four (1.2 GB with one, 1.7 with two, 2.5 with four).
|
|
67
|
+
"ty": Server("ty", ("server",), MAX_SERVERS, 420 << 20, 38),
|
|
68
|
+
# Parallel too: pandas's takes 132s with one, 58s with two and 32s with four (2.8 GB, 3.2, 4.2);
|
|
69
|
+
# the standard library's 39 MB, 4.3 GB with one and 5.6 with four.
|
|
70
|
+
"pyrefly": Server("pyrefly", ("lsp",), MAX_SERVERS, 480 << 20, 120),
|
|
71
|
+
}
|
|
72
|
+
_PROBE: Final = "--version" # quick for each; basedpyright-langserver's exits 1 all the same
|
|
73
|
+
_PROBE_TIMEOUT: Final = 10.0 # seconds it may take
|
|
74
|
+
_CANT_RUN: Final = frozenset({126, 127}) # the shell's "can't execute" and "not found": a broken shim's
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def executable(checker: str) -> str | None:
|
|
78
|
+
"""Find `checker`'s language server: on `PATH`, or beside this Python (an unactivated venv).
|
|
79
|
+
|
|
80
|
+
The first that runs (a version manager's shim can be found, and not run), else the first found.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Its path, or `None` if it isn't installed.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
name: str = SERVERS[checker].executable
|
|
87
|
+
found: list[str] = list(
|
|
88
|
+
dict.fromkeys(
|
|
89
|
+
path
|
|
90
|
+
for path in (shutil.which(name), shutil.which(name, path=str(Path(sys.executable).parent)))
|
|
91
|
+
if path is not None
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return next((path for path in found if _runs(path)), found[0] if found else None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def runs(checker: str) -> bool:
|
|
98
|
+
"""Check that `checker` is installed, and runs.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Whether it does.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
found: str | None = executable(checker)
|
|
105
|
+
return found is not None and _runs(found)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _runs(path: str) -> bool:
|
|
109
|
+
"""Check that an executable runs: its `--version` starts, whatever it answers.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Whether it does.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
done: subprocess.CompletedProcess[bytes] = subprocess.run(
|
|
117
|
+
[path, _PROBE],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
timeout=_PROBE_TIMEOUT,
|
|
120
|
+
check=False,
|
|
121
|
+
)
|
|
122
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
123
|
+
return False
|
|
124
|
+
return done.returncode not in _CANT_RUN
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class HintError(Exception):
|
|
128
|
+
"""The type checker couldn't be started, or failed to answer."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class HungError(HintError):
|
|
132
|
+
"""The type checker's server said nothing for too long."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write(stream: IO[bytes], data: bytes) -> None:
|
|
136
|
+
"""Write `data` to `stream`, and flush it: the server reads it now."""
|
|
137
|
+
_ = stream.write(data)
|
|
138
|
+
stream.flush()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def settings(section: Json) -> Json:
|
|
142
|
+
"""Answer a `workspace/configuration` item: only basedpyright's inlay hints are set.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The section's settings, or `None` for the server's defaults.
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
return _BASEDPYRIGHT if section == _BASEDPYRIGHT_SECTION else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def label(hint: Object) -> str | None:
|
|
152
|
+
"""Read a variable-type hint's type: its label (a string, or parts joined) after its `:`.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The type's text, or `None` for any other hint (a parameter's name, a return type).
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
raw: Json = hint.get("label")
|
|
159
|
+
text: str = (
|
|
160
|
+
raw
|
|
161
|
+
if isinstance(raw, str)
|
|
162
|
+
else "".join(str(cast("Object", part).get("value", "")) for part in cast("list[Json]", raw or []))
|
|
163
|
+
)
|
|
164
|
+
if hint.get("kind", _TYPE_HINT) != _TYPE_HINT or not text.startswith(":"):
|
|
165
|
+
return None
|
|
166
|
+
return text[1:].strip() or None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def message(stream: IO[bytes]) -> Object | None:
|
|
170
|
+
"""Read one framed message: headers, a blank line, then `Content-Length` bytes of JSON.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
It, or `None` at the end of the stream (or a frame without a length).
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
headers: dict[str, str] = {}
|
|
177
|
+
line: bytes
|
|
178
|
+
for line in iter(stream.readline, b""):
|
|
179
|
+
if line == _HEADER_END:
|
|
180
|
+
break
|
|
181
|
+
name: str
|
|
182
|
+
_separator: str
|
|
183
|
+
value: str
|
|
184
|
+
name, _separator, value = line.decode("ascii").partition(":")
|
|
185
|
+
headers[name.strip().lower()] = value.strip()
|
|
186
|
+
if _LENGTH not in headers:
|
|
187
|
+
return None
|
|
188
|
+
body: bytes = stream.read(int(headers[_LENGTH]))
|
|
189
|
+
try:
|
|
190
|
+
return cast("Object", json.loads(body))
|
|
191
|
+
except ValueError: # cut off: the server died in the middle of it
|
|
192
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The files a type checker's server hung on, remembered between runs.
|
|
3
|
+
|
|
4
|
+
A server that hangs on a file costs the run its timeout twice, and a restart each time: once with
|
|
5
|
+
the file among others, once with it alone. The file's text, with the checker and its build (its
|
|
6
|
+
installed distribution's version, else its executable's size and when it changed), names an empty
|
|
7
|
+
file in the cache; while there's one, the file is left without hints from the start. So is the file
|
|
8
|
+
at that path for `_RECENT` seconds, whatever it holds: a second pass over files the first one fixed
|
|
9
|
+
sees the text it fixed. A new version of the checker, or a changed file after that, is asked again.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from constricter.fix.index.stubs.installed import cache_directory
|
|
20
|
+
|
|
21
|
+
_RECENT: Final = 3600.0 # seconds a hang is remembered by the file's path, as well as by its text
|
|
22
|
+
# Where an executable's environment keeps its distributions, from its `bin` (or `Scripts`) directory.
|
|
23
|
+
_DISTRIBUTIONS: Final = ("lib/python*/site-packages", "Lib/site-packages")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _marker(checker: str, command: Sequence[str], named: str) -> Path:
|
|
27
|
+
"""Name what remembers that `checker`, started by `command`, hung on a file `named` so.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Its path in the cache.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
made: str = ""
|
|
34
|
+
with contextlib.suppress(OSError):
|
|
35
|
+
made = build(checker, Path(command[0]))
|
|
36
|
+
key: str = "\0".join((checker, command[0], made, named))
|
|
37
|
+
return cache_directory().with_name("hung") / hashlib.sha256(key.encode("utf-8", "replace")).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build(checker: str, executable: Path) -> str:
|
|
41
|
+
"""Say which build of `checker` `executable` starts: its distribution's, where its environment has one.
|
|
42
|
+
|
|
43
|
+
Not the executable's own where there is: an install writes the script again for the same version.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The distribution's folder's name, which holds its version; else the executable's size and when
|
|
47
|
+
it changed.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
pattern: str
|
|
51
|
+
for pattern in _DISTRIBUTIONS:
|
|
52
|
+
found: list[Path]
|
|
53
|
+
if found := sorted(executable.parent.parent.glob(f"{pattern}/{checker}-*.dist-info")):
|
|
54
|
+
return found[0].name
|
|
55
|
+
return f"{executable.stat().st_size}:{executable.stat().st_mtime_ns}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def hung(checker: str, command: Sequence[str], text: str, path: Path | None = None) -> bool:
|
|
59
|
+
"""Check whether `checker` hung on a file holding `text` in an earlier run, or at `path` of late.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Whether it did.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
if _marker(checker, command, text).exists():
|
|
66
|
+
return True
|
|
67
|
+
if path is None:
|
|
68
|
+
return False
|
|
69
|
+
with contextlib.suppress(OSError):
|
|
70
|
+
return time.time() - _marker(checker, command, str(path.absolute())).stat().st_mtime < _RECENT
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def remember(checker: str, command: Sequence[str], text: str, path: Path | None = None) -> None:
|
|
75
|
+
"""Remember that `checker` hung on a file holding `text`, at `path`; not where the cache can't be."""
|
|
76
|
+
each: str
|
|
77
|
+
for each in (text,) if path is None else (text, str(path.absolute())):
|
|
78
|
+
marker: Path = _marker(checker, command, each)
|
|
79
|
+
with contextlib.suppress(OSError):
|
|
80
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
marker.touch()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""A type checker's hints for a round of files, kept between runs: asked for once while nothing changes.
|
|
3
|
+
|
|
4
|
+
A file's hints rest on the whole program the checker reads, so a round's are kept whole, by what
|
|
5
|
+
they rest on (`key`): the checker and its build (see `remembered.build`), the text of every file
|
|
6
|
+
asked about, the workspace's settings files (`pyproject.toml`, `pyrightconfig.json`, ...), the
|
|
7
|
+
installed distributions of the environments a checker may read (the interpreter's, `VIRTUAL_ENV`'s
|
|
8
|
+
and the workspace's own `.venv`), and the code here that asks and reads. Not a file of the
|
|
9
|
+
workspace's that isn't asked about: walking a tree costs as much as a round can save, and a
|
|
10
|
+
project's files are asked about together. The first round of a project at the
|
|
11
|
+
same commit asks again only when one of those changed; a later round's texts are `--fix`'s, so it's
|
|
12
|
+
kept as well, for the next run with the same fixes. A round read from the store keeps the files the
|
|
13
|
+
checker hung on (see `Session.abandoned`) as it found them.
|
|
14
|
+
|
|
15
|
+
Each entry is a file in the cache, read again within `_KEPT` seconds of its last use; older ones are
|
|
16
|
+
removed as a new one is written. A store that can't be read or written is passed over.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Final, TypeAlias, TypedDict, cast
|
|
27
|
+
|
|
28
|
+
from constricter.cli.lsp import connection, protocol, remembered
|
|
29
|
+
from constricter.cli.lsp.connection import FileHints, Found
|
|
30
|
+
from constricter.fix.core.known import Offered
|
|
31
|
+
from constricter.fix.index.stubs.installed import cache_directory, search_path
|
|
32
|
+
|
|
33
|
+
_KEPT: Final = 14 * 24 * 3600.0 # seconds an entry is kept unused
|
|
34
|
+
# A workspace's files that set how a checker reads it.
|
|
35
|
+
_SETTINGS: Final = frozenset(
|
|
36
|
+
{"pyproject.toml", "setup.cfg", "pyrightconfig.json", "ty.toml", "pyrefly.toml", "mypy.ini"},
|
|
37
|
+
)
|
|
38
|
+
_LOCAL_ENVIRONMENT: Final = ".venv"
|
|
39
|
+
_VARIABLES: Final = ("VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONPATH")
|
|
40
|
+
_SUFFIX: Final = ".json"
|
|
41
|
+
_WHERE: Final = ":" # between a hint's line and column, in an entry
|
|
42
|
+
# One file's hints as an entry holds them: by `line:column`, the text and the edits' text and imports.
|
|
43
|
+
_Entry: TypeAlias = dict[str, list[str | list[str] | None]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Round(TypedDict):
|
|
47
|
+
"""A round as the store holds it: each file's hints, and the files the checker hung on."""
|
|
48
|
+
|
|
49
|
+
found: dict[str, _Entry]
|
|
50
|
+
hung: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def key(checker: str, command: Sequence[str], root: Path, files: Mapping[Path, str]) -> str:
|
|
54
|
+
"""Name a round of `checker`'s hints for `files` (each path's current text), in workspace `root`.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A digest of everything they rest on (see the module docstring).
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
build: str = ""
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
build = remembered.build(checker, Path(command[0]))
|
|
63
|
+
texts: list[str] = [checker, *command, build, *(os.environ.get(name, "") for name in _VARIABLES)]
|
|
64
|
+
parts: list[bytes] = [Path(cast("str", code.__file__)).read_bytes() for code in (connection, protocol)]
|
|
65
|
+
asked: dict[str, str] = {_relative(path, root): text for path, text in files.items()}
|
|
66
|
+
name: str
|
|
67
|
+
for name in sorted(asked):
|
|
68
|
+
texts += [name, asked[name]]
|
|
69
|
+
for name in sorted(_SETTINGS):
|
|
70
|
+
with contextlib.suppress(OSError):
|
|
71
|
+
parts += [name.encode("utf-8"), (root / name).read_bytes()]
|
|
72
|
+
texts += _distributions(root)
|
|
73
|
+
parts += [each.encode("utf-8", "replace") for each in texts]
|
|
74
|
+
return hashlib.sha256(b"\0".join(f"{len(part)}:".encode() + part for part in parts)).hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load(name: str, root: Path, paths: Iterable[Path]) -> tuple[Found, frozenset[Path]] | None:
|
|
78
|
+
"""Read the round stored as `name` (see `key`) for `paths`, in workspace `root`; and mark it used.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Each path's hints and those the checker hung on; `None` if there's no such round, or it can't
|
|
82
|
+
be read.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
entry: Path = _entry(name)
|
|
86
|
+
named: dict[str, Path] = {_relative(path, root): path for path in paths}
|
|
87
|
+
read: tuple[Found, frozenset[Path]]
|
|
88
|
+
try:
|
|
89
|
+
read = _read(entry, named)
|
|
90
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
91
|
+
return None
|
|
92
|
+
with contextlib.suppress(OSError):
|
|
93
|
+
entry.touch()
|
|
94
|
+
return read
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def store(name: str, root: Path, found: Found, hung: frozenset[Path]) -> None:
|
|
98
|
+
"""Keep a round's hints as `name` (see `key`), and remove the entries no run has read of late."""
|
|
99
|
+
entry: Path = _entry(name)
|
|
100
|
+
kept: _Round = {
|
|
101
|
+
"found": {_relative(path, root): _entry_of(hints) for path, hints in found.items()},
|
|
102
|
+
"hung": sorted(_relative(path, root) for path in hung),
|
|
103
|
+
}
|
|
104
|
+
with contextlib.suppress(OSError):
|
|
105
|
+
entry.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
written: Path = entry.with_name(f"{entry.name}.{os.getpid()}")
|
|
107
|
+
_ = written.write_text(json.dumps(kept), encoding="utf-8")
|
|
108
|
+
_ = written.replace(entry) # whole, or not at all: another run may read it meanwhile
|
|
109
|
+
now: float = time.time()
|
|
110
|
+
old: Path
|
|
111
|
+
for old in entry.parent.glob(f"*{_SUFFIX}"):
|
|
112
|
+
with contextlib.suppress(OSError):
|
|
113
|
+
if now - old.stat().st_mtime > _KEPT:
|
|
114
|
+
old.unlink()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read(entry: Path, named: Mapping[str, Path]) -> tuple[Found, frozenset[Path]]:
|
|
118
|
+
"""Read a stored round's hints for the paths `named` by the store's names for them.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Each path's hints, and those the checker hung on.
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
kept: _Round = cast("_Round", json.loads(entry.read_text(encoding="utf-8")))
|
|
125
|
+
found: Found = {path: _hints(kept["found"].get(each, {})) for each, path in named.items()}
|
|
126
|
+
return found, frozenset(named[each] for each in kept["hung"])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _entry(name: str) -> Path:
|
|
130
|
+
"""Find where the round `name` is kept.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Its path in the cache.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
return cache_directory().with_name("hints") / f"{name}{_SUFFIX}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _relative(path: Path, root: Path) -> str:
|
|
140
|
+
"""Write `path` as the store keeps it: under `root`, relative to it.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
It, as text.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
absolute: Path = path if path.is_absolute() else Path.cwd() / path
|
|
147
|
+
with contextlib.suppress(ValueError):
|
|
148
|
+
return absolute.relative_to(root).as_posix()
|
|
149
|
+
return absolute.as_posix()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _distributions(root: Path) -> list[str]:
|
|
153
|
+
"""List the installed distributions a checker may read: each environment's, by folder and name.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Each `*.dist-info` folder's path, which holds its version, in a fixed order.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
folders: list[Path] = list(search_path())
|
|
160
|
+
local: Path = root / _LOCAL_ENVIRONMENT
|
|
161
|
+
folders += [*sorted(local.glob("lib/python3*/site-packages")), local / "Lib" / "site-packages"]
|
|
162
|
+
return [str(found) for folder in folders for found in sorted(folder.glob("*.dist-info"))]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _entry_of(hints: FileHints) -> _Entry:
|
|
166
|
+
"""Write one file's hints as an entry holds them.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Them, by `line:column`.
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
f"{line}{_WHERE}{column}": [
|
|
174
|
+
text,
|
|
175
|
+
None if offered is None else offered.text,
|
|
176
|
+
None if offered is None else list(offered.imports),
|
|
177
|
+
]
|
|
178
|
+
for (line, column), (text, offered) in hints.items()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _hints(entry: _Entry) -> FileHints:
|
|
183
|
+
"""Read one file's hints from its entry.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Them, by line and column.
|
|
187
|
+
|
|
188
|
+
"""
|
|
189
|
+
found: FileHints = {}
|
|
190
|
+
where: str
|
|
191
|
+
parts: list[str | list[str] | None]
|
|
192
|
+
for where, parts in entry.items():
|
|
193
|
+
line: str
|
|
194
|
+
column: str
|
|
195
|
+
line, _, column = where.partition(_WHERE)
|
|
196
|
+
text: str = cast("str", parts[0])
|
|
197
|
+
shown: str | None = cast("str | None", parts[1])
|
|
198
|
+
imports: list[str] = cast("list[str] | None", parts[2]) or []
|
|
199
|
+
found[int(line), int(column)] = (text, None if shown is None else Offered(shown, tuple(imports)))
|
|
200
|
+
return found
|