python-constricter 0.4.1__cp311-cp311-win32.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-win32.pyd +0 -0
  29. constricter/fix/__init__.py +2 -0
  30. constricter/fix/core/__init__.cp311-win32.pyd +0 -0
  31. constricter/fix/core/__init__.py +2 -0
  32. constricter/fix/core/asked.cp311-win32.pyd +0 -0
  33. constricter/fix/core/asked.py +56 -0
  34. constricter/fix/core/fixes.cp311-win32.pyd +0 -0
  35. constricter/fix/core/fixes.py +317 -0
  36. constricter/fix/core/imports.cp311-win32.pyd +0 -0
  37. constricter/fix/core/imports.py +580 -0
  38. constricter/fix/core/inherited.cp311-win32.pyd +0 -0
  39. constricter/fix/core/inherited.py +311 -0
  40. constricter/fix/core/known.cp311-win32.pyd +0 -0
  41. constricter/fix/core/known.py +730 -0
  42. constricter/fix/core/signatures.cp311-win32.pyd +0 -0
  43. constricter/fix/core/signatures.py +132 -0
  44. constricter/fix/index/__init__.cp311-win32.pyd +0 -0
  45. constricter/fix/index/__init__.py +2 -0
  46. constricter/fix/index/awaits.cp311-win32.pyd +0 -0
  47. constricter/fix/index/awaits.py +90 -0
  48. constricter/fix/index/beyond.cp311-win32.pyd +0 -0
  49. constricter/fix/index/beyond.py +149 -0
  50. constricter/fix/index/callers.cp311-win32.pyd +0 -0
  51. constricter/fix/index/callers.py +155 -0
  52. constricter/fix/index/crossed.cp311-win32.pyd +0 -0
  53. constricter/fix/index/crossed.py +327 -0
  54. constricter/fix/index/decorated.cp311-win32.pyd +0 -0
  55. constricter/fix/index/decorated.py +140 -0
  56. constricter/fix/index/fixtures.cp311-win32.pyd +0 -0
  57. constricter/fix/index/fixtures.py +176 -0
  58. constricter/fix/index/linked.cp311-win32.pyd +0 -0
  59. constricter/fix/index/linked.py +142 -0
  60. constricter/fix/index/loose.cp311-win32.pyd +0 -0
  61. constricter/fix/index/loose.py +103 -0
  62. constricter/fix/index/managed.cp311-win32.pyd +0 -0
  63. constricter/fix/index/managed.py +63 -0
  64. constricter/fix/index/modules.cp311-win32.pyd +0 -0
  65. constricter/fix/index/modules.py +741 -0
  66. constricter/fix/index/offers.cp311-win32.pyd +0 -0
  67. constricter/fix/index/offers.py +252 -0
  68. constricter/fix/index/order.cp311-win32.pyd +0 -0
  69. constricter/fix/index/order.py +155 -0
  70. constricter/fix/index/origins.cp311-win32.pyd +0 -0
  71. constricter/fix/index/origins.py +238 -0
  72. constricter/fix/index/own_methods.cp311-win32.pyd +0 -0
  73. constricter/fix/index/own_methods.py +91 -0
  74. constricter/fix/index/own_overloads.cp311-win32.pyd +0 -0
  75. constricter/fix/index/own_overloads.py +70 -0
  76. constricter/fix/index/own_types.cp311-win32.pyd +0 -0
  77. constricter/fix/index/own_types.py +330 -0
  78. constricter/fix/index/params.cp311-win32.pyd +0 -0
  79. constricter/fix/index/params.py +81 -0
  80. constricter/fix/index/plain.cp311-win32.pyd +0 -0
  81. constricter/fix/index/plain.py +221 -0
  82. constricter/fix/index/project.cp311-win32.pyd +0 -0
  83. constricter/fix/index/project.py +797 -0
  84. constricter/fix/index/sides.cp311-win32.pyd +0 -0
  85. constricter/fix/index/sides.py +160 -0
  86. constricter/fix/index/strings.cp311-win32.pyd +0 -0
  87. constricter/fix/index/strings.py +46 -0
  88. constricter/fix/index/stubs/__init__.cp311-win32.pyd +0 -0
  89. constricter/fix/index/stubs/__init__.py +2 -0
  90. constricter/fix/index/stubs/atoms.cp311-win32.pyd +0 -0
  91. constricter/fix/index/stubs/atoms.py +425 -0
  92. constricter/fix/index/stubs/chained.cp311-win32.pyd +0 -0
  93. constricter/fix/index/stubs/chained.py +69 -0
  94. constricter/fix/index/stubs/classnames.cp311-win32.pyd +0 -0
  95. constricter/fix/index/stubs/classnames.py +124 -0
  96. constricter/fix/index/stubs/declared.cp311-win32.pyd +0 -0
  97. constricter/fix/index/stubs/declared.py +508 -0
  98. constricter/fix/index/stubs/installed.cp311-win32.pyd +0 -0
  99. constricter/fix/index/stubs/installed.py +425 -0
  100. constricter/fix/index/stubs/names.cp311-win32.pyd +0 -0
  101. constricter/fix/index/stubs/names.py +200 -0
  102. constricter/fix/index/stubs/read.cp311-win32.pyd +0 -0
  103. constricter/fix/index/stubs/read.py +70 -0
  104. constricter/fix/index/stubs/signatures.cp311-win32.pyd +0 -0
  105. constricter/fix/index/stubs/signatures.py +656 -0
  106. constricter/fix/index/stubs/stubbed.cp311-win32.pyd +0 -0
  107. constricter/fix/index/stubs/stubbed.py +208 -0
  108. constricter/fix/index/taken.cp311-win32.pyd +0 -0
  109. constricter/fix/index/taken.py +45 -0
  110. constricter/fix/index/tuples.cp311-win32.pyd +0 -0
  111. constricter/fix/index/tuples.py +112 -0
  112. constricter/fix/libraries/__init__.cp311-win32.pyd +0 -0
  113. constricter/fix/libraries/__init__.py +2 -0
  114. constricter/fix/libraries/library.cp311-win32.pyd +0 -0
  115. constricter/fix/libraries/library.py +420 -0
  116. constricter/fix/libraries/opened.cp311-win32.pyd +0 -0
  117. constricter/fix/libraries/opened.py +147 -0
  118. constricter/fix/libraries/overloads.cp311-win32.pyd +0 -0
  119. constricter/fix/libraries/overloads.py +960 -0
  120. constricter/fix/libraries/receivers.cp311-win32.pyd +0 -0
  121. constricter/fix/libraries/receivers.py +274 -0
  122. constricter/fix/libraries/stdlib.cp311-win32.pyd +0 -0
  123. constricter/fix/libraries/stdlib.py +752 -0
  124. constricter/fix/libraries/walks.cp311-win32.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-win32.pyd +0 -0
  148. constricter/fix/values/__init__.py +2 -0
  149. constricter/fix/values/aliased.cp311-win32.pyd +0 -0
  150. constricter/fix/values/aliased.py +195 -0
  151. constricter/fix/values/bodies.cp311-win32.pyd +0 -0
  152. constricter/fix/values/bodies.py +76 -0
  153. constricter/fix/values/callables.cp311-win32.pyd +0 -0
  154. constricter/fix/values/callables.py +323 -0
  155. constricter/fix/values/called.cp311-win32.pyd +0 -0
  156. constricter/fix/values/called.py +194 -0
  157. constricter/fix/values/classvars.cp311-win32.pyd +0 -0
  158. constricter/fix/values/classvars.py +629 -0
  159. constricter/fix/values/decided.cp311-win32.pyd +0 -0
  160. constricter/fix/values/decided.py +429 -0
  161. constricter/fix/values/displays.cp311-win32.pyd +0 -0
  162. constricter/fix/values/displays.py +101 -0
  163. constricter/fix/values/doubts.cp311-win32.pyd +0 -0
  164. constricter/fix/values/doubts.py +677 -0
  165. constricter/fix/values/entered.cp311-win32.pyd +0 -0
  166. constricter/fix/values/entered.py +332 -0
  167. constricter/fix/values/fills.cp311-win32.pyd +0 -0
  168. constricter/fix/values/fills.py +446 -0
  169. constricter/fix/values/guesses.cp311-win32.pyd +0 -0
  170. constricter/fix/values/guesses.py +376 -0
  171. constricter/fix/values/hinted.cp311-win32.pyd +0 -0
  172. constricter/fix/values/hinted.py +811 -0
  173. constricter/fix/values/inference.cp311-win32.pyd +0 -0
  174. constricter/fix/values/inference.py +698 -0
  175. constricter/fix/values/loops.cp311-win32.pyd +0 -0
  176. constricter/fix/values/loops.py +335 -0
  177. constricter/fix/values/members.cp311-win32.pyd +0 -0
  178. constricter/fix/values/members.py +578 -0
  179. constricter/fix/values/narrowed.cp311-win32.pyd +0 -0
  180. constricter/fix/values/narrowed.py +220 -0
  181. constricter/fix/values/operated.cp311-win32.pyd +0 -0
  182. constricter/fix/values/operated.py +202 -0
  183. constricter/fix/values/ordered.cp311-win32.pyd +0 -0
  184. constricter/fix/values/ordered.py +74 -0
  185. constricter/fix/values/returned.cp311-win32.pyd +0 -0
  186. constricter/fix/values/returned.py +479 -0
  187. constricter/fix/values/returns.cp311-win32.pyd +0 -0
  188. constricter/fix/values/returns.py +237 -0
  189. constricter/fix/values/shapes.cp311-win32.pyd +0 -0
  190. constricter/fix/values/shapes.py +654 -0
  191. constricter/fix/values/stored.cp311-win32.pyd +0 -0
  192. constricter/fix/values/stored.py +564 -0
  193. constricter/fix/values/targets.cp311-win32.pyd +0 -0
  194. constricter/fix/values/targets.py +419 -0
  195. constricter/fix/values/unchecked.cp311-win32.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-win32.pyd +0 -0
  208. constricter/rules/__init__.py +2 -0
  209. constricter/rules/annotations.cp311-win32.pyd +0 -0
  210. constricter/rules/annotations.py +296 -0
  211. constricter/rules/binding.cp311-win32.pyd +0 -0
  212. constricter/rules/binding.py +652 -0
  213. constricter/rules/calls.cp311-win32.pyd +0 -0
  214. constricter/rules/calls.py +460 -0
  215. constricter/rules/checker.cp311-win32.pyd +0 -0
  216. constricter/rules/checker.py +536 -0
  217. constricter/rules/declarations.cp311-win32.pyd +0 -0
  218. constricter/rules/declarations.py +730 -0
  219. constricter/rules/decorators.cp311-win32.pyd +0 -0
  220. constricter/rules/decorators.py +351 -0
  221. constricter/rules/flow.cp311-win32.pyd +0 -0
  222. constricter/rules/flow.py +502 -0
  223. constricter/rules/keys.cp311-win32.pyd +0 -0
  224. constricter/rules/keys.py +79 -0
  225. constricter/rules/late.cp311-win32.pyd +0 -0
  226. constricter/rules/late.py +969 -0
  227. constricter/rules/narrowing.cp311-win32.pyd +0 -0
  228. constricter/rules/narrowing.py +98 -0
  229. constricter/rules/parsed.cp311-win32.pyd +0 -0
  230. constricter/rules/parsed.py +136 -0
  231. constricter/rules/quoted.cp311-win32.pyd +0 -0
  232. constricter/rules/quoted.py +141 -0
  233. constricter/rules/rebinding.cp311-win32.pyd +0 -0
  234. constricter/rules/rebinding.py +169 -0
  235. constricter/rules/recorded.cp311-win32.pyd +0 -0
  236. constricter/rules/recorded.py +168 -0
  237. constricter/rules/redundant.cp311-win32.pyd +0 -0
  238. constricter/rules/redundant.py +160 -0
  239. constricter/rules/scope.cp311-win32.pyd +0 -0
  240. constricter/rules/scope.py +802 -0
  241. constricter/rules/scoped.cp311-win32.pyd +0 -0
  242. constricter/rules/scoped.py +232 -0
  243. constricter/rules/scoping.cp311-win32.pyd +0 -0
  244. constricter/rules/scoping.py +346 -0
  245. constricter/rules/settings.cp311-win32.pyd +0 -0
  246. constricter/rules/settings.py +189 -0
  247. constricter/rules/syntax.cp311-win32.pyd +0 -0
  248. constricter/rules/syntax.py +472 -0
  249. constricter/rules/tables.cp311-win32.pyd +0 -0
  250. constricter/rules/tables.py +169 -0
  251. constricter/rules/walked.cp311-win32.pyd +0 -0
  252. constricter/rules/walked.py +127 -0
  253. constricter/rules/widened.cp311-win32.pyd +0 -0
  254. constricter/rules/widened.py +322 -0
  255. constricter/trace.py +374 -0
  256. e45ce06b2fdd5c43f782__mypyc.cp311-win32.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,475 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """A running language server, and the JSON-RPC conversation with it (`Connection`), for `hints`.
3
+
4
+ The files it has open, the hint requests sent and not yet answered, and what it has said.
5
+ """
6
+
7
+ import contextlib
8
+ import itertools
9
+ import json
10
+ import os
11
+ import queue
12
+ import re
13
+ import shutil
14
+ import subprocess # starts the type checker's language server
15
+ import sys
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable, Mapping, Sequence
19
+ from dataclasses import dataclass, field
20
+ from functools import partial
21
+ from pathlib import Path
22
+ from typing import IO, Final, NamedTuple, TypeAlias, cast
23
+
24
+ from constricter.cli.lsp import edits, guard, protocol
25
+ from constricter.cli.lsp.protocol import HintError, HungError
26
+ from constricter.fix.core.known import Offered
27
+
28
+ _Json: TypeAlias = protocol.Json
29
+ _Object: TypeAlias = protocol.Object
30
+ # A file's hints, by where each name ends: its text, and what its edits would write (see `edits`).
31
+ FileHints: TypeAlias = dict[tuple[int, int], tuple[str, Offered | None]]
32
+ Found: TypeAlias = dict[Path, FileHints] # each file's
33
+
34
+
35
+ # Seconds without a word from a server before it's taken for hung. Not an answer's wait: pandas'
36
+ # first answer took basedpyright 410s, but it reports its progress (`$/progress`) all the while.
37
+ _TIMEOUT: Final = 120.0
38
+ _POLL: Final = 1.0 # seconds between looks at how long it's been silent, while waiting
39
+
40
+
41
+ # The guard's interpreter: the one a venv's is made from, if it's one. A Windows venv's `python.exe`
42
+ # is a launcher that starts that one as a child, which then outlives a kill of the launcher.
43
+ _GUARD_PYTHON: Final = str(getattr(sys, "_base_executable", "") or sys.executable)
44
+ _SILENCE: Final[_Object] = {} # no message yet, while waiting for one (compared by identity)
45
+ _LINE_BREAK: Final = re.compile(r"\r\n|\r|\n") # the lines positions count, as the protocol has them
46
+ _UTF16: Final = "utf-16"
47
+ _UTF8: Final = "utf-8"
48
+ _METHOD_NOT_FOUND: Final = -32601
49
+ # A request the server dropped because a document changed under it, as later files open (ty's
50
+ # "content modified", pyrefly's "request cancelled"): the client is to ask again.
51
+ _DROPPED: Final = frozenset({-32801, -32800})
52
+ _ASK_AGAIN: Final = 5 # how many times a hint request is asked again after that, at most
53
+ _HINTS: Final = "textDocument/inlayHint"
54
+ _MODIFIED: Final[_Object] = {} # a hint request's answer, when it's to be asked again (by identity)
55
+ _ID: Final = "id"
56
+ _METHOD: Final = "method"
57
+ _ERROR: Final = "error"
58
+ _CONFIGURATION: Final = "workspace/configuration"
59
+ _UTF16_UNITS: Final = "utf-16-le" # UTF-16's code units, two bytes each, without a byte-order mark
60
+
61
+
62
+ @dataclass
63
+ class _Inbox:
64
+ """What a server has said: its messages still to read, when it last said anything, and early answers.
65
+
66
+ An early answer came while another request's were awaited (see `Connection._wanted`).
67
+ """
68
+
69
+ messages: queue.Queue[_Object | None] = field(default_factory=queue.Queue[_Object | None])
70
+ heard: float = field(default_factory=time.monotonic)
71
+ early: dict[int, _Object] = field(default_factory=dict[int, _Object])
72
+
73
+
74
+ @dataclass
75
+ class _Documents:
76
+ """The files a server has open: each one's version and text, as the server has them, by URI."""
77
+
78
+ versions: dict[str, int] = field(default_factory=dict[str, int])
79
+ texts: dict[str, str] = field(default_factory=dict[str, str])
80
+
81
+
82
+ class _Asked(NamedTuple):
83
+ """Hint requests sent, not yet answered: each request's file, and each file's lines."""
84
+
85
+ numbers: dict[int, Path]
86
+ lines: dict[Path, list[str]]
87
+
88
+
89
+ Sent: TypeAlias = tuple[dict[Path, str], _Asked] # a batch of files, and its hint requests
90
+
91
+
92
+ class Connection:
93
+ """A running language server, and the JSON-RPC conversation with it."""
94
+
95
+ def __init__(self, argv: Sequence[str], root: Path) -> None:
96
+ """Start the server (`argv`) and initialize it, with `root` as its one workspace folder.
97
+
98
+ Raises:
99
+ HintError: It can't be started, or fails to initialize.
100
+
101
+ """
102
+ self.name: str = Path(argv[0]).name
103
+ if shutil.which(argv[0]) is None:
104
+ message: str = f"{self.name} couldn't be started: {argv[0]} isn't a program"
105
+ raise HintError(message)
106
+ # Closing it closes its pipes and waits for it, as leaving a `with` would.
107
+ self.stack: contextlib.ExitStack = contextlib.ExitStack()
108
+ # Through `guard`, which stops the server when constricter stops, however it does. Run by
109
+ # its path, not as a module: it needs only the standard library, and this Python needn't be
110
+ # able to import constricter from the server's working directory.
111
+ self.process: subprocess.Popen[bytes] = self.stack.enter_context(
112
+ subprocess.Popen(
113
+ [_GUARD_PYTHON, guard.__file__, str(guard.GRACE), *argv],
114
+ stdin=subprocess.PIPE,
115
+ stdout=subprocess.PIPE,
116
+ stderr=subprocess.DEVNULL,
117
+ cwd=root,
118
+ ),
119
+ )
120
+ self.inbox: _Inbox = _Inbox()
121
+ self.writing: threading.Lock = threading.Lock() # the reader answers the server as it asks
122
+ self.last: int = 0
123
+ self.documents: _Documents = _Documents()
124
+ self.reader: threading.Thread = threading.Thread(
125
+ target=self._listen,
126
+ daemon=True,
127
+ )
128
+ self.reader.start()
129
+ self.encoding: str = _UTF16
130
+ try:
131
+ self._initialize(root)
132
+ except HintError:
133
+ self.close(graceful=False)
134
+ raise
135
+
136
+ def _initialize(self, root: Path) -> None:
137
+ """Initialize the server, with `root` as its workspace folder, and learn how it counts positions."""
138
+ uri: str = root.resolve().as_uri()
139
+ result: _Json = self.request(
140
+ "initialize",
141
+ {
142
+ "processId": os.getpid(),
143
+ "rootUri": uri,
144
+ "workspaceFolders": [{"uri": uri, "name": root.resolve().name}],
145
+ "capabilities": {
146
+ "general": {"positionEncodings": [_UTF8, _UTF16]},
147
+ "textDocument": {"inlayHint": {"dynamicRegistration": False}},
148
+ "workspace": {"configuration": True, "workspaceFolders": True},
149
+ # Its progress, as it works: a server busy for minutes isn't taken for hung.
150
+ "window": {"workDoneProgress": True},
151
+ },
152
+ },
153
+ )
154
+ capabilities: _Object = cast("_Object", cast("_Object", result)["capabilities"])
155
+ self.encoding = str(capabilities.get("positionEncoding") or _UTF16)
156
+ self.notify("initialized", {})
157
+
158
+ def close(self, *, graceful: bool = True) -> None:
159
+ """Shut the server down (or, not `graceful`, stop it); stop it anyway if it won't shut down.
160
+
161
+ Stopping it ends its input: its guard kills it if it doesn't exit then, and exits itself.
162
+ """
163
+ if not (graceful and self._stopped()):
164
+ with self.writing, contextlib.suppress(OSError): # it may have gone: then so has its input
165
+ cast("IO[bytes]", self.process.stdin).close()
166
+ try:
167
+ _ = self.process.wait(timeout=2 * guard.GRACE)
168
+ except subprocess.TimeoutExpired: # the guard itself is stuck: the server goes with its input
169
+ self.process.kill()
170
+ _ = self.process.wait()
171
+ self.reader.join() # its output has ended with it, and so has reading it
172
+ with contextlib.suppress(OSError): # what's left to write can't be: it has gone
173
+ self.stack.close()
174
+
175
+ def _stopped(self) -> bool:
176
+ """Ask the server to shut down and exit, and wait for it to.
177
+
178
+ Returns:
179
+ Whether it did.
180
+
181
+ """
182
+ with contextlib.suppress(HintError): # it's gone, or won't answer: waiting says which
183
+ _ = self.request("shutdown", None)
184
+ self.notify("exit", None)
185
+ try:
186
+ _ = self.process.wait(timeout=guard.GRACE)
187
+ except subprocess.TimeoutExpired:
188
+ return False
189
+ return True
190
+
191
+ def ask(self, files: Mapping[Path, str]) -> "_Asked":
192
+ """Open each file (or send its new text), and ask for its hints over the whole of it.
193
+
194
+ It doesn't wait for them. Each file is left open: basedpyright (1.40) fails the next file's
195
+ hints when that file imports one just closed, and the server is shut down after the last
196
+ file anyway.
197
+
198
+ Returns:
199
+ The requests, to collect their answers with `answers`.
200
+
201
+ """
202
+ lines: dict[Path, list[str]] = {path: _LINE_BREAK.split(text) for path, text in files.items()}
203
+ path: Path
204
+ text: str
205
+ for path, text in files.items():
206
+ self._opened(path.resolve().as_uri(), text)
207
+ return _Asked({self._ask_hints(path, lines[path]): path for path in files}, lines)
208
+
209
+ def _ask_hints(self, path: Path, lines: Sequence[str]) -> int:
210
+ """Ask for an open file's hints over the whole of it, without waiting for them.
211
+
212
+ Returns:
213
+ The request's number.
214
+
215
+ """
216
+ return self._ask(
217
+ _HINTS,
218
+ {
219
+ "textDocument": {"uri": path.resolve().as_uri()},
220
+ "range": {"start": {"line": 0, "character": 0}, "end": {"line": len(lines), "character": 0}},
221
+ },
222
+ )
223
+
224
+ def answers(self, asked: "_Asked") -> Found:
225
+ """Wait for the answers to hint requests `ask` sent, asking again for those the server dropped.
226
+
227
+ Returns:
228
+ Each file's variable-type hints' texts, by where the name each types ends.
229
+
230
+ Raises:
231
+ HintError: The server dropped one `_ASK_AGAIN` times more (see `_DROPPED`).
232
+
233
+ """
234
+ found: Found = {}
235
+ waiting: dict[int, Path] = asked.numbers
236
+ for _ in range(_ASK_AGAIN + 1):
237
+ answers: dict[int, _Json] = self._collect(set(waiting), _HINTS)
238
+ found.update(
239
+ (path, self._found(answers[number], asked.lines[path]))
240
+ for number, path in waiting.items()
241
+ if answers[number] is not _MODIFIED
242
+ )
243
+ waiting = {
244
+ self._ask_hints(path, asked.lines[path]): path
245
+ for number, path in waiting.items()
246
+ if answers[number] is _MODIFIED
247
+ }
248
+ if not waiting:
249
+ return found
250
+ error: str = f"{self.name} failed `{_HINTS}`: dropped {_ASK_AGAIN + 1} times"
251
+ raise HintError(error)
252
+
253
+ def _opened(self, uri: str, text: str) -> None:
254
+ """Send a file's text: opening it, or (open already) replacing its whole text, if it's changed."""
255
+ if self.documents.texts.get(uri) == text:
256
+ return
257
+ self.documents.texts[uri] = text
258
+ version: int
259
+ if (version := self.documents.versions.get(uri, 0) + 1) == 1:
260
+ self.notify(
261
+ "textDocument/didOpen",
262
+ {"textDocument": {"uri": uri, "languageId": "python", "version": version, "text": text}},
263
+ )
264
+ else:
265
+ self.notify(
266
+ "textDocument/didChange",
267
+ {"textDocument": {"uri": uri, "version": version}, "contentChanges": [{"text": text}]},
268
+ )
269
+ self.documents.versions[uri] = version
270
+
271
+ def _found(self, answer: _Json, lines: Sequence[str]) -> FileHints:
272
+ """Read one file's hints.
273
+
274
+ Returns:
275
+ Each variable-type hint's text and what its edits would write, by where the name it types ends.
276
+
277
+ """
278
+ found: FileHints = {}
279
+ existing: edits.FromImports = edits.FromImports(lines)
280
+ locate: Callable[[_Object], tuple[int, int] | None] = partial(self._where, lines=lines)
281
+ hint: _Json
282
+ for hint in cast("list[_Json]", answer or []):
283
+ where: tuple[int, int] | None
284
+ label: str | None
285
+ if (label := protocol.label(cast("_Object", hint))) is not None and (
286
+ where := locate(cast("_Object", cast("_Object", hint)["position"]))
287
+ ) is not None:
288
+ _ = found.setdefault(
289
+ where,
290
+ (label, edits.offered(cast("_Object", hint), label, locate, existing)),
291
+ )
292
+ return found
293
+
294
+ def _where(self, position: _Object, lines: Sequence[str]) -> tuple[int, int] | None:
295
+ """Turn a position into a line (from 1) and a UTF-8 byte column, as `ast` counts.
296
+
297
+ Returns:
298
+ It, or `None` for a position past the file's end.
299
+
300
+ """
301
+ line: int = int(cast("int", position["line"]))
302
+ character: int = int(cast("int", position["character"]))
303
+ if line >= len(lines):
304
+ return None
305
+ text: str = lines[line]
306
+ if self.encoding == _UTF8:
307
+ return line + 1, character
308
+ # The first `character` UTF-16 code units, two bytes each.
309
+ before: str = text.encode(_UTF16_UNITS)[: 2 * character].decode(_UTF16_UNITS, errors="ignore")
310
+ return line + 1, len(before.encode())
311
+
312
+ def request(self, method: str, params: _Json) -> _Json:
313
+ """Send a request and wait for its answer.
314
+
315
+ Returns:
316
+ Its result.
317
+
318
+ """
319
+ number: int = self._ask(method, params)
320
+ return self._collect({number}, method)[number]
321
+
322
+ def _ask(self, method: str, params: _Json) -> int:
323
+ """Send a request, without waiting for its answer.
324
+
325
+ Returns:
326
+ Its number, which its answer carries.
327
+
328
+ """
329
+ self.last += 1
330
+ self._send({"jsonrpc": "2.0", _ID: self.last, _METHOD: method, "params": params})
331
+ return self.last
332
+
333
+ def _collect(self, numbers: set[int], method: str) -> dict[int, _Json]:
334
+ """Wait for the answers to requests `numbers` (to `method`), in whatever order they come.
335
+
336
+ Raises `HintError` if the server exits, takes too long, or answers one with an error.
337
+
338
+ Returns:
339
+ Each request's result.
340
+
341
+ """
342
+ return dict(self._answer_to(numbers, method) for _ in numbers)
343
+
344
+ def _answer_to(self, numbers: set[int], method: str) -> tuple[int, _Json]:
345
+ """Wait for the next answer to one of requests `numbers`, passing over any other message.
346
+
347
+ Returns:
348
+ Its request's number, and its result.
349
+
350
+ Raises:
351
+ HintError: The server exited, took too long, or answered with an error.
352
+
353
+ """
354
+ early: set[int] = numbers & self.inbox.early.keys()
355
+ # `_next` raises rather than end: there is always a next message, or an error.
356
+ answer: _Object = (
357
+ self.inbox.early.pop(early.pop())
358
+ if early
359
+ else next(
360
+ message
361
+ for message in iter(partial(self._next, method), None)
362
+ if self._wanted(message, numbers)
363
+ )
364
+ )
365
+ if _ERROR in answer:
366
+ failure: _Object = cast("_Object", answer[_ERROR])
367
+ if method == _HINTS and failure.get("code") in _DROPPED:
368
+ return cast("int", answer[_ID]), _MODIFIED
369
+ error: str = f"{self.name} failed `{method}`: {failure.get('message')}"
370
+ raise HintError(error)
371
+ return cast("int", answer[_ID]), answer.get("result")
372
+
373
+ def _wanted(self, message: _Object, numbers: set[int]) -> bool:
374
+ """Check whether `message` answers one of requests `numbers`, keeping it if it answers another.
375
+
376
+ With a second batch asked before the first's answers are in, the second's can come first.
377
+
378
+ Returns:
379
+ Whether it answers one of `numbers`.
380
+
381
+ """
382
+ number: _Json = message.get(_ID)
383
+ if isinstance(number, int) and number not in numbers and number <= self.last:
384
+ self.inbox.early[number] = message
385
+ return number in numbers
386
+
387
+ def _next(self, method: str) -> _Object:
388
+ """Wait for the server's next message, while waiting for its answer to `method`.
389
+
390
+ Returns:
391
+ It.
392
+
393
+ Raises:
394
+ HintError: The server exited, or took too long.
395
+
396
+ """
397
+ # `_waited` raises rather than end: there is always a next message, or an error.
398
+ message: _Object | None = next(
399
+ waited for waited in (self._waited(method) for _ in itertools.count()) if waited is not _SILENCE
400
+ )
401
+ if message is None:
402
+ error: str = f"{self.name} exited while answering `{method}`"
403
+ raise HintError(error)
404
+ return message
405
+
406
+ def _waited(self, method: str) -> _Object | None:
407
+ """Wait a moment for the server's next message.
408
+
409
+ Returns:
410
+ It (`None` at the end of its output), or `_SILENCE` if none came yet.
411
+
412
+ Raises:
413
+ HungError: It has said nothing at all for `_TIMEOUT` seconds.
414
+
415
+ """
416
+ try:
417
+ return self.inbox.messages.get(timeout=_POLL)
418
+ except queue.Empty:
419
+ if time.monotonic() - self.inbox.heard >= _TIMEOUT:
420
+ error: str = f"{self.name} said nothing for {_TIMEOUT:.0f}s while answering `{method}`"
421
+ raise HungError(error) from None
422
+ return _SILENCE
423
+
424
+ def _listen(self) -> None:
425
+ """Read the server's messages until it closes its output (then queue `None`).
426
+
427
+ Its own requests are answered at once, here: a server may wait for the answer before it
428
+ reads anything more, while a batch of requests is still being written to it. The rest are
429
+ queued for the requests waiting on them.
430
+ """
431
+ stdout: IO[bytes] = cast("IO[bytes]", self.process.stdout)
432
+ message: _Object
433
+ for message in iter(partial(protocol.message, stdout), None):
434
+ self.inbox.heard = time.monotonic() # a word, of any kind: it's working
435
+ if _METHOD in message and _ID in message:
436
+ with contextlib.suppress(HintError): # it has gone: its output ends next
437
+ self._answer(message)
438
+ else:
439
+ self.inbox.messages.put(message)
440
+ self.inbox.messages.put(None)
441
+
442
+ def _answer(self, message: _Object) -> None:
443
+ """Answer one of the server's own requests: its settings, and nothing else it asks for."""
444
+ method: str = str(message[_METHOD])
445
+ reply: _Object = {"jsonrpc": "2.0", _ID: message[_ID]}
446
+ if method == _CONFIGURATION:
447
+ items: list[_Json] = cast("list[_Json]", cast("_Object", message["params"])["items"])
448
+ reply["result"] = [protocol.settings(cast("_Object", item).get("section")) for item in items]
449
+ elif method in {"client/registerCapability", "window/workDoneProgress/create"}:
450
+ reply["result"] = None
451
+ else:
452
+ reply[_ERROR] = {"code": _METHOD_NOT_FOUND, "message": f"not supported: {method}"}
453
+ self._send(reply)
454
+
455
+ def notify(self, method: str, params: _Json) -> None:
456
+ """Send a notification: no answer comes."""
457
+ self._send({"jsonrpc": "2.0", "method": method, "params": params})
458
+
459
+ def _send(self, message: _Object) -> None:
460
+ """Frame and send one message.
461
+
462
+ Raises:
463
+ HintError: The server has exited.
464
+
465
+ """
466
+ body: bytes = json.dumps(message).encode()
467
+ with self.writing: # one message at a time: the reader answers the server's own meanwhile
468
+ try:
469
+ protocol.write(
470
+ cast("IO[bytes]", self.process.stdin),
471
+ f"Content-Length: {len(body)}\r\n\r\n".encode() + body,
472
+ )
473
+ except (OSError, ValueError) as error: # it has gone, or its input was closed to stop it
474
+ failure: str = f"{self.name} exited: {error}"
475
+ raise HintError(failure) from error
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-with`: what a hint's own edits would write (see `Offered`).
3
+
4
+ An editor that accepts an inlay hint applies its `textEdits`: the annotation, spelled as the file can
5
+ write it, and the imports its names take. An import comes as a statement to add
6
+ (`from shapes import Shape`), or as names inserted into a `from` import the file has (`, Shape`).
7
+
8
+ A hint with no edits (pyrefly's, for a loop's or an unpacking's names) may say where each class in
9
+ its label is defined instead: the import is from the module that file is.
10
+ """
11
+
12
+ import ast
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Final, TypeAlias, cast
17
+ from urllib.parse import urlsplit
18
+ from urllib.request import url2pathname
19
+
20
+ from constricter.cli.lsp.protocol import Json, Object
21
+ from constricter.fix.core.known import Offered
22
+ from constricter.fix.index.modules import SUFFIX, module_name
23
+ from constricter.rules import parsed
24
+
25
+ _Where: TypeAlias = tuple[int, int] # a line (from 1) and a UTF-8 byte column, as `ast` counts
26
+ _Locate: TypeAlias = Callable[[Object], _Where | None] # a position's, or `None` past the file's end
27
+ _SITE: Final = "site-packages"
28
+ _BUNDLED: Final = "pyrefly_bundled_typeshed" # how the folders pyrefly unpacks its stubs into start
29
+ _STUBS: Final = "-stubs" # a stub package's folder, after the package's name
30
+ _PACKAGE: Final = "__init__"
31
+ _BUILTINS: Final = "builtins"
32
+
33
+
34
+ @dataclass
35
+ class FromImports:
36
+ """A file's `from` imports (`found`), read from its `lines` when one is first looked for."""
37
+
38
+ lines: Sequence[str]
39
+ found: list[ast.ImportFrom] | None = None
40
+
41
+ def at(self, where: _Where) -> ast.ImportFrom | None:
42
+ """Find the `from` import `where` is in.
43
+
44
+ Returns:
45
+ It, or `None` (always, for a file that doesn't parse).
46
+
47
+ """
48
+ if self.found is None:
49
+ self.found = _from_imports(self.lines)
50
+ return next(
51
+ (
52
+ node
53
+ for node in self.found
54
+ if (node.lineno, node.col_offset)
55
+ <= where
56
+ <= (node.end_lineno or node.lineno, node.end_col_offset or 0)
57
+ ),
58
+ None,
59
+ )
60
+
61
+
62
+ def _from_imports(lines: Sequence[str]) -> list[ast.ImportFrom]:
63
+ """Read a file's `from` imports, wherever they are.
64
+
65
+ Returns:
66
+ Them; none for a file that doesn't parse.
67
+
68
+ """
69
+ try:
70
+ tree: ast.Module = parsed.parse("\n".join(lines))
71
+ except (SyntaxError, ValueError): # a null byte is a `ValueError` before Python 3.12
72
+ return []
73
+ return [node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)]
74
+
75
+
76
+ def offered(hint: Object, label: str, locate: _Locate, existing: FromImports) -> Offered | None:
77
+ """Read what a variable-type hint (showing `label`) would write: its annotation and its imports.
78
+
79
+ Returns:
80
+ Them; `None` for a hint whose edits say no more than its label, one with an edit that is
81
+ neither, or one with no edits whose label doesn't say where a class in it is defined.
82
+
83
+ """
84
+ edits: list[Json]
85
+ if not (edits := cast("list[Json]", hint.get("textEdits") or [])):
86
+ located: tuple[str, ...] = _located(hint.get("label"))
87
+ return Offered(label, located) if located else None
88
+ text: str = ""
89
+ imports: list[str] = []
90
+ edit: Json
91
+ for edit in edits:
92
+ new: str = str(cast("Object", edit).get("newText", ""))
93
+ start: Object = cast("Object", cast("Object", cast("Object", edit)["range"])["start"])
94
+ if start == hint["position"] and new.startswith(":"):
95
+ text = new[1:].strip()
96
+ continue
97
+ found: list[str] | None
98
+ if (found := _imports(new.strip(), start, locate, existing)) is None:
99
+ return None
100
+ imports += found
101
+ return Offered(text, tuple(imports)) if text and (imports or text != label) else None
102
+
103
+
104
+ def _located(label: Json) -> tuple[str, ...]:
105
+ """Read the imports a label's parts take: each class a part names, from the module its file is.
106
+
107
+ Returns:
108
+ A statement for each class whose defining file's module can be named, once; not a builtin's.
109
+
110
+ """
111
+ found: dict[str, None] = {}
112
+ part: Json
113
+ for part in label if isinstance(label, list) else []:
114
+ name: str = str(cast("Object", part).get("value", ""))
115
+ location: Json = cast("Object", part).get("location")
116
+ if location is None or not name.isidentifier():
117
+ continue
118
+ uri: str = str(cast("Object", location)["uri"])
119
+ module: str | None = _defined_in(Path(url2pathname(urlsplit(uri).path)))
120
+ if module and module != _BUILTINS:
121
+ found[f"from {module} import {name}"] = None
122
+ return tuple(found)
123
+
124
+
125
+ def _defined_in(path: Path) -> str | None:
126
+ """Name the module a file is: an installed one, one of pyrefly's own stubs, or a project's.
127
+
128
+ Returns:
129
+ It, by the folders after `site-packages` or pyrefly's stubs' folder, else by its package
130
+ folders as the project index names it; `None` for a stub anywhere else, or no file at all.
131
+
132
+ """
133
+ if not path.name: # a location that isn't a file's (`untitled:`)
134
+ return None
135
+ parts: tuple[str, ...] = path.with_suffix("").parts
136
+ index: int
137
+ part: str
138
+ for index, part in enumerate(parts[:-1]):
139
+ if part == _SITE or part.startswith(_BUNDLED):
140
+ after: int = index + 2
141
+ inside: list[str] = [parts[index + 1].removesuffix(_STUBS), *parts[after:]]
142
+ return ".".join(inside[:-1] if inside[-1] == _PACKAGE and len(inside) > 1 else inside)
143
+ return module_name(path) if path.suffix == SUFFIX else None
144
+
145
+
146
+ def _imports(new: str, start: Object, locate: _Locate, existing: FromImports) -> list[str] | None:
147
+ """Read an edit that adds imports: whole statements, or names into a `from` import at `start`.
148
+
149
+ Returns:
150
+ Each name's import, as a statement of its own; `None` for any other edit.
151
+
152
+ """
153
+ try:
154
+ body: list[ast.stmt] = ast.parse(new).body
155
+ except SyntaxError: # `, Shape`: names for an import the file has
156
+ body = []
157
+ if body and all(isinstance(stmt, ast.Import | ast.ImportFrom) for stmt in body):
158
+ return [statement for stmt in body for statement in _statements(stmt)]
159
+ where: _Where | None = locate(start)
160
+ node: ast.ImportFrom | None = None if where is None else existing.at(where)
161
+ names: list[str] = [name.strip() for name in new.split(",") if name.strip()]
162
+ if node is None or not names or not all(name.isidentifier() for name in names):
163
+ return None
164
+ return [f"from {_module(node)} import {name}" for name in names]
165
+
166
+
167
+ def _statements(stmt: ast.stmt) -> list[str]:
168
+ """Write an import as one statement per name it binds.
169
+
170
+ Returns:
171
+ Them.
172
+
173
+ """
174
+ prefix: str = f"from {_module(stmt)} import " if isinstance(stmt, ast.ImportFrom) else "import "
175
+ return [
176
+ prefix + alias.name + (f" as {alias.asname}" if alias.asname else "")
177
+ for alias in cast("ast.Import | ast.ImportFrom", stmt).names
178
+ ]
179
+
180
+
181
+ def _module(node: ast.ImportFrom) -> str:
182
+ """Write the module a `from` import names.
183
+
184
+ Returns:
185
+ It, a relative one with its dots.
186
+
187
+ """
188
+ return "." * node.level + (node.module or "")