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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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 [])