sobres 1.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.
- sobres/__about__.py +1 -0
- sobres/__init__.py +5 -0
- sobres/api/__init__.py +19 -0
- sobres/api/app.py +496 -0
- sobres/api/auth.py +117 -0
- sobres/api/jobs.py +260 -0
- sobres/api/static/assets/BacktestCharts-C7phgH_S.js +1 -0
- sobres/api/static/assets/EChart-BHrkUjbY.js +1 -0
- sobres/api/static/assets/FrontierChart-B3J3HsGE.js +1 -0
- sobres/api/static/assets/echarts-BpE1uIOo.js +39 -0
- sobres/api/static/assets/index-DikqmPZ6.js +68 -0
- sobres/api/static/assets/index-pgZOLb7x.css +1 -0
- sobres/api/static/assets/motion-D99TpcCF.js +17 -0
- sobres/api/static/index.html +25 -0
- sobres/api/static/manifest.json +80 -0
- sobres/cli/__init__.py +0 -0
- sobres/cli/commands/__init__.py +29 -0
- sobres/cli/commands/analyze.py +390 -0
- sobres/cli/commands/cache.py +59 -0
- sobres/cli/commands/commands.py +26 -0
- sobres/cli/commands/config.py +85 -0
- sobres/cli/commands/data.py +135 -0
- sobres/cli/commands/db.py +103 -0
- sobres/cli/commands/deploy.py +102 -0
- sobres/cli/commands/doctor.py +105 -0
- sobres/cli/commands/econ.py +880 -0
- sobres/cli/commands/fx.py +436 -0
- sobres/cli/commands/init.py +227 -0
- sobres/cli/commands/optimize.py +925 -0
- sobres/cli/commands/plan.py +648 -0
- sobres/cli/commands/portfolio.py +122 -0
- sobres/cli/commands/ppp.py +398 -0
- sobres/cli/commands/run.py +174 -0
- sobres/cli/commands/serve.py +263 -0
- sobres/cli/commands/trade.py +892 -0
- sobres/cli/commands/upgrade.py +68 -0
- sobres/cli/commands/watchlist.py +112 -0
- sobres/cli/context.py +237 -0
- sobres/cli/main.py +223 -0
- sobres/cli/metric_table.py +45 -0
- sobres/cli/render.py +130 -0
- sobres/cli/window.py +27 -0
- sobres/config.py +221 -0
- sobres/core/__init__.py +5 -0
- sobres/core/allocate.py +114 -0
- sobres/core/backtest.py +241 -0
- sobres/core/conventions.py +59 -0
- sobres/core/errors.py +120 -0
- sobres/core/factors.py +265 -0
- sobres/core/forecast.py +1045 -0
- sobres/core/fx.py +170 -0
- sobres/core/goals.py +408 -0
- sobres/core/moments.py +221 -0
- sobres/core/optimize.py +545 -0
- sobres/core/ppp.py +303 -0
- sobres/core/rates.py +58 -0
- sobres/core/regression.py +145 -0
- sobres/core/returns.py +116 -0
- sobres/core/risk.py +216 -0
- sobres/core/simulate.py +145 -0
- sobres/core/timeseries.py +308 -0
- sobres/core/trading.py +318 -0
- sobres/core/validation.py +27 -0
- sobres/data/__init__.py +5 -0
- sobres/data/align.py +62 -0
- sobres/data/base.py +212 -0
- sobres/data/brokers/__init__.py +1 -0
- sobres/data/brokers/alpaca.py +340 -0
- sobres/data/brokers/base.py +174 -0
- sobres/data/brokers/fake.py +218 -0
- sobres/data/cache.py +213 -0
- sobres/data/currency.py +277 -0
- sobres/data/ecb_provider.py +157 -0
- sobres/data/fixtures.py +355 -0
- sobres/data/fred_provider.py +196 -0
- sobres/data/gaps.py +83 -0
- sobres/data/ken_french.py +212 -0
- sobres/data/ppp_provider.py +346 -0
- sobres/data/providers.py +77 -0
- sobres/data/storage/__init__.py +37 -0
- sobres/data/storage/adapters/__init__.py +8 -0
- sobres/data/storage/adapters/migrations.py +61 -0
- sobres/data/storage/adapters/schema.py +257 -0
- sobres/data/storage/adapters/sqlite.py +1233 -0
- sobres/data/storage/base.py +507 -0
- sobres/data/yfinance_provider.py +319 -0
- sobres/deploy.py +304 -0
- sobres/doctor.py +532 -0
- sobres/observability/__init__.py +9 -0
- sobres/observability/logging.py +152 -0
- sobres/observability/redaction.py +64 -0
- sobres/observability/tracing.py +224 -0
- sobres/py.typed +0 -0
- sobres/registry.py +583 -0
- sobres/results.py +215 -0
- sobres/settings.py +409 -0
- sobres/solvers/__init__.py +1 -0
- sobres/solvers/base.py +38 -0
- sobres/solvers/scipy.py +35 -0
- sobres-1.1.0.dist-info/METADATA +485 -0
- sobres-1.1.0.dist-info/RECORD +104 -0
- sobres-1.1.0.dist-info/WHEEL +4 -0
- sobres-1.1.0.dist-info/entry_points.txt +2 -0
- sobres-1.1.0.dist-info/licenses/LICENSE +21 -0
sobres/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
sobres/__init__.py
ADDED
sobres/api/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The HTTP adapter (0004): FastAPI routes generated from the command registry.
|
|
2
|
+
|
|
3
|
+
No business logic lives here — request handling, validation, serialization,
|
|
4
|
+
job dispatch and access control only. ``pip install sobres[web]`` brings the
|
|
5
|
+
runtime; the built SPA ships under ``static/``. ``create_app`` is imported
|
|
6
|
+
lazily so the base install (no FastAPI) can still import ``sobres.api.auth``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = ["create_app"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_app(*args: Any, **kwargs: Any) -> Any:
|
|
17
|
+
from sobres.api.app import create_app as _create_app
|
|
18
|
+
|
|
19
|
+
return _create_app(*args, **kwargs)
|
sobres/api/app.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""``create_app``: the FastAPI application, generated from the command registry.
|
|
2
|
+
|
|
3
|
+
Every registered command becomes ``POST /api/v1/<group>/<name>`` with its
|
|
4
|
+
parameter model as the request body — one validation path shared with the
|
|
5
|
+
CLI. Long-running commands are dispatched as jobs (``202 {job_id}``) and
|
|
6
|
+
stream progress over SSE. Errors map 0001's taxonomy onto HTTP status codes
|
|
7
|
+
and carry the same actionable message the CLI prints, plus the run id.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI, Request, Response
|
|
21
|
+
from fastapi.exceptions import RequestValidationError
|
|
22
|
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
23
|
+
from fastapi.staticfiles import StaticFiles
|
|
24
|
+
from pydantic import BaseModel, ValidationError
|
|
25
|
+
|
|
26
|
+
from sobres.__about__ import __version__
|
|
27
|
+
from sobres.api import auth
|
|
28
|
+
from sobres.api.jobs import JobRunner, job_event
|
|
29
|
+
from sobres.cli.context import Context
|
|
30
|
+
from sobres.config import Config, display_value, process_environment, resolve
|
|
31
|
+
from sobres.core.errors import (
|
|
32
|
+
ConfigurationError,
|
|
33
|
+
InsufficientDataError,
|
|
34
|
+
ProviderError,
|
|
35
|
+
SobresError,
|
|
36
|
+
UsageError,
|
|
37
|
+
)
|
|
38
|
+
from sobres.data.storage.base import TERMINAL_JOB_STATES, OpenOptions, Storage, open_storage
|
|
39
|
+
from sobres.deploy import require_data_volume
|
|
40
|
+
from sobres.doctor import run_checks
|
|
41
|
+
from sobres.observability import get_logger, new_run_id, run_id, span
|
|
42
|
+
from sobres.observability.tracing import current_trace_ids
|
|
43
|
+
from sobres.registry import Command, all_commands, command_schema, validate_params
|
|
44
|
+
from sobres.settings import all_settings, get_setting
|
|
45
|
+
|
|
46
|
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
47
|
+
API_PREFIX = "/api/v1"
|
|
48
|
+
READINESS_CHECKS = ("config-file", "db-reachable", "db-schema", "disk-space", "data-volume")
|
|
49
|
+
PUBLIC_PATHS = {f"{API_PREFIX}/health", f"{API_PREFIX}/auth/login", f"{API_PREFIX}/auth/logout"}
|
|
50
|
+
STATUS_FOR: dict[type[SobresError], int] = {
|
|
51
|
+
UsageError: 400,
|
|
52
|
+
ConfigurationError: 400,
|
|
53
|
+
ProviderError: 502,
|
|
54
|
+
InsufficientDataError: 422,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LoginBody(BaseModel):
|
|
59
|
+
token: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SettingBody(BaseModel):
|
|
63
|
+
key: str
|
|
64
|
+
value: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class VerifyBody(BaseModel):
|
|
68
|
+
value: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AppState:
|
|
73
|
+
environ: dict[str, str]
|
|
74
|
+
config: Config
|
|
75
|
+
storage: Storage
|
|
76
|
+
runner: JobRunner
|
|
77
|
+
require_token: bool
|
|
78
|
+
sources: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
limiter: auth.RateLimiter = field(default_factory=auth.RateLimiter)
|
|
80
|
+
|
|
81
|
+
def context(self) -> Context:
|
|
82
|
+
return Context(
|
|
83
|
+
config=self.config,
|
|
84
|
+
environ=self.environ,
|
|
85
|
+
interactive=False,
|
|
86
|
+
sources=dict(self.sources),
|
|
87
|
+
surface="api",
|
|
88
|
+
_storage=self.storage,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_app(
|
|
93
|
+
environ: Mapping[str, str] | None = None,
|
|
94
|
+
*,
|
|
95
|
+
require_token: bool = False,
|
|
96
|
+
config_path: Path | None = None,
|
|
97
|
+
sources: dict[str, Any] | None = None,
|
|
98
|
+
start_worker: bool = True,
|
|
99
|
+
static_dir: Path | None = None,
|
|
100
|
+
) -> FastAPI:
|
|
101
|
+
"""Build the application over one resolved configuration and one opened storage."""
|
|
102
|
+
env = process_environment() if environ is None else dict(environ)
|
|
103
|
+
config = resolve(None, env, path=config_path)
|
|
104
|
+
if config.error is not None:
|
|
105
|
+
raise config.error
|
|
106
|
+
require_data_volume(config)
|
|
107
|
+
storage = open_storage(config.db_url, OpenOptions())
|
|
108
|
+
runner = JobRunner(storage, config, env, sources=sources)
|
|
109
|
+
state = AppState(
|
|
110
|
+
environ=env,
|
|
111
|
+
config=config,
|
|
112
|
+
storage=storage,
|
|
113
|
+
runner=runner,
|
|
114
|
+
require_token=require_token,
|
|
115
|
+
sources=dict(sources or {}),
|
|
116
|
+
)
|
|
117
|
+
app = FastAPI(
|
|
118
|
+
title="sobres",
|
|
119
|
+
version=__version__,
|
|
120
|
+
description="Every CLI command as POST /api/v1/<group>/<name>; long-running ones as jobs.",
|
|
121
|
+
docs_url="/api/docs",
|
|
122
|
+
openapi_url="/api/openapi.json",
|
|
123
|
+
redoc_url=None,
|
|
124
|
+
)
|
|
125
|
+
app.state.sobres = state
|
|
126
|
+
log = get_logger("sobres.api")
|
|
127
|
+
|
|
128
|
+
@app.on_event("startup")
|
|
129
|
+
def _start() -> None:
|
|
130
|
+
if start_worker:
|
|
131
|
+
runner.start()
|
|
132
|
+
|
|
133
|
+
@app.on_event("shutdown")
|
|
134
|
+
def _stop() -> None:
|
|
135
|
+
runner.stop()
|
|
136
|
+
storage.close()
|
|
137
|
+
|
|
138
|
+
# ----------------------------------------------------------- middleware
|
|
139
|
+
@app.middleware("http")
|
|
140
|
+
async def _observe(request: Request, call_next: Callable[[Request], Any]) -> Response:
|
|
141
|
+
rid = new_run_id()
|
|
142
|
+
started = time.perf_counter()
|
|
143
|
+
carrier = {k: v for k, v in request.headers.items() if k in ("traceparent", "tracestate")}
|
|
144
|
+
route_name = _registry_name(request.url.path)
|
|
145
|
+
with span(
|
|
146
|
+
"http.request",
|
|
147
|
+
{"http.method": request.method, "http.route": request.url.path, "command": route_name},
|
|
148
|
+
carrier=carrier,
|
|
149
|
+
) as sp:
|
|
150
|
+
denied = _authorize(request, state)
|
|
151
|
+
if denied is not None:
|
|
152
|
+
response: Response = denied
|
|
153
|
+
else:
|
|
154
|
+
response = await call_next(request)
|
|
155
|
+
sp.set_attribute("http.status_code", response.status_code)
|
|
156
|
+
ids = current_trace_ids()
|
|
157
|
+
response.headers["X-Run-Id"] = rid
|
|
158
|
+
if ids is not None:
|
|
159
|
+
response.headers["X-Trace-Id"] = ids[0]
|
|
160
|
+
log.info(
|
|
161
|
+
"http.request",
|
|
162
|
+
method=request.method,
|
|
163
|
+
path=request.url.path,
|
|
164
|
+
status=response.status_code,
|
|
165
|
+
elapsed_ms=round((time.perf_counter() - started) * 1000, 2),
|
|
166
|
+
)
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------ errors
|
|
170
|
+
@app.exception_handler(SobresError)
|
|
171
|
+
async def _sobres_error(request: Request, exc: SobresError) -> JSONResponse:
|
|
172
|
+
status = next((code for cls, code in STATUS_FOR.items() if isinstance(exc, cls)), 500)
|
|
173
|
+
body = {
|
|
174
|
+
"error": exc.message,
|
|
175
|
+
"hint": exc.hint,
|
|
176
|
+
"error_class": type(exc).__name__,
|
|
177
|
+
"exit_code": exc.exit_code,
|
|
178
|
+
"run_id": run_id(),
|
|
179
|
+
}
|
|
180
|
+
return JSONResponse(body, status_code=status)
|
|
181
|
+
|
|
182
|
+
@app.exception_handler(RequestValidationError)
|
|
183
|
+
async def _request_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
184
|
+
# Same shape and status as the CLI's usage error: name the field and the constraint.
|
|
185
|
+
problems = [
|
|
186
|
+
".".join(str(p) for p in err.get("loc", ()) if p != "body") + ": " + str(err.get("msg"))
|
|
187
|
+
for err in exc.errors()
|
|
188
|
+
]
|
|
189
|
+
return JSONResponse(
|
|
190
|
+
{
|
|
191
|
+
"error": "invalid parameters: " + "; ".join(problems),
|
|
192
|
+
"hint": "see /api/docs for the parameter schema",
|
|
193
|
+
"error_class": "UsageError",
|
|
194
|
+
"exit_code": 2,
|
|
195
|
+
"run_id": run_id(),
|
|
196
|
+
},
|
|
197
|
+
status_code=400,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@app.exception_handler(ValidationError)
|
|
201
|
+
async def _validation_error(request: Request, exc: ValidationError) -> JSONResponse:
|
|
202
|
+
return JSONResponse({"error": str(exc), "run_id": run_id()}, status_code=400)
|
|
203
|
+
|
|
204
|
+
@app.exception_handler(Exception)
|
|
205
|
+
async def _internal(request: Request, exc: Exception) -> JSONResponse:
|
|
206
|
+
log.error("http.internal_error", error=f"{type(exc).__name__}: {exc}", exc_info=True)
|
|
207
|
+
return JSONResponse(
|
|
208
|
+
{
|
|
209
|
+
"error": "internal error",
|
|
210
|
+
"hint": f"quote run id {run_id()} when reporting this",
|
|
211
|
+
"run_id": run_id(),
|
|
212
|
+
},
|
|
213
|
+
status_code=500,
|
|
214
|
+
# Starlette's ServerErrorMiddleware handles this outside ``_observe``.
|
|
215
|
+
headers={"X-Run-Id": run_id() or ""},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------ core routes
|
|
219
|
+
@app.get(f"{API_PREFIX}/health", tags=["meta"])
|
|
220
|
+
def health() -> dict[str, Any]:
|
|
221
|
+
# Readiness is doctor's own checks (the fast, offline ones), not a second notion of health.
|
|
222
|
+
reports = run_checks(state.context(), offline=True, only=READINESS_CHECKS)
|
|
223
|
+
checks = {r.name: r.status for r in reports}
|
|
224
|
+
return {
|
|
225
|
+
"app": "sobres",
|
|
226
|
+
"version": __version__,
|
|
227
|
+
"ok": True,
|
|
228
|
+
"ready": not any(s == "fail" for s in checks.values()),
|
|
229
|
+
"checks": checks,
|
|
230
|
+
"token_required": state.require_token,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@app.get(f"{API_PREFIX}/commands", tags=["meta"])
|
|
234
|
+
def commands() -> dict[str, Any]:
|
|
235
|
+
return {"commands": [command_schema(c) for c in all_commands()]}
|
|
236
|
+
|
|
237
|
+
@app.post(f"{API_PREFIX}/auth/login", tags=["auth"])
|
|
238
|
+
def login(body: LoginBody, request: Request, response: Response) -> dict[str, Any]:
|
|
239
|
+
source = request.client.host if request.client else "unknown"
|
|
240
|
+
if state.require_token and not state.limiter.allow(source):
|
|
241
|
+
return JSONResponse({"error": "too many attempts"}, status_code=429) # type: ignore[return-value]
|
|
242
|
+
if not state.require_token:
|
|
243
|
+
return {"ok": True, "token_required": False}
|
|
244
|
+
if not auth.verify_token(body.token, auth.stored_token(state.storage.kv)):
|
|
245
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401) # type: ignore[return-value]
|
|
246
|
+
response.set_cookie(
|
|
247
|
+
auth.COOKIE_NAME, body.token, httponly=True, samesite="strict", secure=False
|
|
248
|
+
)
|
|
249
|
+
return {"ok": True, "token_required": True}
|
|
250
|
+
|
|
251
|
+
@app.post(f"{API_PREFIX}/auth/logout", tags=["auth"])
|
|
252
|
+
def logout(response: Response) -> dict[str, Any]:
|
|
253
|
+
response.delete_cookie(auth.COOKIE_NAME)
|
|
254
|
+
return {"ok": True}
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------ settings
|
|
257
|
+
@app.get(f"{API_PREFIX}/settings", tags=["settings"])
|
|
258
|
+
def settings_list() -> dict[str, Any]:
|
|
259
|
+
cfg = resolve(None, state.environ, path=state.config.path)
|
|
260
|
+
return {
|
|
261
|
+
"settings": [
|
|
262
|
+
{
|
|
263
|
+
"key": s.key,
|
|
264
|
+
"env": s.env,
|
|
265
|
+
"description": s.description,
|
|
266
|
+
"type": s.type,
|
|
267
|
+
"secret": s.secret,
|
|
268
|
+
"required": s.required,
|
|
269
|
+
"obtain": s.obtain,
|
|
270
|
+
"affects": list(s.affects),
|
|
271
|
+
"choices": list(s.choices),
|
|
272
|
+
"has_live_validator": s.validate_live is not None,
|
|
273
|
+
"browser_editable": s.browser_editable,
|
|
274
|
+
"value": display_value(s, cfg.get(s.key)),
|
|
275
|
+
"source": cfg.source(s.key),
|
|
276
|
+
"default": s.default,
|
|
277
|
+
}
|
|
278
|
+
for s in all_settings()
|
|
279
|
+
],
|
|
280
|
+
"config_path": str(state.config.path),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@app.put(f"{API_PREFIX}/settings", tags=["settings"])
|
|
284
|
+
def settings_put(body: SettingBody) -> dict[str, Any]:
|
|
285
|
+
# The same code path as `sobres config set`: validate, then write the file at 0600.
|
|
286
|
+
# Settings that move data or traces elsewhere are refused here, before that path
|
|
287
|
+
# runs; they stay available from the terminal.
|
|
288
|
+
from sobres.cli.commands.config import ConfigSetParams, config_set
|
|
289
|
+
|
|
290
|
+
if not get_setting(body.key).browser_editable:
|
|
291
|
+
raise UsageError(
|
|
292
|
+
f"{body.key} cannot be changed from the browser",
|
|
293
|
+
hint=f"run: sobres config set {body.key} <value>",
|
|
294
|
+
)
|
|
295
|
+
result = config_set(ConfigSetParams(key=body.key, value=body.value), state.context())
|
|
296
|
+
state.config = resolve(None, state.environ, path=state.config.path)
|
|
297
|
+
state.runner._config = state.config
|
|
298
|
+
return {"ok": True, "message": result.summary_line()}
|
|
299
|
+
|
|
300
|
+
@app.post(f"{API_PREFIX}/settings/{{key}}/verify", tags=["settings"])
|
|
301
|
+
def settings_verify(key: str, body: VerifyBody) -> dict[str, Any]:
|
|
302
|
+
setting = get_setting(key)
|
|
303
|
+
if setting.validate_live is None:
|
|
304
|
+
raise UsageError(f"{key} has no live validator")
|
|
305
|
+
value = body.value if body.value is not None else state.config.get(key)
|
|
306
|
+
if not value:
|
|
307
|
+
raise UsageError(f"{key} is not set; nothing to verify")
|
|
308
|
+
outcome = setting.validate_live(str(value))
|
|
309
|
+
return {"ok": outcome.ok, "message": outcome.message}
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------- jobs
|
|
312
|
+
@app.get(f"{API_PREFIX}/jobs", tags=["jobs"])
|
|
313
|
+
def jobs_list(limit: int = 20, state_filter: str | None = None) -> dict[str, Any]:
|
|
314
|
+
return {
|
|
315
|
+
"jobs": [job_event(j) for j in state.storage.jobs.list(limit=limit, state=state_filter)]
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@app.get(f"{API_PREFIX}/jobs/{{job_id}}", tags=["jobs"])
|
|
319
|
+
def job_get(job_id: str) -> dict[str, Any]:
|
|
320
|
+
job = state.storage.jobs.get(job_id)
|
|
321
|
+
if job is None:
|
|
322
|
+
raise UsageError(f"no job {job_id!r}")
|
|
323
|
+
return job_event(job)
|
|
324
|
+
|
|
325
|
+
@app.post(f"{API_PREFIX}/jobs/{{job_id}}/cancel", tags=["jobs"])
|
|
326
|
+
def job_cancel(job_id: str) -> dict[str, Any]:
|
|
327
|
+
job = state.runner.cancel(job_id)
|
|
328
|
+
if job is None:
|
|
329
|
+
raise UsageError(f"no job {job_id!r}")
|
|
330
|
+
return job_event(job, "cancel requested")
|
|
331
|
+
|
|
332
|
+
@app.get(f"{API_PREFIX}/jobs/{{job_id}}/events", tags=["jobs"])
|
|
333
|
+
def job_events(job_id: str) -> StreamingResponse:
|
|
334
|
+
job = state.storage.jobs.get(job_id)
|
|
335
|
+
if job is None:
|
|
336
|
+
raise UsageError(f"no job {job_id!r}")
|
|
337
|
+
return StreamingResponse(
|
|
338
|
+
_sse(state, job_id),
|
|
339
|
+
media_type="text/event-stream",
|
|
340
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------- registry
|
|
344
|
+
for cmd in all_commands():
|
|
345
|
+
app.add_api_route(
|
|
346
|
+
cmd.route,
|
|
347
|
+
_make_endpoint(cmd, state),
|
|
348
|
+
methods=["POST"],
|
|
349
|
+
name=cmd.name,
|
|
350
|
+
summary=cmd.help,
|
|
351
|
+
tags=[cmd.group or "root"],
|
|
352
|
+
status_code=202 if cmd.long_running else 200,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# ----------------------------------------------------------- frontend
|
|
356
|
+
assets = (static_dir or STATIC_DIR) / "assets"
|
|
357
|
+
if assets.is_dir():
|
|
358
|
+
app.mount("/assets", StaticFiles(directory=assets), name="assets")
|
|
359
|
+
|
|
360
|
+
@app.get("/manifest.json", include_in_schema=False)
|
|
361
|
+
def manifest() -> Response:
|
|
362
|
+
path = (static_dir or STATIC_DIR) / "manifest.json"
|
|
363
|
+
if path.exists():
|
|
364
|
+
return Response(path.read_text(encoding="utf-8"), media_type="application/json")
|
|
365
|
+
return JSONResponse({"views": {}}, status_code=404)
|
|
366
|
+
|
|
367
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
368
|
+
def spa(path: str) -> Response:
|
|
369
|
+
root = static_dir or STATIC_DIR
|
|
370
|
+
candidate = root / path
|
|
371
|
+
if path and candidate.is_file() and candidate.resolve().is_relative_to(root.resolve()):
|
|
372
|
+
media = "text/javascript" if path.endswith(".js") else None
|
|
373
|
+
return Response(candidate.read_bytes(), media_type=media)
|
|
374
|
+
index = root / "index.html"
|
|
375
|
+
if index.exists():
|
|
376
|
+
return HTMLResponse(index.read_text(encoding="utf-8"))
|
|
377
|
+
return HTMLResponse(_PLACEHOLDER, status_code=200)
|
|
378
|
+
|
|
379
|
+
return app
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_PLACEHOLDER = """<!doctype html><title>sobres</title>
|
|
383
|
+
<body style="font-family:system-ui;background:#0b0f14;color:#e6edf3;padding:2rem">
|
|
384
|
+
<h1>sobres API is running</h1>
|
|
385
|
+
<p>The web UI's built assets are not present in this install. The API is at
|
|
386
|
+
<a href="/api/docs" style="color:#7cc4ff">/api/docs</a>. Build the frontend with
|
|
387
|
+
<code>cd frontend && npm ci && npm run build</code>.</p></body>"""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _registry_name(path: str) -> str | None:
|
|
391
|
+
if not path.startswith(API_PREFIX + "/"):
|
|
392
|
+
return None
|
|
393
|
+
return ".".join(path[len(API_PREFIX) + 1 :].split("/"))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _authorize(request: Request, state: AppState) -> Response | None:
|
|
397
|
+
"""401 for API calls without a valid token when one is required; never via query string."""
|
|
398
|
+
path = request.url.path
|
|
399
|
+
if not path.startswith(API_PREFIX) or path in PUBLIC_PATHS or not state.require_token:
|
|
400
|
+
if "token" in request.query_params and path.startswith(API_PREFIX):
|
|
401
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
402
|
+
return None
|
|
403
|
+
if "token" in request.query_params:
|
|
404
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
405
|
+
presented = None
|
|
406
|
+
header = request.headers.get("authorization", "")
|
|
407
|
+
if header.lower().startswith("bearer "):
|
|
408
|
+
presented = header[7:].strip()
|
|
409
|
+
elif auth.COOKIE_NAME in request.cookies:
|
|
410
|
+
presented = request.cookies[auth.COOKIE_NAME]
|
|
411
|
+
if presented and auth.verify_token(presented, auth.stored_token(state.storage.kv)):
|
|
412
|
+
return None
|
|
413
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _make_endpoint(cmd: Command, state: AppState) -> Callable[..., Any]:
|
|
417
|
+
params_model = cmd.params
|
|
418
|
+
|
|
419
|
+
def endpoint(body: BaseModel, request: Request) -> Any:
|
|
420
|
+
raw = body.model_dump(mode="json", exclude_unset=True)
|
|
421
|
+
params = validate_params(cmd, raw)
|
|
422
|
+
if cmd.long_running:
|
|
423
|
+
carrier = {
|
|
424
|
+
k: v for k, v in request.headers.items() if k in ("traceparent", "tracestate")
|
|
425
|
+
}
|
|
426
|
+
job = state.runner.submit(
|
|
427
|
+
cmd,
|
|
428
|
+
params.model_dump(mode="json"),
|
|
429
|
+
run_id=run_id(),
|
|
430
|
+
trace_context=carrier or None,
|
|
431
|
+
)
|
|
432
|
+
return JSONResponse(
|
|
433
|
+
{**job_event(job, "queued"), "url": f"{API_PREFIX}/jobs/{job.id}"}, status_code=202
|
|
434
|
+
)
|
|
435
|
+
ctx = state.context()
|
|
436
|
+
with span(f"api.{cmd.name}", {"command": cmd.name}):
|
|
437
|
+
result = cmd.handler(params, ctx)
|
|
438
|
+
if getattr(params, "save_run", False):
|
|
439
|
+
from sobres.cli.main import record_run
|
|
440
|
+
|
|
441
|
+
record_run(cmd, params, result, ctx)
|
|
442
|
+
return result.payload()
|
|
443
|
+
|
|
444
|
+
endpoint.__signature__ = inspect.Signature( # type: ignore[attr-defined]
|
|
445
|
+
[
|
|
446
|
+
inspect.Parameter(
|
|
447
|
+
"body", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=params_model
|
|
448
|
+
),
|
|
449
|
+
inspect.Parameter(
|
|
450
|
+
"request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
|
|
451
|
+
),
|
|
452
|
+
]
|
|
453
|
+
)
|
|
454
|
+
endpoint.__annotations__ = {"body": params_model, "request": Request, "return": Any}
|
|
455
|
+
endpoint.__name__ = cmd.name.replace(".", "_")
|
|
456
|
+
endpoint.__doc__ = cmd.help
|
|
457
|
+
return endpoint
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _sse(state: AppState, job_id: str) -> Iterator[str]:
|
|
461
|
+
"""Current state first, then every event until the job is terminal."""
|
|
462
|
+
q = state.runner.subscribe(job_id)
|
|
463
|
+
try:
|
|
464
|
+
job = state.storage.jobs.get(job_id)
|
|
465
|
+
if job is None:
|
|
466
|
+
return
|
|
467
|
+
yield _frame(job_event(job, "current"))
|
|
468
|
+
if job.state in TERMINAL_JOB_STATES:
|
|
469
|
+
return
|
|
470
|
+
last_state = job.state
|
|
471
|
+
while True:
|
|
472
|
+
try:
|
|
473
|
+
event = q.get(timeout=1.0)
|
|
474
|
+
except Exception:
|
|
475
|
+
current = state.storage.jobs.get(job_id)
|
|
476
|
+
if current is None:
|
|
477
|
+
return
|
|
478
|
+
if current.state != last_state:
|
|
479
|
+
yield _frame(job_event(current, "state"))
|
|
480
|
+
last_state = current.state
|
|
481
|
+
if current.state in TERMINAL_JOB_STATES:
|
|
482
|
+
return
|
|
483
|
+
yield ": keepalive\n\n"
|
|
484
|
+
continue
|
|
485
|
+
yield _frame(event)
|
|
486
|
+
if event["state"] in TERMINAL_JOB_STATES:
|
|
487
|
+
return
|
|
488
|
+
finally:
|
|
489
|
+
state.runner.unsubscribe(job_id, q)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _frame(event: dict[str, Any]) -> str:
|
|
493
|
+
return f"event: job\ndata: {json.dumps(event, default=str)}\n\n"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
__all__ = ["create_app"]
|
sobres/api/auth.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Deployment token: generated, stored hashed, compared in constant time.
|
|
2
|
+
|
|
3
|
+
A loopback-only server needs no token — the socket is unreachable remotely.
|
|
4
|
+
Any other bind address requires one. Tokens are 32 random bytes (256 bits of
|
|
5
|
+
entropy) as base64url; only a salted PBKDF2 hash is stored, in the key/value
|
|
6
|
+
store, and comparison is constant-time. Presented as a bearer header or an
|
|
7
|
+
httpOnly cookie, never in a query string.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import math
|
|
16
|
+
import secrets
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections import Counter, defaultdict, deque
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sobres.core.errors import UsageError
|
|
23
|
+
from sobres.data.storage.base import KeyValueStore
|
|
24
|
+
|
|
25
|
+
KV_KEY = "auth.token"
|
|
26
|
+
COOKIE_NAME = "sobres_token"
|
|
27
|
+
MIN_TOKEN_BITS = 128
|
|
28
|
+
PBKDF2_ROUNDS = 100_000
|
|
29
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_loopback(host: str) -> bool:
|
|
33
|
+
return host.strip().lower() in LOOPBACK_HOSTS or host.startswith("127.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_token() -> str:
|
|
37
|
+
"""32 random bytes from the OS CSPRNG, base64url without padding."""
|
|
38
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _entropy_bits(token: str) -> float:
|
|
42
|
+
"""Shannon entropy of the characters times the length: a floor on strength."""
|
|
43
|
+
if not token:
|
|
44
|
+
return 0.0
|
|
45
|
+
counts = Counter(token)
|
|
46
|
+
n = len(token)
|
|
47
|
+
per_char = -sum(c / n * math.log2(c / n) for c in counts.values())
|
|
48
|
+
return per_char * n
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_strength(token: str) -> None:
|
|
52
|
+
"""Reject a weak user-supplied token; the tool prefers to generate its own."""
|
|
53
|
+
if len(token) < 22 or _entropy_bits(token) < MIN_TOKEN_BITS:
|
|
54
|
+
raise UsageError(
|
|
55
|
+
"that token is too weak (it needs at least 128 bits of entropy)",
|
|
56
|
+
hint="let sobres generate one: sobres serve token rotate",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def hash_token(token: str, salt: bytes | None = None) -> dict[str, str]:
|
|
61
|
+
salt = salt or secrets.token_bytes(16)
|
|
62
|
+
digest = hashlib.pbkdf2_hmac("sha256", token.encode(), salt, PBKDF2_ROUNDS)
|
|
63
|
+
return {"salt": base64.b64encode(salt).decode(), "hash": base64.b64encode(digest).decode()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_token(token: str, stored: dict[str, Any] | None) -> bool:
|
|
67
|
+
if not stored or not token:
|
|
68
|
+
return False
|
|
69
|
+
salt = base64.b64decode(stored["salt"])
|
|
70
|
+
expected = base64.b64decode(stored["hash"])
|
|
71
|
+
digest = hashlib.pbkdf2_hmac("sha256", token.encode(), salt, PBKDF2_ROUNDS)
|
|
72
|
+
return hmac.compare_digest(digest, expected)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def stored_token(kv: KeyValueStore) -> dict[str, Any] | None:
|
|
76
|
+
value = kv.get(KV_KEY)
|
|
77
|
+
return dict(value) if isinstance(value, dict) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_token(kv: KeyValueStore) -> str | None:
|
|
81
|
+
"""Store a generated token if none exists; return the plaintext only when new."""
|
|
82
|
+
if stored_token(kv) is not None:
|
|
83
|
+
return None
|
|
84
|
+
token = generate_token()
|
|
85
|
+
kv.set(KV_KEY, hash_token(token))
|
|
86
|
+
return token
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rotate_token(kv: KeyValueStore, token: str | None = None) -> str:
|
|
90
|
+
"""Replace the stored token (generated unless supplied and strong enough)."""
|
|
91
|
+
if token is not None:
|
|
92
|
+
validate_strength(token)
|
|
93
|
+
else:
|
|
94
|
+
token = generate_token()
|
|
95
|
+
kv.set(KV_KEY, hash_token(token))
|
|
96
|
+
return token
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RateLimiter:
|
|
100
|
+
"""Per-source sliding window for authentication attempts."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, limit: int = 10, window_s: float = 60.0) -> None:
|
|
103
|
+
self.limit = limit
|
|
104
|
+
self.window_s = window_s
|
|
105
|
+
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
|
106
|
+
self._lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def allow(self, source: str, now: float | None = None) -> bool:
|
|
109
|
+
stamp = time.monotonic() if now is None else now
|
|
110
|
+
with self._lock:
|
|
111
|
+
hits = self._hits[source]
|
|
112
|
+
while hits and hits[0] <= stamp - self.window_s:
|
|
113
|
+
hits.popleft()
|
|
114
|
+
if len(hits) >= self.limit:
|
|
115
|
+
return False
|
|
116
|
+
hits.append(stamp)
|
|
117
|
+
return True
|