SourceIndex 0.1.0__py3-none-any.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.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Per-repo master key: OS keychain when available, mode-0600 file fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import crypto
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
KEYRING_SERVICE = "sourceindex"
|
|
17
|
+
ENV_KEY_FILE_DIR = "SOURCEINDEX_KEY_DIR"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _key_file_dir() -> Path:
|
|
21
|
+
override = os.environ.get(ENV_KEY_FILE_DIR)
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser().resolve()
|
|
24
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
25
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
26
|
+
return base / "sourceindex" / "keys"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _try_keyring_get(repo_hash: str) -> bytes | None:
|
|
30
|
+
try:
|
|
31
|
+
import keyring # type: ignore[import-not-found]
|
|
32
|
+
import keyring.errors # noqa: F401 type: ignore[import-not-found]
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
encoded = keyring.get_password(KEYRING_SERVICE, repo_hash)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
_log.debug("keyring.get_password failed: %s", e)
|
|
39
|
+
return None
|
|
40
|
+
if not encoded:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
key = base64.b64decode(encoded)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
if len(key) != crypto.KEY_LEN:
|
|
47
|
+
return None
|
|
48
|
+
return key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _try_keyring_set(repo_hash: str, key: bytes) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
import keyring # type: ignore[import-not-found]
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
encoded = base64.b64encode(key).decode("ascii")
|
|
57
|
+
try:
|
|
58
|
+
keyring.set_password(KEYRING_SERVICE, repo_hash, encoded)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
_log.debug("keyring.set_password failed: %s", e)
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_key_file(path: Path) -> bytes | None:
|
|
66
|
+
try:
|
|
67
|
+
raw = path.read_bytes()
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return None
|
|
70
|
+
if len(raw) != crypto.KEY_LEN:
|
|
71
|
+
# Refuse rather than silently regenerate — operator may have hand-edited.
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Key file at {path} is {len(raw)} bytes; expected {crypto.KEY_LEN}."
|
|
74
|
+
)
|
|
75
|
+
return raw
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_key_file(path: Path, key: bytes) -> None:
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
try:
|
|
81
|
+
path.parent.chmod(0o700)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
# O_EXCL+0o600 avoids a window where the key sits at the default umask.
|
|
85
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
86
|
+
try:
|
|
87
|
+
os.write(fd, key)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_or_create_key(repo_hash: str) -> bytes:
|
|
93
|
+
key = _try_keyring_get(repo_hash)
|
|
94
|
+
if key is not None:
|
|
95
|
+
return key
|
|
96
|
+
|
|
97
|
+
key_path = _key_file_dir() / f"{repo_hash}.key"
|
|
98
|
+
key = _read_key_file(key_path)
|
|
99
|
+
if key is not None:
|
|
100
|
+
return key
|
|
101
|
+
|
|
102
|
+
key = crypto.generate_key()
|
|
103
|
+
if _try_keyring_set(repo_hash, key):
|
|
104
|
+
return key
|
|
105
|
+
|
|
106
|
+
_write_key_file(key_path, key)
|
|
107
|
+
_log.warning(
|
|
108
|
+
"No OS keychain backend available; persisted the encryption key to "
|
|
109
|
+
"%s (mode 0600). Set up gnome-keyring / kwallet / Keychain.app for "
|
|
110
|
+
"stronger at-rest protection. Override the directory with "
|
|
111
|
+
"$SOURCEINDEX_KEY_DIR.",
|
|
112
|
+
key_path,
|
|
113
|
+
)
|
|
114
|
+
return key
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def delete_key(repo_hash: str) -> None:
|
|
118
|
+
try:
|
|
119
|
+
import keyring # type: ignore[import-not-found]
|
|
120
|
+
keyring.delete_password(KEYRING_SERVICE, repo_hash)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
_log.debug("keyring.delete_password failed: %s", e)
|
|
123
|
+
key_path = _key_file_dir() / f"{repo_hash}.key"
|
|
124
|
+
try:
|
|
125
|
+
key_path.unlink()
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
pass
|
|
128
|
+
except OSError as e:
|
|
129
|
+
_log.debug("unlink %s failed: %s", key_path, e)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Daemon lifecycle: socket paths, spawn-on-first-RPC, pidfile, graceful stop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def repo_hash_for(main_worktree: Path) -> str:
|
|
23
|
+
# Resolve symlinks so a symlinked-in path and its target hash the same.
|
|
24
|
+
canonical = str(Path(main_worktree).resolve())
|
|
25
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def runtime_dir() -> Path:
|
|
29
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
30
|
+
if xdg and Path(xdg).is_dir():
|
|
31
|
+
base = Path(xdg) / "sourceindex"
|
|
32
|
+
else:
|
|
33
|
+
# Per-user fallback so two accounts on a shared box don't collide.
|
|
34
|
+
base = Path("/tmp") / f"sourceindex-{os.getuid()}"
|
|
35
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
try:
|
|
37
|
+
base.chmod(0o700)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return base
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def user_socket(repo_hash: str) -> Path:
|
|
44
|
+
return runtime_dir() / f"{repo_hash}.sock"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def admin_socket(repo_hash: str) -> Path:
|
|
48
|
+
return runtime_dir() / f"{repo_hash}.admin.sock"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pidfile(repo_hash: str) -> Path:
|
|
52
|
+
return runtime_dir() / f"{repo_hash}.pid"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def spawn_lock(repo_hash: str) -> Path:
|
|
56
|
+
return runtime_dir() / f"{repo_hash}.spawn.lock"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def progress_file(repo_hash: str) -> Path:
|
|
60
|
+
return runtime_dir() / f"{repo_hash}.progress"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_pidfile(repo_hash: str) -> None:
|
|
64
|
+
p = pidfile(repo_hash)
|
|
65
|
+
payload = f"{os.getpid()} {int(time.time())}\n"
|
|
66
|
+
tmp = p.with_suffix(".pid.tmp")
|
|
67
|
+
tmp.write_text(payload)
|
|
68
|
+
os.replace(tmp, p)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_pidfile(repo_hash: str) -> tuple[int, int] | None:
|
|
72
|
+
p = pidfile(repo_hash)
|
|
73
|
+
try:
|
|
74
|
+
raw = p.read_text().strip()
|
|
75
|
+
except OSError:
|
|
76
|
+
return None
|
|
77
|
+
parts = raw.split()
|
|
78
|
+
if len(parts) != 2:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return int(parts[0]), int(parts[1])
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pid_alive(pid: int) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
os.kill(pid, 0)
|
|
89
|
+
except ProcessLookupError:
|
|
90
|
+
return False
|
|
91
|
+
except PermissionError:
|
|
92
|
+
return True
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cleanup_stale_files(repo_hash: str) -> None:
|
|
97
|
+
info = read_pidfile(repo_hash)
|
|
98
|
+
if info is not None and pid_alive(info[0]):
|
|
99
|
+
return
|
|
100
|
+
for p in (pidfile(repo_hash), user_socket(repo_hash), admin_socket(repo_hash)):
|
|
101
|
+
try:
|
|
102
|
+
p.unlink()
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def spawn_lock_held(repo_hash: str):
|
|
109
|
+
lock_path = spawn_lock(repo_hash)
|
|
110
|
+
lock_path.touch(exist_ok=True)
|
|
111
|
+
fd = os.open(str(lock_path), os.O_RDWR)
|
|
112
|
+
try:
|
|
113
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
114
|
+
yield
|
|
115
|
+
finally:
|
|
116
|
+
try:
|
|
117
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
118
|
+
finally:
|
|
119
|
+
os.close(fd)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _double_fork_exec(argv: list[str]) -> None:
|
|
123
|
+
pid = os.fork()
|
|
124
|
+
if pid > 0:
|
|
125
|
+
os.waitpid(pid, 0)
|
|
126
|
+
return
|
|
127
|
+
os.setsid()
|
|
128
|
+
pid2 = os.fork()
|
|
129
|
+
if pid2 > 0:
|
|
130
|
+
os._exit(0)
|
|
131
|
+
# Reopen stdio onto /dev/null so a stray print() can't leak to the client.
|
|
132
|
+
try:
|
|
133
|
+
fd_null = os.open(os.devnull, os.O_RDWR)
|
|
134
|
+
for target_fd in (0, 1, 2):
|
|
135
|
+
os.dup2(fd_null, target_fd)
|
|
136
|
+
if fd_null > 2:
|
|
137
|
+
os.close(fd_null)
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
os.execvp(argv[0], argv)
|
|
142
|
+
except FileNotFoundError as e:
|
|
143
|
+
_log.error("daemon execvp failed: %s", e)
|
|
144
|
+
os._exit(2)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def daemon_argv(repo_root: Path, *, allow_dump: bool) -> list[str]:
|
|
148
|
+
binary = shutil.which("sourceindex") or sys.executable
|
|
149
|
+
if binary == sys.executable:
|
|
150
|
+
argv = [binary, "-m", "sourceindex.cli", "daemon"]
|
|
151
|
+
else:
|
|
152
|
+
argv = [binary, "daemon"]
|
|
153
|
+
argv += ["--repo-root", str(repo_root)]
|
|
154
|
+
if allow_dump:
|
|
155
|
+
argv.append("--allow-dump")
|
|
156
|
+
return argv
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def spawn_daemon(repo_root: Path, *, allow_dump: bool, timeout_s: float = 5.0) -> None:
|
|
160
|
+
rh = repo_hash_for(repo_root)
|
|
161
|
+
with spawn_lock_held(rh):
|
|
162
|
+
# File-existence alone is not a liveness signal — a crashed daemon
|
|
163
|
+
# leaves its socket behind. Check pidfile + pid_alive + socket so
|
|
164
|
+
# stale orphans fall through to cleanup_stale_files instead of
|
|
165
|
+
# tricking the client into connecting to a dead listener.
|
|
166
|
+
if is_daemon_running(repo_root):
|
|
167
|
+
return
|
|
168
|
+
cleanup_stale_files(rh)
|
|
169
|
+
_double_fork_exec(daemon_argv(repo_root, allow_dump=allow_dump))
|
|
170
|
+
|
|
171
|
+
deadline = time.monotonic() + timeout_s
|
|
172
|
+
while time.monotonic() < deadline:
|
|
173
|
+
if user_socket(rh).exists():
|
|
174
|
+
return
|
|
175
|
+
time.sleep(0.05)
|
|
176
|
+
raise TimeoutError(
|
|
177
|
+
f"daemon for {repo_root} did not bind {user_socket(rh)} within "
|
|
178
|
+
f"{timeout_s}s — check `sourceindex daemon --repo-root {repo_root}`"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def stop_daemon(repo_root: Path, *, timeout_s: float = 5.0) -> bool:
|
|
183
|
+
rh = repo_hash_for(repo_root)
|
|
184
|
+
info = read_pidfile(rh)
|
|
185
|
+
if info is None or not pid_alive(info[0]):
|
|
186
|
+
cleanup_stale_files(rh)
|
|
187
|
+
return False
|
|
188
|
+
pid, _ = info
|
|
189
|
+
try:
|
|
190
|
+
os.kill(pid, signal.SIGTERM)
|
|
191
|
+
except ProcessLookupError:
|
|
192
|
+
cleanup_stale_files(rh)
|
|
193
|
+
return False
|
|
194
|
+
deadline = time.monotonic() + timeout_s
|
|
195
|
+
while time.monotonic() < deadline:
|
|
196
|
+
if not pid_alive(pid):
|
|
197
|
+
cleanup_stale_files(rh)
|
|
198
|
+
return True
|
|
199
|
+
time.sleep(0.05)
|
|
200
|
+
try:
|
|
201
|
+
os.kill(pid, signal.SIGKILL)
|
|
202
|
+
except ProcessLookupError:
|
|
203
|
+
pass
|
|
204
|
+
cleanup_stale_files(rh)
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_daemon_running(repo_root: Path) -> bool:
|
|
209
|
+
rh = repo_hash_for(repo_root)
|
|
210
|
+
info = read_pidfile(rh)
|
|
211
|
+
if info is None:
|
|
212
|
+
return False
|
|
213
|
+
return pid_alive(info[0]) and user_socket(rh).exists()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def connect_blocking(path: Path, *, timeout_s: float = 5.0) -> socket.socket:
|
|
217
|
+
deadline = time.monotonic() + timeout_s
|
|
218
|
+
while True:
|
|
219
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
220
|
+
try:
|
|
221
|
+
s.connect(str(path))
|
|
222
|
+
return s
|
|
223
|
+
except (FileNotFoundError, ConnectionRefusedError) as e:
|
|
224
|
+
s.close()
|
|
225
|
+
if time.monotonic() >= deadline:
|
|
226
|
+
raise FileNotFoundError(
|
|
227
|
+
f"could not connect to {path} within {timeout_s}s: {e}"
|
|
228
|
+
) from e
|
|
229
|
+
time.sleep(0.05)
|
|
230
|
+
except OSError as e:
|
|
231
|
+
s.close()
|
|
232
|
+
if e.errno in (errno.ENOENT, errno.ECONNREFUSED):
|
|
233
|
+
if time.monotonic() >= deadline:
|
|
234
|
+
raise
|
|
235
|
+
time.sleep(0.05)
|
|
236
|
+
continue
|
|
237
|
+
raise
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 wire protocol over UDS, length-prefixed frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import struct
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MAX_FRAME_BYTES = 16 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
USER_METHODS = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"ping",
|
|
16
|
+
"search",
|
|
17
|
+
"init",
|
|
18
|
+
"update",
|
|
19
|
+
"install_hooks",
|
|
20
|
+
"usage",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ADMIN_METHODS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"dump_all",
|
|
27
|
+
"read_index_md",
|
|
28
|
+
"read_detail",
|
|
29
|
+
"read_env",
|
|
30
|
+
"shutdown",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# JSON-RPC reserved
|
|
36
|
+
E_PARSE_ERROR = -32700
|
|
37
|
+
E_INVALID_REQUEST = -32600
|
|
38
|
+
E_METHOD_NOT_FOUND = -32601
|
|
39
|
+
E_INVALID_PARAMS = -32602
|
|
40
|
+
E_INTERNAL = -32603
|
|
41
|
+
E_SERVER = -32000
|
|
42
|
+
|
|
43
|
+
# SourceIndex-specific
|
|
44
|
+
E_NO_INDEX = 1001
|
|
45
|
+
E_AUTH_REQUIRED = 1002
|
|
46
|
+
E_BUDGET_EXCEEDED = 1003
|
|
47
|
+
E_KEY_UNAVAILABLE = 1004
|
|
48
|
+
E_FRAME_TOO_LARGE = 1005
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RpcError(Exception):
|
|
52
|
+
def __init__(self, code: int, message: str, data: Any | None = None) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.code = code
|
|
55
|
+
self.message = message
|
|
56
|
+
self.data = data
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
out: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
60
|
+
if self.data is not None:
|
|
61
|
+
out["data"] = self.data
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode_frame(obj: dict) -> bytes:
|
|
66
|
+
body = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
67
|
+
if len(body) > MAX_FRAME_BYTES:
|
|
68
|
+
raise RpcError(
|
|
69
|
+
E_FRAME_TOO_LARGE,
|
|
70
|
+
f"frame is {len(body)} bytes; max is {MAX_FRAME_BYTES}",
|
|
71
|
+
)
|
|
72
|
+
return struct.pack(">I", len(body)) + body
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def read_frame_async(reader) -> dict:
|
|
76
|
+
hdr = await reader.readexactly(4)
|
|
77
|
+
(n,) = struct.unpack(">I", hdr)
|
|
78
|
+
if n > MAX_FRAME_BYTES:
|
|
79
|
+
raise RpcError(
|
|
80
|
+
E_FRAME_TOO_LARGE,
|
|
81
|
+
f"incoming frame is {n} bytes; max is {MAX_FRAME_BYTES}",
|
|
82
|
+
)
|
|
83
|
+
body = await reader.readexactly(n)
|
|
84
|
+
try:
|
|
85
|
+
return json.loads(body.decode("utf-8"))
|
|
86
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
87
|
+
raise RpcError(E_PARSE_ERROR, f"frame parse error: {e}") from e
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_frame_sync(sock) -> dict:
|
|
91
|
+
hdr = _recv_exact(sock, 4)
|
|
92
|
+
(n,) = struct.unpack(">I", hdr)
|
|
93
|
+
if n > MAX_FRAME_BYTES:
|
|
94
|
+
raise RpcError(
|
|
95
|
+
E_FRAME_TOO_LARGE,
|
|
96
|
+
f"incoming frame is {n} bytes; max is {MAX_FRAME_BYTES}",
|
|
97
|
+
)
|
|
98
|
+
body = _recv_exact(sock, n)
|
|
99
|
+
return json.loads(body.decode("utf-8"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _recv_exact(sock, n: int) -> bytes:
|
|
103
|
+
buf = bytearray()
|
|
104
|
+
while len(buf) < n:
|
|
105
|
+
chunk = sock.recv(n - len(buf))
|
|
106
|
+
if not chunk:
|
|
107
|
+
raise ConnectionError("connection closed mid-frame")
|
|
108
|
+
buf.extend(chunk)
|
|
109
|
+
return bytes(buf)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_request(method: str, params: dict | None = None, *, id_: int = 1) -> dict:
|
|
113
|
+
req: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "id": id_}
|
|
114
|
+
if params is not None:
|
|
115
|
+
req["params"] = params
|
|
116
|
+
return req
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def make_response(id_: Any, result: Any) -> dict:
|
|
120
|
+
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_error(id_: Any, err: RpcError | Exception) -> dict:
|
|
124
|
+
if isinstance(err, RpcError):
|
|
125
|
+
return {"jsonrpc": "2.0", "id": id_, "error": err.to_dict()}
|
|
126
|
+
return {
|
|
127
|
+
"jsonrpc": "2.0",
|
|
128
|
+
"id": id_,
|
|
129
|
+
"error": {
|
|
130
|
+
"code": E_SERVER,
|
|
131
|
+
"message": str(err) or type(err).__name__,
|
|
132
|
+
"data": {"error_type": type(err).__name__},
|
|
133
|
+
},
|
|
134
|
+
}
|