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.
- 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-win32.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp311-win32.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp311-win32.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp311-win32.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp311-win32.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp311-win32.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp311-win32.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp311-win32.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp311-win32.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp311-win32.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp311-win32.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp311-win32.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp311-win32.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp311-win32.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp311-win32.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp311-win32.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp311-win32.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp311-win32.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp311-win32.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp311-win32.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp311-win32.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp311-win32.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp311-win32.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp311-win32.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp311-win32.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp311-win32.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp311-win32.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp311-win32.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp311-win32.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp311-win32.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp311-win32.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp311-win32.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp311-win32.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp311-win32.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp311-win32.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp311-win32.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp311-win32.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp311-win32.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp311-win32.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp311-win32.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-win32.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp311-win32.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp311-win32.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp311-win32.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp311-win32.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp311-win32.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp311-win32.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp311-win32.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp311-win32.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp311-win32.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp311-win32.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp311-win32.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp311-win32.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp311-win32.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp311-win32.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp311-win32.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp311-win32.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp311-win32.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp311-win32.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp311-win32.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp311-win32.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp311-win32.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp311-win32.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp311-win32.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp311-win32.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-win32.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp311-win32.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp311-win32.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp311-win32.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp311-win32.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp311-win32.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp311-win32.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp311-win32.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp311-win32.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp311-win32.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp311-win32.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp311-win32.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp311-win32.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp311-win32.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp311-win32.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp311-win32.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp311-win32.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp311-win32.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp311-win32.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp311-win32.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp311-win32.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp311-win32.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp311-win32.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp311-win32.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp311-win32.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,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 "")
|