puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
app/launcher.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""The `puremethod` command (08-app.md §3 CLI, §5 steps 9 and 11, KB-025).
|
|
2
|
+
|
|
3
|
+
`puremethod` starts self-hosted mode in the foreground (the API and the supervisor in one
|
|
4
|
+
process, §5 step 8), then opens the browser signed in with a one-time code. While it runs it
|
|
5
|
+
holds an exclusive lock on `<data dir>/server.lock` and records its port in `server.json`,
|
|
6
|
+
so `open`, `stop` and a second `puremethod` find it. `stop` asks it to exit by creating
|
|
7
|
+
`stop.request`, which the server polls; it then shuts down as on Ctrl+C."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import webbrowser
|
|
24
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import IO
|
|
27
|
+
|
|
28
|
+
from app import paths
|
|
29
|
+
from app.accounts import SigninCodes, bootstrap, issue_token, tenants
|
|
30
|
+
from app.db import Database
|
|
31
|
+
from app.settings import DEFAULT_WEB_DIR, Settings, SettingsError, data_dir_from, key_file
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("app.launcher")
|
|
34
|
+
|
|
35
|
+
READY_TIMEOUT_S = 60.0
|
|
36
|
+
STOP_TIMEOUT_S = 30.0
|
|
37
|
+
POLL_S = 0.5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- files that describe a running server ----------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _lock_path(s: Settings) -> Path:
|
|
43
|
+
return s.data_dir / "server.lock"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _state_path(s: Settings) -> Path:
|
|
47
|
+
return s.data_dir / "server.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stop_path(s: Settings) -> Path:
|
|
51
|
+
return s.data_dir / "stop.request"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _try_lock(fh: IO[bytes]) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
fh.seek(0)
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
import msvcrt
|
|
59
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
60
|
+
else:
|
|
61
|
+
import fcntl
|
|
62
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
63
|
+
except OSError:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _unlock(fh: IO[bytes]) -> None:
|
|
69
|
+
# Explicitly: Windows releases a lock left on a closed handle only after a delay.
|
|
70
|
+
with contextlib.suppress(OSError):
|
|
71
|
+
fh.seek(0)
|
|
72
|
+
if sys.platform == "win32":
|
|
73
|
+
import msvcrt
|
|
74
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
75
|
+
else:
|
|
76
|
+
import fcntl
|
|
77
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextlib.contextmanager
|
|
81
|
+
def server_lock(s: Settings, wait_s: float = 0.0) -> Iterator[bool]:
|
|
82
|
+
"""Yields True while this process holds the lock; False when another server holds it.
|
|
83
|
+
`wait_s` retries briefly, so a start does not lose to another command's quick probe."""
|
|
84
|
+
s.data_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
with open(_lock_path(s), "a+b") as fh:
|
|
86
|
+
end = time.monotonic() + wait_s
|
|
87
|
+
mine = _try_lock(fh)
|
|
88
|
+
while not mine and time.monotonic() < end:
|
|
89
|
+
time.sleep(0.1)
|
|
90
|
+
mine = _try_lock(fh)
|
|
91
|
+
try:
|
|
92
|
+
yield mine
|
|
93
|
+
finally:
|
|
94
|
+
if mine:
|
|
95
|
+
_unlock(fh)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def running_port(s: Settings) -> int | None:
|
|
99
|
+
"""The port of the server running on this data directory, or None."""
|
|
100
|
+
with server_lock(s) as mine:
|
|
101
|
+
if mine:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return int(json.loads(_state_path(s).read_text(encoding="utf-8"))["port"])
|
|
105
|
+
except (OSError, ValueError, KeyError):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _port_answers(host: str, port: int) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
with socket.create_connection((host, port), timeout=1):
|
|
112
|
+
return True
|
|
113
|
+
except OSError:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---- sign-in links -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def signin_url(s: Settings, port: int) -> str:
|
|
120
|
+
"""A link with a fresh one-time code in the URL fragment, which browsers never send in a
|
|
121
|
+
request line, so it is never in a server log (08 §5 step 9)."""
|
|
122
|
+
db = Database(s.app_db)
|
|
123
|
+
found = tenants(db)
|
|
124
|
+
if len(found) != 1:
|
|
125
|
+
raise SettingsError("self-hosted mode has exactly one user; start it with `puremethod`")
|
|
126
|
+
host = "127.0.0.1" if s.host in ("0.0.0.0", "::") else s.host # noqa: S104
|
|
127
|
+
return f"http://{host}:{port}/signin/#code={SigninCodes(db).issue(found[0])}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def open_browser(url: str, opener: Callable[[str], bool] | None = None) -> None:
|
|
131
|
+
"""Hands the link to the browser. It is never printed: output may go to a log file, and
|
|
132
|
+
the code must never be logged (INV-9). Without a browser, sign in with an API token."""
|
|
133
|
+
base = url.split("#", 1)[0]
|
|
134
|
+
if (opener or webbrowser.open)(url):
|
|
135
|
+
print(f"puremethod is open in your browser ({base})", flush=True)
|
|
136
|
+
else:
|
|
137
|
+
print(f"no browser could be opened. Sign in at {base} with an API token from "
|
|
138
|
+
"`puremethod token rotate`", flush=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---- serving -------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def serve(settings: Settings, *, on_ready: Callable[[int], None] | None = None,
|
|
144
|
+
stop_file: Path | None = None) -> int:
|
|
145
|
+
"""Self-hosted mode in this process until Ctrl+C or a stop request. The API token from
|
|
146
|
+
the first start is not printed (KB-025)."""
|
|
147
|
+
import uvicorn
|
|
148
|
+
|
|
149
|
+
from app import bundled
|
|
150
|
+
from app.api import create_app
|
|
151
|
+
from app.server import build_ctx
|
|
152
|
+
from app.supervisor import Supervisor
|
|
153
|
+
|
|
154
|
+
if settings.mode != "self-hosted":
|
|
155
|
+
print("hosted mode is not available in v0.1", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
ctx = build_ctx(settings)
|
|
158
|
+
bootstrap(ctx.db)
|
|
159
|
+
if settings.web_dir is None or not (settings.web_dir / "index.html").is_file():
|
|
160
|
+
print("web UI not built: serving the API only (see implementation/web)", flush=True)
|
|
161
|
+
bundled.start_all(ctx.db) # each local literature service starts with the server
|
|
162
|
+
app = create_app(ctx, settings.web_dir, local_status=bundled.status_for(settings))
|
|
163
|
+
server = uvicorn.Server(uvicorn.Config(app,
|
|
164
|
+
host=settings.host, port=settings.port,
|
|
165
|
+
log_level="info"))
|
|
166
|
+
done = threading.Event()
|
|
167
|
+
|
|
168
|
+
def watch() -> None:
|
|
169
|
+
announced = False
|
|
170
|
+
while not done.wait(POLL_S):
|
|
171
|
+
if not announced and server.started:
|
|
172
|
+
announced = True
|
|
173
|
+
if on_ready is not None:
|
|
174
|
+
threading.Thread(target=_safe, args=(on_ready, settings.port),
|
|
175
|
+
daemon=True).start()
|
|
176
|
+
if stop_file is not None and stop_file.exists():
|
|
177
|
+
with contextlib.suppress(OSError):
|
|
178
|
+
stop_file.unlink()
|
|
179
|
+
server.should_exit = True
|
|
180
|
+
|
|
181
|
+
supervisor = Supervisor(ctx.db, lambda lid: ctx.status.status(lid).status)
|
|
182
|
+
supervisor.start()
|
|
183
|
+
watcher = threading.Thread(target=watch, name="launcher", daemon=True)
|
|
184
|
+
watcher.start()
|
|
185
|
+
try:
|
|
186
|
+
server.run()
|
|
187
|
+
finally:
|
|
188
|
+
done.set()
|
|
189
|
+
supervisor.stop()
|
|
190
|
+
bundled.stop_all(ctx.db)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _safe(fn: Callable[[int], None], port: int) -> None:
|
|
195
|
+
try:
|
|
196
|
+
fn(port)
|
|
197
|
+
except Exception as exc: # the server keeps running; `puremethod open` retries
|
|
198
|
+
print(f"could not open a sign-in link: {exc}", file=sys.stderr, flush=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---- commands ------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def ensure_key(env: Mapping[str, str]) -> bool:
|
|
204
|
+
"""The launcher, not `serve`, creates the master key file on first start (08 §5 step 3).
|
|
205
|
+
Returns True when it created one."""
|
|
206
|
+
if env.get("PM_MASTER_KEY"):
|
|
207
|
+
return False
|
|
208
|
+
path = key_file(env)
|
|
209
|
+
if path.is_file():
|
|
210
|
+
return False
|
|
211
|
+
if path.is_relative_to(data_dir_from(env)):
|
|
212
|
+
raise SettingsError("the master key file must be outside the data directory")
|
|
213
|
+
paths.create_key_file(path)
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_start(env: Mapping[str, str]) -> int:
|
|
218
|
+
created = ensure_key(env)
|
|
219
|
+
settings = Settings.from_env(env)
|
|
220
|
+
port = running_port(settings)
|
|
221
|
+
if port is not None:
|
|
222
|
+
print("puremethod is already running", flush=True)
|
|
223
|
+
open_browser(signin_url(settings, port))
|
|
224
|
+
return 0
|
|
225
|
+
if _port_answers("127.0.0.1", settings.port):
|
|
226
|
+
print(f"port {settings.port} is in use by another program; set PM_PORT to a free port",
|
|
227
|
+
file=sys.stderr)
|
|
228
|
+
return 1
|
|
229
|
+
if created:
|
|
230
|
+
print(f"created the master key file {key_file(env)}\n"
|
|
231
|
+
" back it up: without it, stored keys cannot be read (it is not in the data "
|
|
232
|
+
"directory)", flush=True)
|
|
233
|
+
with server_lock(settings, wait_s=3.0) as mine:
|
|
234
|
+
if not mine:
|
|
235
|
+
print("puremethod is starting in another window", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
with contextlib.suppress(OSError):
|
|
238
|
+
_stop_path(settings).unlink()
|
|
239
|
+
_state_path(settings).write_text(json.dumps({"pid": os.getpid(),
|
|
240
|
+
"port": settings.port}), encoding="utf-8")
|
|
241
|
+
try:
|
|
242
|
+
print("puremethod is starting; press Ctrl+C to stop", flush=True)
|
|
243
|
+
return serve(settings, stop_file=_stop_path(settings),
|
|
244
|
+
on_ready=lambda port: open_browser(signin_url(settings, port)))
|
|
245
|
+
finally:
|
|
246
|
+
with contextlib.suppress(OSError):
|
|
247
|
+
_state_path(settings).unlink()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_open(env: Mapping[str, str]) -> int:
|
|
251
|
+
settings = Settings.from_env(env)
|
|
252
|
+
port = running_port(settings)
|
|
253
|
+
if port is None:
|
|
254
|
+
print("puremethod is not running; start it with `puremethod`", file=sys.stderr)
|
|
255
|
+
return 1
|
|
256
|
+
open_browser(signin_url(settings, port))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_stop(env: Mapping[str, str], timeout: float = STOP_TIMEOUT_S) -> int:
|
|
261
|
+
settings = Settings.from_env(env)
|
|
262
|
+
if running_port(settings) is None:
|
|
263
|
+
print("puremethod is not running", flush=True)
|
|
264
|
+
return 0
|
|
265
|
+
_stop_path(settings).touch()
|
|
266
|
+
end = time.monotonic() + timeout
|
|
267
|
+
while time.monotonic() < end:
|
|
268
|
+
if running_port(settings) is None:
|
|
269
|
+
print("puremethod stopped", flush=True)
|
|
270
|
+
return 0
|
|
271
|
+
time.sleep(POLL_S)
|
|
272
|
+
print("puremethod did not stop in time; press Ctrl+C in its window", file=sys.stderr)
|
|
273
|
+
return 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_token_rotate(env: Mapping[str, str]) -> int:
|
|
277
|
+
settings = Settings.from_env(env)
|
|
278
|
+
db = Database(settings.app_db)
|
|
279
|
+
found = tenants(db)
|
|
280
|
+
if len(found) != 1:
|
|
281
|
+
print("token rotate needs exactly one user (self-hosted); start `puremethod` first",
|
|
282
|
+
file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
print(f"new API token (shown once; all sessions ended): {issue_token(db, found[0])}",
|
|
285
|
+
flush=True)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def doctor(env: Mapping[str, str]) -> list[tuple[str, bool, str]]:
|
|
290
|
+
"""Checks for `puremethod doctor`: (what, ok, detail). Never reads key material."""
|
|
291
|
+
out: list[tuple[str, bool, str]] = []
|
|
292
|
+
out.append(("Python", sys.version_info >= (3, 11),
|
|
293
|
+
f"{platform.python_version()} (3.11 or newer needed)"))
|
|
294
|
+
docker = shutil.which("docker")
|
|
295
|
+
if docker is None:
|
|
296
|
+
out.append(("Docker", False, "not found; needed for local-docker runs, the LaTeX build "
|
|
297
|
+
"and the bundled PureScholar"))
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
300
|
+
# Fixed arguments, no shell.
|
|
301
|
+
proc = subprocess.run([docker, "version", "--format", # noqa: S603
|
|
302
|
+
"{{.Server.Version}}"], capture_output=True, text=True,
|
|
303
|
+
timeout=20, check=False)
|
|
304
|
+
ok = proc.returncode == 0 and bool(proc.stdout.strip())
|
|
305
|
+
out.append(("Docker", ok, f"engine {proc.stdout.strip()}" if ok
|
|
306
|
+
else "installed but the engine is not running"))
|
|
307
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
308
|
+
out.append(("Docker", False, "installed but not answering"))
|
|
309
|
+
path = key_file(env)
|
|
310
|
+
if env.get("PM_MASTER_KEY"):
|
|
311
|
+
out.append(("Master key", True, "from PM_MASTER_KEY"))
|
|
312
|
+
elif not path.is_file():
|
|
313
|
+
out.append(("Master key", False, f"{path} (created on first `puremethod`)"))
|
|
314
|
+
else:
|
|
315
|
+
private = paths.is_restricted(path)
|
|
316
|
+
out.append(("Master key", private, str(path) if private
|
|
317
|
+
else f"{path} is readable by other users; restrict it to yours"))
|
|
318
|
+
# Built without the key, so doctor works before the first start.
|
|
319
|
+
data = data_dir_from(env)
|
|
320
|
+
probe = Settings(mode="self-hosted", data_dir=data, master_key=b"",
|
|
321
|
+
port=int(env.get("PM_PORT", "8400")))
|
|
322
|
+
out.append(("Data directory", _writable(data), str(data)))
|
|
323
|
+
port = running_port(probe)
|
|
324
|
+
if port is not None:
|
|
325
|
+
out.append(("Server", True, f"running on port {port}"))
|
|
326
|
+
else:
|
|
327
|
+
free = not _port_answers("127.0.0.1", probe.port)
|
|
328
|
+
out.append(("Port", free, f"{probe.port} {'free' if free else 'in use'}"))
|
|
329
|
+
web = Path(env["PM_WEB_DIR"]).resolve() if env.get("PM_WEB_DIR") else DEFAULT_WEB_DIR
|
|
330
|
+
out.append(("Web UI", (web / "index.html").is_file(), str(web)))
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _writable(d: Path) -> bool:
|
|
335
|
+
try:
|
|
336
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
probe = d / ".write-test"
|
|
338
|
+
probe.write_text("", encoding="utf-8")
|
|
339
|
+
probe.unlink()
|
|
340
|
+
except OSError:
|
|
341
|
+
return False
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_doctor(env: Mapping[str, str]) -> int:
|
|
346
|
+
checks = doctor(env)
|
|
347
|
+
for what, ok, detail in checks:
|
|
348
|
+
print(f"{'ok ' if ok else 'FAIL'} {what}: {detail}", flush=True)
|
|
349
|
+
return 0 if all(ok for _, ok, _ in checks) else 1
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
COMMANDS: dict[str, Callable[[Mapping[str, str]], int]] = {
|
|
353
|
+
"start": cmd_start, "open": cmd_open, "stop": cmd_stop, "doctor": cmd_doctor}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main(argv: list[str] | None = None) -> int:
|
|
357
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
|
358
|
+
parser = argparse.ArgumentParser(
|
|
359
|
+
prog="puremethod", description="puremethod: an idea in, a paper and a run bundle out.")
|
|
360
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
361
|
+
sub.add_parser("start", help="start, then open the browser signed in (the default)")
|
|
362
|
+
sub.add_parser("open", help="a new sign-in link for a running server")
|
|
363
|
+
sub.add_parser("stop", help="stop the running server")
|
|
364
|
+
sub.add_parser("doctor", help="check Python, Docker, ports and directories")
|
|
365
|
+
token = sub.add_parser("token", help="API tokens for scripts")
|
|
366
|
+
token.add_argument("action", choices=["rotate"],
|
|
367
|
+
help="print a new API token and end all sessions")
|
|
368
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
369
|
+
env = os.environ
|
|
370
|
+
try:
|
|
371
|
+
if args.cmd == "token":
|
|
372
|
+
return cmd_token_rotate(env)
|
|
373
|
+
return COMMANDS[args.cmd or "start"](env)
|
|
374
|
+
except SettingsError as exc:
|
|
375
|
+
print(f"cannot start: {exc}", file=sys.stderr)
|
|
376
|
+
return 1
|
|
377
|
+
except paths.KeyFileExists:
|
|
378
|
+
print("another puremethod created the master key just now; run it again",
|
|
379
|
+
file=sys.stderr)
|
|
380
|
+
return 1
|
|
381
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
382
|
+
print(f"cannot create the master key file: {type(exc).__name__}", file=sys.stderr)
|
|
383
|
+
return 1
|
|
384
|
+
except KeyboardInterrupt:
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
sys.exit(main())
|
app/leases.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Leases: one worker per run, one per preflight check (08-app.md §5 step 2, KB-021).
|
|
2
|
+
|
|
3
|
+
A lease is taken only if it is absent or expired, in one write transaction, so two workers
|
|
4
|
+
never hold the same key. Worker side only."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from app.db import Database
|
|
9
|
+
|
|
10
|
+
LEASE_TTL_S = 60.0
|
|
11
|
+
RENEW_S = 15.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def acquire(db: Database, key: str, holder: str, now: float, ttl: float = LEASE_TTL_S) -> bool:
|
|
15
|
+
with db.tx(write=True) as c:
|
|
16
|
+
rows = c.execute("SELECT holder, expires_at FROM leases WHERE key = ?", (key,))
|
|
17
|
+
if rows and float(rows[0][1]) >= now and rows[0][0] != holder:
|
|
18
|
+
return False
|
|
19
|
+
c.execute("INSERT INTO leases VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET "
|
|
20
|
+
"holder = excluded.holder, expires_at = excluded.expires_at",
|
|
21
|
+
(key, holder, now + ttl))
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def renew(db: Database, key: str, holder: str, now: float, ttl: float = LEASE_TTL_S) -> bool:
|
|
26
|
+
"""False when the lease is no longer this holder's, or has expired: the worker must stop."""
|
|
27
|
+
with db.tx(write=True) as c:
|
|
28
|
+
return c.rowcount("UPDATE leases SET expires_at = ? WHERE key = ? AND holder = ? "
|
|
29
|
+
"AND expires_at >= ?", (now + ttl, key, holder, now)) == 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def release(db: Database, key: str, holder: str) -> None:
|
|
33
|
+
with db.tx(write=True) as c:
|
|
34
|
+
c.execute("DELETE FROM leases WHERE key = ? AND holder = ?", (key, holder))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def held(db: Database, key: str, now: float) -> bool:
|
|
38
|
+
with db.tx() as c:
|
|
39
|
+
rows = c.execute("SELECT expires_at FROM leases WHERE key = ?", (key,))
|
|
40
|
+
return bool(rows) and float(rows[0][0]) >= now
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The scholarly sources the bundled PureScholar searches (08-app.md §3 Onboarding step 2, §4,
|
|
2
|
+
§5 step 12; KB-039).
|
|
3
|
+
|
|
4
|
+
OpenAlex, Crossref and Semantic Scholar work without a key and are on by default. The
|
|
5
|
+
publisher sources do nothing without their key, so they can only be on with one. Elsevier's
|
|
6
|
+
one key serves both ScienceDirect and Scopus. `env` is what the literature-service task passes
|
|
7
|
+
the container; `check` is one search request with a key, for its credential check. Keys go to
|
|
8
|
+
the source only, never into a returned message."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
SOURCES = ("openalex", "crossref", "semanticscholar", "ieee", "sciencedirect", "scopus",
|
|
19
|
+
"springer")
|
|
20
|
+
DEFAULT_SOURCES = ("openalex", "crossref", "semanticscholar")
|
|
21
|
+
# The key each source uses: (key group). A source not listed takes no key.
|
|
22
|
+
KEY_GROUP = {"semanticscholar": "semanticscholar", "ieee": "ieee", "sciencedirect": "elsevier",
|
|
23
|
+
"scopus": "elsevier", "springer": "springer"}
|
|
24
|
+
KEY_GROUPS = ("semanticscholar", "ieee", "elsevier", "springer")
|
|
25
|
+
NEEDS_KEY = frozenset({"ieee", "sciencedirect", "scopus", "springer"})
|
|
26
|
+
ENV_NAME = {"semanticscholar": "SEMANTIC_SCHOLAR_API_KEY", "ieee": "IEEE_API_KEY",
|
|
27
|
+
"elsevier": "ELSEVIER_API_KEY", "springer": "SPRINGER_API_KEY"}
|
|
28
|
+
CREDENTIAL_TYPE = "literature-key"
|
|
29
|
+
CHECK_TIMEOUT_S = 20.0
|
|
30
|
+
_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SourcesRefused(ValueError):
|
|
34
|
+
"""A sources setting the bundled PureScholar cannot use; the message says why."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sources_of(setting: dict[str, Any]) -> list[str]:
|
|
38
|
+
"""The enabled sources; a setting saved before KB-039 has the defaults."""
|
|
39
|
+
return list(setting.get("sources") or DEFAULT_SOURCES)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def keys_needed(setting: dict[str, Any]) -> dict[str, str]:
|
|
43
|
+
"""{key group: credential name} for the enabled sources that have a key (INV-2: only
|
|
44
|
+
these are decrypted by the literature-service task)."""
|
|
45
|
+
names = setting.get("source_keys") or {}
|
|
46
|
+
out: dict[str, str] = {}
|
|
47
|
+
for source in sources_of(setting):
|
|
48
|
+
group = KEY_GROUP.get(source)
|
|
49
|
+
if group and names.get(group):
|
|
50
|
+
out[group] = str(names[group])
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate(sources: Iterable[str], source_keys: dict[str, str], email: str | None,
|
|
55
|
+
credential_types: dict[str, str]) -> None:
|
|
56
|
+
"""Raises SourcesRefused for: no source on, an unknown source or key group, a publisher
|
|
57
|
+
source on without its key, a key that is not a stored literature-source key, or an email
|
|
58
|
+
that is not an address."""
|
|
59
|
+
chosen = list(sources)
|
|
60
|
+
if not chosen:
|
|
61
|
+
raise SourcesRefused("turn at least one literature source on")
|
|
62
|
+
unknown = sorted(set(chosen) - set(SOURCES))
|
|
63
|
+
if unknown:
|
|
64
|
+
raise SourcesRefused(f"unknown literature sources: {', '.join(unknown)}")
|
|
65
|
+
if len(set(chosen)) != len(chosen):
|
|
66
|
+
raise SourcesRefused("a literature source is listed twice")
|
|
67
|
+
for group, name in source_keys.items():
|
|
68
|
+
if group not in KEY_GROUPS:
|
|
69
|
+
raise SourcesRefused(f"{group!r} takes no key")
|
|
70
|
+
if credential_types.get(name) != CREDENTIAL_TYPE:
|
|
71
|
+
raise SourcesRefused(f"no {CREDENTIAL_TYPE} credential named {name!r}")
|
|
72
|
+
for source in chosen:
|
|
73
|
+
if source in NEEDS_KEY and not source_keys.get(KEY_GROUP[source]):
|
|
74
|
+
raise SourcesRefused(f"{source} needs its key before it can be turned on")
|
|
75
|
+
if email and (len(email) > 254 or not _EMAIL.match(email)):
|
|
76
|
+
raise SourcesRefused("the OpenAlex contact email is not an email address")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def env(setting: dict[str, Any], lookup: Callable[[str], str]) -> dict[str, str]:
|
|
80
|
+
"""The container environment for the sources (08 §5 step 12)."""
|
|
81
|
+
out = {"EXTERNAL_SOURCES": ",".join(sources_of(setting))}
|
|
82
|
+
email = str(setting.get("openalex_email") or "")
|
|
83
|
+
if email:
|
|
84
|
+
out["OPENALEX_MAILTO"] = email
|
|
85
|
+
for group, name in keys_needed(setting).items():
|
|
86
|
+
out[ENV_NAME[group]] = lookup(name)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- the key check (08 §5 step 3) ---------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _request(group: str, key: str) -> tuple[str, dict[str, str], dict[str, str]]:
|
|
93
|
+
"""(url, params, headers) for one small search with the key, as PureScholar sends it."""
|
|
94
|
+
if group == "semanticscholar":
|
|
95
|
+
return ("https://api.semanticscholar.org/graph/v1/paper/search",
|
|
96
|
+
{"query": "reproducibility", "limit": "1", "fields": "title"},
|
|
97
|
+
{"x-api-key": key})
|
|
98
|
+
if group == "ieee":
|
|
99
|
+
return ("https://ieeexploreapi.ieee.org/api/v1/search/articles",
|
|
100
|
+
{"querytext": "reproducibility", "max_records": "1", "apikey": key}, {})
|
|
101
|
+
if group == "elsevier":
|
|
102
|
+
return ("https://api.elsevier.com/content/search/scopus",
|
|
103
|
+
{"query": "reproducibility", "count": "1"},
|
|
104
|
+
{"X-ELS-APIKey": key, "Accept": "application/json"})
|
|
105
|
+
if group == "springer":
|
|
106
|
+
return ("https://api.springernature.com/meta/v2/json",
|
|
107
|
+
{"q": "reproducibility", "p": "1", "api_key": key}, {})
|
|
108
|
+
raise ValueError(f"unknown key group {group!r}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check(group: str, key: str, client: httpx.Client | None = None) -> list[str]:
|
|
112
|
+
"""One search request with the key. Returns the problems; empty means passed. Messages
|
|
113
|
+
never include the request URL, which carries some keys."""
|
|
114
|
+
url, params, headers = _request(group, key)
|
|
115
|
+
own = client is None
|
|
116
|
+
http = client or httpx.Client(timeout=CHECK_TIMEOUT_S)
|
|
117
|
+
try:
|
|
118
|
+
resp = http.get(url, params=params, headers=headers)
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
return [f"{group} did not answer ({type(exc).__name__})"]
|
|
121
|
+
finally:
|
|
122
|
+
if own:
|
|
123
|
+
http.close()
|
|
124
|
+
if resp.status_code in (401, 403):
|
|
125
|
+
return [f"{group} refused the key (HTTP {resp.status_code})"]
|
|
126
|
+
if resp.status_code == 429:
|
|
127
|
+
return [f"{group} is rate limiting this machine (HTTP 429); try the check again in a "
|
|
128
|
+
"minute"]
|
|
129
|
+
if resp.status_code >= 400:
|
|
130
|
+
return [f"{group} answered HTTP {resp.status_code}"]
|
|
131
|
+
return []
|
app/model_endpoints.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Where a profile role's model is reached (07-llm.md §4 Endpoint; 08-app.md INV-11, INV-12;
|
|
2
|
+
KB-036).
|
|
3
|
+
|
|
4
|
+
A stored role names its endpoint as one of:
|
|
5
|
+
- `base_url`: an http(s) URL;
|
|
6
|
+
- `local_port`: a server on the engine's machine (self-hosted only);
|
|
7
|
+
- `machine` and `remote_port`: a server on an SSH machine, reached through a tunnel.
|
|
8
|
+
|
|
9
|
+
When a run is created, `expand` turns the role into what the worker needs: a `base_url`, or
|
|
10
|
+
an `ssh` block with the machine's resolved connection (the run's snapshot, like its
|
|
11
|
+
executor's), and `public_only` in hosted mode."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from app.addresses import private_address
|
|
20
|
+
from app.settings import Mode
|
|
21
|
+
|
|
22
|
+
ENDPOINT_KEYS = ("base_url", "local_port", "machine")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EndpointRefused(ValueError):
|
|
26
|
+
"""The role's endpoint breaks a rule; the message says which and how to fix it."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _port(value: Any, what: str) -> int:
|
|
30
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535:
|
|
31
|
+
raise EndpointRefused(f"{what} must be a port number")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check(spec: dict[str, Any], mode: Mode, machine_ids: set[str]) -> None:
|
|
36
|
+
"""Refuses a role endpoint that breaks KB-036's rules. Hosted providers keep their
|
|
37
|
+
optional `base_url`, under the same address rules."""
|
|
38
|
+
given = [k for k in ENDPOINT_KEYS if spec.get(k) is not None]
|
|
39
|
+
if len(given) > 1:
|
|
40
|
+
raise EndpointRefused(f"give one of {', '.join(ENDPOINT_KEYS)}, not several")
|
|
41
|
+
if given and given[0] != "base_url" and spec.get("provider") != "openai_compatible":
|
|
42
|
+
raise EndpointRefused("a local or SSH endpoint needs provider openai_compatible")
|
|
43
|
+
if spec.get("provider") == "openai_compatible" and not given:
|
|
44
|
+
raise EndpointRefused("an openai_compatible model needs base_url, local_port or "
|
|
45
|
+
"machine")
|
|
46
|
+
if "local_port" in given:
|
|
47
|
+
if mode == "hosted": # INV-11: "this computer" is the platform's server
|
|
48
|
+
raise EndpointRefused("hosted puremethod has no local models; use an SSH machine "
|
|
49
|
+
"or a public URL")
|
|
50
|
+
_port(spec["local_port"], "local_port")
|
|
51
|
+
if "machine" in given:
|
|
52
|
+
if str(spec["machine"]) not in machine_ids:
|
|
53
|
+
raise EndpointRefused(f"no SSH machine {spec['machine']!r}")
|
|
54
|
+
_port(spec.get("remote_port"), "remote_port")
|
|
55
|
+
if "base_url" in given:
|
|
56
|
+
url = str(spec["base_url"])
|
|
57
|
+
parts = urlsplit(url)
|
|
58
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
59
|
+
raise EndpointRefused("base_url must be an http or https URL")
|
|
60
|
+
private = private_address(parts.hostname)
|
|
61
|
+
if mode == "hosted" and private: # INV-11
|
|
62
|
+
raise EndpointRefused(f"{parts.hostname} is not a public address; hosted "
|
|
63
|
+
"puremethod does not call it. Use an SSH machine instead")
|
|
64
|
+
if parts.scheme == "http" and not private and spec.get("allow_unencrypted") is not True:
|
|
65
|
+
# INV-12: prompts and code would cross the network unencrypted.
|
|
66
|
+
raise EndpointRefused(f"{url} is plain http to a public address: prompts and code "
|
|
67
|
+
"would cross the network unencrypted. Use https or an SSH "
|
|
68
|
+
"machine, or confirm with allow_unencrypted")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def expand(spec: dict[str, Any], mode: Mode,
|
|
72
|
+
resolve_machine: Callable[[str], dict[str, Any]]) -> dict[str, Any]:
|
|
73
|
+
"""The role as a run uses it (see the module docstring)."""
|
|
74
|
+
out = {k: v for k, v in spec.items()
|
|
75
|
+
if k not in ("local_port", "machine", "remote_port", "allow_unencrypted")}
|
|
76
|
+
if spec.get("local_port") is not None:
|
|
77
|
+
out["base_url"] = f"http://127.0.0.1:{int(spec['local_port'])}/v1"
|
|
78
|
+
if spec.get("machine") is not None:
|
|
79
|
+
conn = resolve_machine(str(spec["machine"]))
|
|
80
|
+
target = {k: conn[k] for k in ("host", "user", "port", "key_credential", "host_key")}
|
|
81
|
+
out["ssh"] = {"machine": spec["machine"], "remote_port": int(spec["remote_port"]),
|
|
82
|
+
"hops": [*conn.get("jumps", []), target]}
|
|
83
|
+
if mode == "hosted" and out.get("base_url"):
|
|
84
|
+
out["public_only"] = True # INV-11, checked again at every call
|
|
85
|
+
if isinstance(spec.get("fallback"), dict):
|
|
86
|
+
out["fallback"] = expand(spec["fallback"], mode, resolve_machine)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ssh_credentials(llm: dict[str, dict[str, Any]]) -> list[str]:
|
|
91
|
+
"""The SSH key credentials an expanded profile's tunnels use (08 §5 step 3)."""
|
|
92
|
+
names: list[str] = []
|
|
93
|
+
for spec in llm.values():
|
|
94
|
+
for s in (spec, spec.get("fallback") or {}):
|
|
95
|
+
ssh = s.get("ssh") if isinstance(s, dict) else None
|
|
96
|
+
if isinstance(ssh, dict):
|
|
97
|
+
names += [str(h["key_credential"]) for h in ssh.get("hops", [])]
|
|
98
|
+
return sorted(set(names))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def machines_used(spec: dict[str, Any]) -> list[str]:
|
|
102
|
+
out = [str(spec["machine"])] if spec.get("machine") else []
|
|
103
|
+
fb = spec.get("fallback")
|
|
104
|
+
return out + (machines_used(fb) if isinstance(fb, dict) else [])
|