SPB Git

spb/findata

Public

AI assistant (config-gated, validated SQL only), EXPLAIN in sandbox

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 1, 2026) parent de3e9e3 Browse files

18 changed files +1,239 −12

added apps/web/README.md +43 −0
@@ -0,0 +1,43 @@
1+# FinData OS — web console
2+
3+Private operator console for the FinData OS lakehouse. Next.js 15 (App Router) + TypeScript + Tailwind CSS v4, built as a **static export** and served by the Python API — no Node process in production.
4+
5+## Develop
6+
7+```bash
8+cd apps/web
9+pnpm install
10+pnpm dev # http://localhost:3000 — /api/* is proxied to http://127.0.0.1:8600 (next.config.ts rewrites, dev only)
11+pnpm typecheck # tsc --noEmit
12+```
13+
14+Start the API locally (`uv run uvicorn findata_api.main:app --port 8600`) before using the dev server; the console authenticates with the API's cookie session and sends `X-CSRF-Token` on every non-GET request.
15+
16+## Build & serve
17+
18+```bash
19+pnpm build # → apps/web/out/ (index.html + one directory per route, trailing slashes)
20+```
21+
22+`services/api/src/findata_api/main.py` mounts `apps/web/out` (or `FINDATA_WEB_DIST`) with `StaticFiles(html=True)`, so `/datasets/` serves `out/datasets/index.html`, and the console is same-origin with `/api/v1`. The CSP is `default-src 'self'` (inline styles allowed, inline scripts not); the bundle contains no inline scripts, external fonts or CDNs.
23+
24+Detail pages use query strings instead of dynamic segments (static export cannot pre-render unknown ids): `/datasets/detail/?id=…`, `/symbols/detail/?symbol=…`.
25+
26+## Layout
27+
28+```
29+src/lib/api.ts fetch wrapper (same-origin cookies, CSRF header, error normalisation) + API types
30+src/lib/auth.tsx /auth/me context, redirects to /login/ (401) or the MFA step
31+src/lib/hooks.ts useAsync, useDebounced, useQueryParam, useInterval, useLocalStorage
32+src/lib/format.ts bytes, thin-space numbers, YYYY-MM-DD HH:mm:ssZ, CSV
33+src/components/ Shell (sidebar ≥1024px, bottom tabs + drawer on mobile), DataTable (cards <640px), charts, dialogs, toast
34+src/app/<route>/page.tsx one client page per route
35+```
36+
37+Routes: `/` overview · `/login/` · `/datasets/` (+`detail/`) · `/symbols/` (+`detail/`) · `/coverage/` · `/bars/` · `/ingestion/` · `/quality/` · `/storage/` · `/keys/` · `/playground/` · `/exports/` · `/fundamentals/` · `/docs/` · `/audit/` · `/settings/`.
38+
39+Owner-only pages (ingestion, storage, keys, audit, environment settings) are hidden from the navigation for other roles and show a notice if opened directly; the API enforces the `admin` scope regardless.
40+
41+## Design
42+
43+Brand-neutral palette validated with the `dataviz` skill (categorical blue/orange/aqua, sequential blue ramp for the coverage heatmap, fixed status colours with icon + label), light and dark via `prefers-color-scheme`, system font stack. Charts: `recharts` (one axis per chart, 2 px lines, ≤24 px bars, hover tooltips) and plain SVG for the heatmap.
modified apps/web/package.json +1 −1
@@ -5,7 +5,7 @@
5 "description": "FinData OS — private web console (static export served by the Python API)", 5 "description": "FinData OS — private web console (static export served by the Python API)",
6 "scripts": { 6 "scripts": {
7 "dev": "next dev -p 3000", 7 "dev": "next dev -p 3000",
8− "build": "next build", 8+ "build": "next build && node scripts/externalize-inline-scripts.mjs",
9 "typecheck": "tsc --noEmit", 9 "typecheck": "tsc --noEmit",
10 "lint": "next lint" 10 "lint": "next lint"
11 }, 11 },
added apps/web/scripts/externalize-inline-scripts.mjs +66 −0
@@ -0,0 +1,66 @@
1+#!/usr/bin/env node
2+/**
3+ * Post-build step for the static export.
4+ *
5+ * The API serves the console with `Content-Security-Policy: script-src 'self'` (no 'unsafe-inline').
6+ * Next.js App Router emits the React Server Components payload as inline
7+ * `<script>self.__next_f.push([...])</script>` tags, which such a CSP blocks — hydration would never run.
8+ *
9+ * This script rewrites every `out/**\/*.html`: the inline scripts (all of them are `__next_f` pushes) are
10+ * concatenated, in document order, into one external file `out/_next/static/flight/<hash>.js`, and a single
11+ * blocking `<script src=…>` is inserted where the first inline script was. Pushing the flight data from an
12+ * external file is order-independent for Next's client runtime (it buffers pushes until the app chunk takes
13+ * over and closes the stream on DOMContentLoaded, which a blocking script always precedes).
14+ *
15+ * Fails loudly if an inline script that is not a `__next_f` push is found, so the CSP contract stays explicit.
16+ */
17+import { createHash } from "node:crypto";
18+import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19+import { dirname, join, relative } from "node:path";
20+import { fileURLToPath } from "node:url";
21+
22+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
23+const out = join(root, "out");
24+const flightDir = join(out, "_next", "static", "flight");
25+
26+function walk(dir, acc = []) {
27+ for (const name of readdirSync(dir)) {
28+ const p = join(dir, name);
29+ const st = statSync(p);
30+ if (st.isDirectory()) {
31+ if (name === "_next") continue;
32+ walk(p, acc);
33+ } else if (name.endsWith(".html")) acc.push(p);
34+ }
35+ return acc;
36+}
37+
38+const INLINE = /<script>([\s\S]*?)<\/script>/g;
39+let files = 0, scripts = 0, bytes = 0;
40+mkdirSync(flightDir, { recursive: true });
41+
42+for (const file of walk(out)) {
43+ const html = readFileSync(file, "utf8");
44+ const chunks = [];
45+ let first = -1;
46+ for (const m of html.matchAll(INLINE)) {
47+ const body = m[1];
48+ if (!/^\s*\(?self\.__next_f/.test(body)) {
49+ throw new Error(`${relative(root, file)}: unexpected inline <script> (not a __next_f push):\n${body.slice(0, 200)}`);
50+ }
51+ if (first < 0) first = m.index;
52+ chunks.push(body.trim().replace(/;?$/, ";"));
53+ }
54+ if (!chunks.length) continue;
55+ const js = chunks.join("\n") + "\n";
56+ const hash = createHash("sha256").update(js).digest("hex").slice(0, 16);
57+ const name = `${hash}.js`;
58+ writeFileSync(join(flightDir, name), js);
59+ let i = 0;
60+ const rewritten = html.replace(INLINE, () => (i++ === 0 ? `<script src="/_next/static/flight/${name}"></script>` : ""));
61+ writeFileSync(file, rewritten);
62+ files++;
63+ scripts += chunks.length;
64+ bytes += js.length;
65+}
66+console.log(`externalize-inline-scripts: ${files} html files, ${scripts} inline scripts moved to _next/static/flight (${(bytes / 1024).toFixed(1)} kB)`);
added apps/web/src/app/audit/page.tsx +85 −0
@@ -0,0 +1,85 @@
1+"use client";
2+import { useState } from "react";
3+import { RefreshCw } from "lucide-react";
4+import { api, type AuditRow } from "@/lib/api";
5+import { useAsync, useDebounced } from "@/lib/hooks";
6+import { useAuth } from "@/lib/auth";
7+import { fmtInt, fmtTs } from "@/lib/format";
8+import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";
9+import { DataTable } from "@/components/DataTable";
10+import { JsonView } from "@/components/JsonView";
11+
12+const ACTIONS = ["", "auth.", "auth.login", "auth.mfa", "admin.", "admin.enqueue", "admin.key_create", "admin.key_revoke", "export.", "query.sql"];
13+const HIDDEN = new Set(["id", "at", "action", "actor", "principal", "target", "outcome", "ip", "request_id", "detail", "user_agent"]);
14+
15+export default function AuditPage() {
16+ const { isOwner } = useAuth();
17+ const [action, setAction] = useState("");
18+ const [limit, setLimit] = useState(200);
19+ const da = useDebounced(action.trim(), 300);
20+ const { data, error, loading, reload } = useAsync((s) => api.get<{ data: AuditRow[] }>("/admin/audit", { action: da || undefined, limit }, s), [da, limit], { enabled: isOwner });
21+
22+ if (!isOwner) return <Banner tone="warn">The audit log is restricted to the owner.</Banner>;
23+
24+ return (
25+ <>
26+ <PageHeader
27+ title="Audit log"
28+ subtitle="Every authentication event, admin action, export and SQL query, with request ids."
29+ actions={
30+ <>
31+ <Field label="Action prefix" className="w-48">
32+ <input className="input py-1 font-mono text-xs" list="audit-actions" value={action} onChange={(e) => setAction(e.target.value)} placeholder="admin." />
33+ <datalist id="audit-actions">
34+ {ACTIONS.filter(Boolean).map((a) => (
35+ <option key={a} value={a} />
36+ ))}
37+ </datalist>
38+ </Field>
39+ <Field label="Limit" className="w-24">
40+ <select className="input py-1 text-xs" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
41+ {[100, 200, 500, 1000, 2000].map((n) => (
42+ <option key={n} value={n}>
43+ {n}
44+ </option>
45+ ))}
46+ </select>
47+ </Field>
48+ <button className="btn btn-sm self-end" onClick={reload}>
49+ <RefreshCw className="h-3.5 w-3.5" /> Refresh
50+ </button>
51+ </>
52+ }
53+ />
54+ {error && <ErrorState error={error} retry={reload} />}
55+ <Card>
56+ <DataTable
57+ rows={data?.data}
58+ loading={loading}
59+ rowKey={(r, i) => String(r.id ?? `${r.at}-${i}`)}
60+ mobile="cards"
61+ maxHeight="75dvh"
62+ columns={[
63+ { key: "at", header: "At (UTC)", render: (r) => <span className="tnum text-xs whitespace-nowrap">{fmtTs(r.at)}</span> },
64+ { key: "action", header: "Action", mono: true },
65+ { key: "actor", header: "Actor", render: (r) => <span className="text-xs">{String(r.actor ?? r.principal ?? "—")}</span> },
66+ { key: "target", header: "Target", render: (r) => <span className="font-mono text-xs break-all">{r.target ?? "—"}</span> },
67+ { key: "outcome", header: "Outcome", render: (r) => (r.outcome ? <StatusBadge status={r.outcome === "denied" ? "denied" : r.outcome === "ok" ? "ok" : r.outcome} label={r.outcome} /> : <span className="text-ink-3">—</span>) },
68+ { key: "ip", header: "IP", hideSm: true, mono: true },
69+ {
70+ key: "detail",
71+ header: "Detail",
72+ render: (r) => {
73+ const d = r.detail ?? Object.fromEntries(Object.entries(r).filter(([k]) => !HIDDEN.has(k)));
74+ return Object.keys(d).length ? <JsonView value={d} collapsed /> : <span className="text-ink-3">—</span>;
75+ },
76+ },
77+ { key: "request_id", header: "Request", hideSm: true, render: (r) => <span className="font-mono text-[11px] text-ink-3">{r.request_id ?? "—"}</span> },
78+ ]}
79+ empty="No audit entries match."
80+ footer={<span>{fmtInt(data?.data.length ?? 0)} entries</span>}
81+ />
82+ </Card>
83+ </>
84+ );
85+}
modified apps/web/src/app/bars/page.tsx +2 −2
@@ -24,7 +24,7 @@ export default function BarsPage() {
24 const [out, setOut] = useState<BarsOut | null>(null); 24 const [out, setOut] = useState<BarsOut | null>(null);
25 const [rows, setRows] = useState<Bar[]>([]); 25 const [rows, setRows] = useState<Bar[]>([]);
26 const [busy, setBusy] = useState(false); 26 const [busy, setBusy] = useState(false);
27− const [error, setError] = useState<unknown>(null); 27+ const [error, setError] = useState<Error | null>(null);
28 const [view, setView] = useState<"chart" | "table">("chart"); 28 const [view, setView] = useState<"chart" | "table">("chart");
29 const toast = useToast(); 29 const toast = useToast();
30 30
@@ -47,7 +47,7 @@ export default function BarsPage() {
47 setOut(o); 47 setOut(o);
48 setRows(o.data); 48 setRows(o.data);
49 } catch (err) { 49 } catch (err) {
50− setError(err); 50+ setError(err instanceof Error ? err : new Error(String(err)));
51 setOut(null); 51 setOut(null);
52 setRows([]); 52 setRows([]);
53 } finally { 53 } finally {
added apps/web/src/app/docs/page.tsx +239 −0
@@ -0,0 +1,239 @@
1+"use client";
2+import Link from "next/link";
3+import { ExternalLink } from "lucide-react";
4+import { Banner, Card, PageHeader } from "@/components/ui";
5+
6+const BAR_COLUMNS: [string, string, string][] = [
7+ ["instrument_id", "string", "Stable id, e.g. firstrate:stock:AAPL"],
8+ ["symbol_as_reported", "string", "Ticker as delivered by the vendor at that date"],
9+ ["venue", "string?", "Exchange / venue when known"],
10+ ["asset_class", "string", "stock, etf, index, crypto, fx, futures"],
11+ ["interval", "string", "1m, 5m, 1d"],
12+ ["timestamp_utc", "timestamp[us, UTC]", "Bar OPEN instant, UTC"],
13+ ["session_date", "date", "US trading session (America/New_York)"],
14+ ["open / high / low / close", "float64", "Raw (as traded) prices"],
15+ ["volume", "float64", "Shares/contracts; zero-volume bars are absent"],
16+ ["currency", "string", "ISO 4217 (USD)"],
17+ ["trade_count", "int32?", "When supplied by the vendor"],
18+ ["vwap", "float64?", "When supplied by the vendor"],
19+ ["adjustment_type", "string", "Always raw in storage"],
20+ ["source_id", "string", "Source / product that produced the row"],
21+ ["ingested_at", "timestamp[us, UTC]", "Load instant"],
22+ ["quality_flags", "list<string>", "e.g. ohlc_inconsistent, price_jump, outside_session"],
23+ ["revision_id", "int32", "Increments when the vendor re-delivers a bar"],
24+];
25+
26+export default function DocsPage() {
27+ return (
28+ <>
29+ <PageHeader
30+ title="Documentation"
31+ subtitle="What FinData OS is, the data contract, licensing and how to use the API."
32+ actions={
33+ <>
34+ <a className="btn btn-sm" href="/api/docs" target="_blank" rel="noopener">
35+ Swagger UI <ExternalLink className="h-3.5 w-3.5" />
36+ </a>
37+ <a className="btn btn-sm" href="/api/v1/openapi.json" target="_blank" rel="noopener">
38+ OpenAPI JSON <ExternalLink className="h-3.5 w-3.5" />
39+ </a>
40+ </>
41+ }
42+ />
43+ <div className="prose-sm grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
44+ <nav className="card hidden h-fit lg:sticky lg:top-6 lg:block" aria-label="On this page">
45+ <ul className="flex flex-col gap-1 text-sm">
46+ {[["#platform", "The platform"], ["#contract", "Data contract"], ["#bars", "Bars columns"], ["#adjustments", "Adjustments"], ["#fundamentals", "Fundamentals (PIT)"], ["#license", "License"], ["#api", "API quick start"], ["#sdk", "Python SDK"], ["#limits", "Limits"], ["#limitations", "Limitations"]].map(([h, l]) => (
47+ <li key={h}>
48+ <a href={h} className="nav-link">
49+ {l}
50+ </a>
51+ </li>
52+ ))}
53+ </ul>
54+ </nav>
55+ <div className="flex min-w-0 flex-col gap-4">
56+ <Card title="The platform" id="platform">
57+ <p className="text-sm">
58+ <strong>FinData OS</strong> is a private, research-grade financial data lakehouse. It ingests vendor files (FirstRate Data intraday bars, corporate actions and fundamentals; SEC EDGAR when configured) into an immutable Parquet lake with a
59+ Postgres catalog, versions every dataset, checks quality at load time, and serves the result through one HTTP API that this console, the Python SDK and ad-hoc SQL all share. One writer (the ingest worker) owns the lake; the API is read-only
60+ except for job control, keys and settings.
61+ </p>
62+ <ul className="mt-2 list-disc pl-5 text-sm text-ink-2">
63+ <li>Curated layer: Parquet files per dataset, <code>base</code> files plus <code>delta</code> appends, compacted into new versions.</li>
64+ <li>Raw layer: vendor archives kept for re-processing (hash-verified).</li>
65+ <li>Catalog: datasets, versions, files, coverage per (symbol, session), instruments, corporate actions, jobs, exports, audit.</li>
66+ </ul>
67+ </Card>
68+
69+ <Card title="Data contract" id="contract">
70+ <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
71+ <dt className="font-medium">Timestamp convention</dt>
72+ <dd>
73+ Every bar is stamped at its <strong>open</strong>: the 1-minute bar labelled 13:30:00Z covers 13:30:00 → 13:30:59.999 UTC.
74+ </dd>
75+ <dt className="font-medium">Storage timezone</dt>
76+ <dd>
77+ <code>timestamp_utc</code> is UTC. <code>session_date</code> is the US trading session in America/New_York; it is the key for coverage and expected-bar counts.
78+ </dd>
79+ <dt className="font-medium">Session span</dt>
80+ <dd>
81+ Bars are retained for the extended session <strong>04:00 – 20:00 ET</strong>; regular hours are 09:30 – 16:00 ET. Completeness = bars ÷ expected regular-hours bars, capped at 100 %.
82+ </dd>
83+ <dt className="font-medium">Zero-volume bars</dt>
84+ <dd>Minutes without a trade are <strong>absent</strong>, not stored as zero rows. A gap is not necessarily a vendor outage — check the coverage page.</dd>
85+ <dt className="font-medium">Adjustments</dt>
86+ <dd>
87+ Stored prices are <strong>raw</strong>. <code>split</code> and <code>split_div</code> are computed at query time from the corporate-actions table (ex-date based, backward factor), so a new split re-adjusts history without rewriting
88+ files. The API echoes <code>adjustment_notes</code> explaining what was applied.
89+ </dd>
90+ <dt className="font-medium">Versioning</dt>
91+ <dd>
92+ Each dataset has a <code>current_version</code> with a snapshot id. Exports pin the version at submission; <code>GET /market/bars?version=</code> can read an earlier one.
93+ </dd>
94+ <dt className="font-medium">Quality flags</dt>
95+ <dd>Rows are never silently dropped; suspicious bars carry <code>quality_flags</code> and are also recorded as anomalies.</dd>
96+ </dl>
97+ </Card>
98+
99+ <Card title="Bars schema (findata.schema = bars, version 1)" id="bars">
100+ <div className="table-wrap">
101+ <table className="table">
102+ <thead>
103+ <tr>
104+ <th>Column</th>
105+ <th>Arrow type</th>
106+ <th>Meaning</th>
107+ </tr>
108+ </thead>
109+ <tbody>
110+ {BAR_COLUMNS.map(([c, t, m]) => (
111+ <tr key={c}>
112+ <td className="font-mono text-xs">{c}</td>
113+ <td className="font-mono text-xs text-ink-2">{t}</td>
114+ <td className="text-xs">{m}</td>
115+ </tr>
116+ ))}
117+ </tbody>
118+ </table>
119+ </div>
120+ <p className="mt-2 text-xs text-ink-3">
121+ The authoritative schema is served by <code>GET /api/v1/catalog/schema/bars</code>. In the SQL playground each dataset is a DuckDB view named after its id with dots replaced by underscores, e.g. <code>market_bars_stock_1m_raw</code>.
122+ </p>
123+ </Card>
124+
125+ <Card title="Adjustments in practice" id="adjustments">
126+ <ul className="list-disc pl-5 text-sm">
127+ <li>
128+ <code>raw</code> — prices as traded; volumes as reported. Use for microstructure work and when you join your own actions.
129+ </li>
130+ <li>
131+ <code>split</code> — prices divided and volumes multiplied by the cumulative split factor of all splits with ex-date after the bar.
132+ </li>
133+ <li>
134+ <code>split_div</code> — additionally applies the cash-dividend backward factor <em>(1 − D / P<sub>close, prev</sub>)</em>. Total-return style; not suitable for tick-size studies.
135+ </li>
136+ </ul>
137+ </Card>
138+
139+ <Card title="Fundamentals — point in time" id="fundamentals">
140+ <p className="text-sm">
141+ Every fact carries <code>available_at</code> (= filing date + 1 day 00:00 America/New_York, a conservative knowability instant) and <code>observed_at</code> (when this platform first saw the value). A request with <code>as_of</code> returns
142+ only facts with <code>available_at ≤ as_of</code>; among revisions, the first observed value is used unless a later revision was observed before <code>as_of</code>. The response counts <code>facts_hidden_by_as_of</code> so you can tell how much of the
143+ record you are not seeing.
144+ </p>
145+ </Card>
146+
147+ <Card title="License and redistribution" id="license">
148+ <Banner tone="warn">
149+ <strong>FirstRate Data — private use only, no redistribution.</strong>
150+ </Banner>
151+ <ul className="mt-3 list-disc pl-5 text-sm">
152+ <li>Raw bars, exports, SQL results and tables derived row-for-row from them may be used by the subscriber only. They may <strong>not</strong> be published, shared, re-hosted or served through a public API.</li>
153+ <li>Aggregated results (charts, statistics, model outputs) may be published with attribution to FirstRate Data.</li>
154+ <li>SEC EDGAR content is U.S. public domain; the SEC fair-access policy (declared User-Agent, request rate) applies to retrieval.</li>
155+ <li>API keys are personal. Every download, export and query is written to the audit log with the principal and request id.</li>
156+ </ul>
157+ </Card>
158+
159+ <Card title="API quick start" id="api">
160+ <p className="text-sm">All endpoints live under <code>/api/v1</code>. Authenticate with the console session (cookie + <code>X-CSRF-Token</code> on writes) or with an API key: <code>Authorization: Bearer fd_…</code>. Errors are JSON: <code>{`{"error":{"code","message","request_id"}}`}</code>.</p>
161+ <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`export FINDATA_URL="https://findata.spboucher.ai"
162+export FINDATA_API_KEY="fd_xxx.yyy"
163+
164+# health + catalog
165+curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/health" | jq .status
166+curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/catalog/datasets" | jq '.data[].id'
167+
168+# one week of 1-minute bars, split-adjusted, as CSV (≤ 31 days per call for 1m)
169+curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\
170+ "$FINDATA_URL/api/v1/market/bars?symbol=AAPL&interval=1m&start=2026-10-20&end=2026-10-24&adjustment=split&format=csv&limit=10000" -o aapl.csv
171+
172+# bigger ranges: async export → poll → download
173+curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\
174+ -d '{"dataset_id":"market_bars.stock.1m.raw","symbols":["AAPL","MSFT"],"start":"2024-01-01","end":"2026-10-30","format":"parquet"}' \\
175+ "$FINDATA_URL/api/v1/exports"
176+# → {"id":"…","status":"queued","poll":"/api/v1/exports/…"}; when status=ready: GET /api/v1/exports/{id}/download
177+
178+# point-in-time fundamentals
179+curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\
180+ "$FINDATA_URL/api/v1/fundamentals/AAPL?as_of=2025-01-01T00:00:00Z&metrics=rev,net_inc&period_type=q"
181+
182+# read-only SQL (DuckDB)
183+curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\
184+ -d '{"sql":"SELECT session_date, sum(volume) v FROM market_bars_stock_1m_raw WHERE symbol_as_reported='"'"'AAPL'"'"' GROUP BY 1 ORDER BY 1 DESC LIMIT 5","max_rows":100}' \\
185+ "$FINDATA_URL/api/v1/query"`}</pre>
186+ </Card>
187+
188+ <Card title="Python SDK" id="sdk">
189+ <p className="text-sm">
190+ Install from the repo (<code>packages/python-sdk</code>): <code>pip install -e packages/python-sdk</code>. The client pages through Arrow IPC automatically.
191+ </p>
192+ <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`from findata_sdk import FinData
193+
194+fd = FinData("https://findata.spboucher.ai", api_key="fd_xxx.yyy") # or FINDATA_URL / FINDATA_API_KEY env vars
195+
196+bars = fd.bars("AAPL", "1m", "2026-10-26", "2026-10-30", adjustment="split") # pyarrow.Table, all pages
197+df = bars.to_pandas() # or .to_polars()
198+
199+path = fd.export(["AAPL", "MSFT"], "2024-01-01", "2026-10-30", format="parquet") # waits, downloads, returns local path
200+
201+facts = fd.fundamentals("AAPL", as_of="2025-01-01T00:00:00Z", metrics=["rev", "net_inc"])
202+print(fd.datasets()[0]["id"], fd.health()["status"])`}</pre>
203+ </Card>
204+
205+ <Card title="Limits" id="limits">
206+ <ul className="list-disc pl-5 text-sm">
207+ <li>
208+ <code>GET /market/bars</code>: span ≤ <strong>31 days</strong> for 1m, 186 days for 5m, 3 660 days for 1d; ≤ 10 000 rows per page (cursor pagination). HTTP 413 <code>range_too_large</code> otherwise → use{" "}
209+ <Link href="/exports/" className="text-accent-ink hover:underline">
210+ Exports
211+ </Link>
212+ .
213+ </li>
214+ <li>Exports: ≤ 500 symbols, ≤ 12 years, 3 concurrent per principal, files expire after ~72 h.</li>
215+ <li>SQL: one read-only statement, server-side timeout, ≤ 100 000 rows; the console displays at most 1 000 rows (download the CSV for the rest).</li>
216+ <li>Rate limit per key/session (default 600 requests/min, HTTP 429 with <code>Retry-After</code>).</li>
217+ </ul>
218+ </Card>
219+
220+ <Card title="Known limitations" id="limitations">
221+ <ul className="list-disc pl-5 text-sm">
222+ <li>
223+ <strong>Fundamentals revision history starts at first ingestion.</strong> Values observed before the platform existed are a single revision (proxy for “as originally filed”); true as-filed history for older periods is not reconstructed.
224+ </li>
225+ <li>
226+ <strong>SEC EDGAR is “pending configuration”</strong> until a fair-access User-Agent is set on the server (<code>SEC_EDGAR_USER_AGENT</code>). Until then the dataset is listed but empty.
227+ </li>
228+ <li>
229+ <strong>Unsupported:</strong> tick data, quotes/NBBO, order-book depth, options chains. Those datasets appear as <em>unsupported</em> in the catalog on purpose so nobody plans on them.
230+ </li>
231+ <li>Vendor files arrive with a lag (typically next day); the overview shows <em>vendor lag</em> and <em>days since last session</em> per dataset.</li>
232+ <li>The console is a static bundle served by the API; it needs JavaScript and a modern browser. No telemetry, no third-party assets.</li>
233+ </ul>
234+ </Card>
235+ </div>
236+ </div>
237+ </>
238+ );
239+}
added apps/web/src/app/fundamentals/page.tsx +248 −0
@@ -0,0 +1,248 @@
1+"use client";
2+import { useEffect, useMemo, useState, type FormEvent } from "react";
3+import { Play } from "lucide-react";
4+import clsx from "clsx";
5+import { api, type Fact, type FundamentalsOut, type PeriodsOut } from "@/lib/api";
6+import { useQueryParam } from "@/lib/hooks";
7+import { fmtCompact, fmtInt, fmtNum, fmtTs, nowLocalInput } from "@/lib/format";
8+import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge, Tabs } from "@/components/ui";
9+import { DataTable } from "@/components/DataTable";
10+
11+const DEFAULT_PIVOT = 6;
12+
13+export default function FundamentalsPage() {
14+ const qTicker = useQueryParam("ticker");
15+ const [ticker, setTicker] = useState("AAPL");
16+ const [asOf, setAsOf] = useState(nowLocalInput());
17+ const [periodType, setPeriodType] = useState<"" | "q" | "a">("q");
18+ const [metrics, setMetrics] = useState("");
19+ const [out, setOut] = useState<FundamentalsOut | null>(null);
20+ const [periods, setPeriods] = useState<PeriodsOut | null>(null);
21+ const [busy, setBusy] = useState(false);
22+ const [error, setError] = useState<Error | null>(null);
23+ const [tab, setTab] = useState<"pivot" | "facts" | "periods" | "earnings">("pivot");
24+ const [pivotMetrics, setPivotMetrics] = useState<string[]>([]);
25+
26+ useEffect(() => {
27+ if (qTicker) setTicker(qTicker.toUpperCase());
28+ }, [qTicker]);
29+
30+ async function run(e?: FormEvent) {
31+ e?.preventDefault();
32+ setBusy(true);
33+ setError(null);
34+ try {
35+ const asOfIso = asOf ? new Date(asOf).toISOString() : undefined;
36+ const t = ticker.trim();
37+ const [f, p] = await Promise.all([
38+ api.get<FundamentalsOut>(`/fundamentals/${encodeURIComponent(t)}`, { as_of: asOfIso, metrics: metrics.trim() || undefined, period_type: periodType || undefined, limit: 5000 }),
39+ api.get<PeriodsOut>(`/fundamentals/${encodeURIComponent(t)}/periods`).catch(() => null),
40+ ]);
41+ setOut(f);
42+ setPeriods(p);
43+ const all = Array.from(new Set(f.data.map((x) => x.metric)));
44+ setPivotMetrics((cur) => (cur.length ? cur.filter((m) => all.includes(m)) : all.slice(0, DEFAULT_PIVOT)));
45+ } catch (err) {
46+ setError(err instanceof Error ? err : new Error(String(err)));
47+ setOut(null);
48+ setPeriods(null);
49+ } finally {
50+ setBusy(false);
51+ }
52+ }
53+
54+ const allMetrics = useMemo(() => Array.from(new Set((out?.data ?? []).map((x) => x.metric))).sort(), [out]);
55+ const pivot = useMemo(() => {
56+ const rows = new Map<string, { period: string; type: string; fy: number | null; fp: string | null; filing: string | null; values: Record<string, Fact> }>();
57+ for (const f of out?.data ?? []) {
58+ const key = `${f.fiscal_period_end}|${f.fiscal_period_type}`;
59+ const r = rows.get(key) ?? { period: f.fiscal_period_end, type: f.fiscal_period_type, fy: f.fiscal_year, fp: f.fiscal_period, filing: f.filing_date, values: {} };
60+ r.values[f.metric] = f;
61+ rows.set(key, r);
62+ }
63+ return Array.from(rows.values()).sort((a, b) => b.period.localeCompare(a.period));
64+ }, [out]);
65+
66+ return (
67+ <>
68+ <PageHeader title="Fundamentals" subtitle="Point-in-time facts: only what was knowable at the chosen instant is returned." />
69+ <Card className="mb-4">
70+ <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-5 md:items-end">
71+ <Field label="Ticker or entity id">
72+ <input className="input font-mono uppercase" value={ticker} onChange={(e) => setTicker(e.target.value)} required placeholder="AAPL or frd:AAPL" />
73+ </Field>
74+ <Field label="As of (local time → UTC)">
75+ <input type="datetime-local" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
76+ </Field>
77+ <Field label="Period type">
78+ <select className="input" value={periodType} onChange={(e) => setPeriodType(e.target.value as typeof periodType)}>
79+ <option value="q">quarterly</option>
80+ <option value="a">annual</option>
81+ <option value="">both</option>
82+ </select>
83+ </Field>
84+ <Field label="Metrics (comma-separated)">
85+ <input className="input font-mono text-xs" value={metrics} onChange={(e) => setMetrics(e.target.value)} placeholder="rev, net_inc, eps" />
86+ </Field>
87+ <button className="btn btn-primary" disabled={busy || !ticker.trim()}>
88+ <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}
89+ </button>
90+ </form>
91+ </Card>
92+
93+ {error && <ErrorState error={error} retry={() => void run()} />}
94+
95+ {out && (
96+ <>
97+ <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
98+ <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} facts</span>
99+ <span className="pill bg-surface-2 text-ink-2 tnum">as of {fmtTs(out.meta.as_of)}</span>
100+ {out.meta.facts_hidden_by_as_of > 0 && <span className="pill bg-warn-soft text-warn-ink tnum">{fmtInt(out.meta.facts_hidden_by_as_of)} facts hidden by as_of</span>}
101+ {out.entity && <span className="pill bg-surface-2 text-ink-2">{String(out.entity.name ?? out.entity.entity_id ?? "")}</span>}
102+ <span className="ml-auto">
103+ <Tabs value={tab} onChange={setTab} items={[{ value: "pivot", label: "Pivot" }, { value: "facts", label: "Raw facts" }, { value: "periods", label: `Periods (${periods?.periods.length ?? 0})` }, { value: "earnings", label: `Earnings (${periods?.earnings.length ?? 0})` }]} />
104+ </span>
105+ </div>
106+ <Banner tone="accent" className="mb-4">
107+ <span className="text-xs">PIT policy: {out.meta.pit_policy}. Revision history exists only from the first ingestion by this platform.</span>
108+ </Banner>
109+
110+ {tab === "pivot" && (
111+ <Card title="Periods × metrics" actions={<span className="text-xs text-ink-3">{pivotMetrics.length} of {allMetrics.length} metrics</span>}>
112+ <div className="mb-3 flex flex-wrap gap-1">
113+ {allMetrics.map((m) => {
114+ const on = pivotMetrics.includes(m);
115+ return (
116+ <button key={m} className={clsx("pill border font-mono", on ? "border-accent bg-accent-soft text-accent-ink" : "border-border bg-surface-1 text-ink-2 hover:bg-surface-3")} onClick={() => setPivotMetrics((cur) => (on ? cur.filter((x) => x !== m) : [...cur, m]))} aria-pressed={on}>
117+ {m}
118+ </button>
119+ );
120+ })}
121+ </div>
122+ {pivot.length === 0 ? (
123+ <Empty>No facts visible at this as-of instant.</Empty>
124+ ) : (
125+ <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}>
126+ <table className="table">
127+ <thead>
128+ <tr>
129+ <th>Period end</th>
130+ <th>FY / FP</th>
131+ <th>Filed</th>
132+ {pivotMetrics.map((m) => (
133+ <th key={m} className="text-right font-mono">
134+ {m}
135+ </th>
136+ ))}
137+ </tr>
138+ </thead>
139+ <tbody>
140+ {pivot.map((r) => (
141+ <tr key={`${r.period}-${r.type}`}>
142+ <td className="tnum whitespace-nowrap">
143+ {r.period} <span className="text-ink-3">{r.type}</span>
144+ </td>
145+ <td className="tnum text-xs text-ink-2 whitespace-nowrap">
146+ {r.fy ?? "—"} / {r.fp ?? "—"}
147+ </td>
148+ <td className="tnum text-xs text-ink-2 whitespace-nowrap">{r.filing ?? "—"}</td>
149+ {pivotMetrics.map((m) => {
150+ const f = r.values[m];
151+ return (
152+ <td key={m} className="num whitespace-nowrap" title={f ? `${f.value} ${f.unit} · rev ${f.revision_id}${f.restatement_flag ? " · restated" : ""}` : undefined}>
153+ {f ? (
154+ <>
155+ {fmtValue(f)}
156+ {f.revision_id > 1 && <sup className="ml-0.5 text-[9px] text-warn-ink">r{f.revision_id}</sup>}
157+ </>
158+ ) : (
159+ <span className="text-ink-3">—</span>
160+ )}
161+ </td>
162+ );
163+ })}
164+ </tr>
165+ ))}
166+ </tbody>
167+ </table>
168+ </div>
169+ )}
170+ <p className="mt-2 text-[11px] text-ink-3">Values compacted (K/M/B); hover for exact value and unit. <sup>rN</sup> marks a revision observed before as-of.</p>
171+ </Card>
172+ )}
173+
174+ {tab === "facts" && (
175+ <Card title="Raw facts">
176+ <DataTable
177+ rows={out.data}
178+ rowKey={(f, i) => `${f.metric}-${f.fiscal_period_end}-${f.fiscal_period_type}-${f.revision_id}-${i}`}
179+ mobile="scroll"
180+ maxHeight="60dvh"
181+ columns={[
182+ { key: "metric", header: "Metric", mono: true },
183+ { key: "value", header: "Value", num: true, render: (f) => <span title={String(f.value)}>{fmtValue(f)}</span> },
184+ { key: "unit", header: "Unit", hideSm: true },
185+ { key: "fiscal_period_end", header: "Period end", render: (f) => <span className="tnum">{f.fiscal_period_end} <span className="text-ink-3">{f.fiscal_period_type}</span></span> },
186+ { key: "fy", header: "FY/FP", hideSm: true, render: (f) => `${f.fiscal_year ?? "—"}/${f.fiscal_period ?? "—"}` },
187+ { key: "filing_date", header: "Filed", render: (f) => <span className="tnum">{f.filing_date ?? "—"}</span> },
188+ { key: "available_at", header: "Available at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.available_at)}</span> },
189+ { key: "observed_at", header: "Observed at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.observed_at)}</span> },
190+ { key: "revision_id", header: "Rev", num: true },
191+ { key: "restatement_flag", header: "Restated", render: (f) => (f.restatement_flag ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },
192+ { key: "source_document", header: "Source", hideSm: true, render: (f) => <span className="text-xs text-ink-2">{f.source_document ?? f.source_id}</span> },
193+ ]}
194+ />
195+ </Card>
196+ )}
197+
198+ {tab === "periods" && (
199+ <Card title="Fiscal periods (all revisions, not PIT-filtered)">
200+ <DataTable
201+ rows={periods?.periods}
202+ rowKey={(p) => `${p.fiscal_period_end}-${p.fiscal_period_type}-${p.filing_date}`}
203+ mobile="scroll"
204+ maxHeight="60dvh"
205+ columns={[
206+ { key: "fiscal_period_end", header: "Period end", render: (p) => <span className="tnum">{p.fiscal_period_end} <span className="text-ink-3">{p.fiscal_period_type}</span></span> },
207+ { key: "fy", header: "FY/FP", render: (p) => `${p.fiscal_year ?? "—"}/${p.fiscal_period ?? "—"}` },
208+ { key: "filing_date", header: "Filed", render: (p) => <span className="tnum">{p.filing_date ?? "—"}</span> },
209+ { key: "available_at", header: "Available at", render: (p) => <span className="tnum text-xs">{fmtTs(p.available_at)}</span> },
210+ { key: "metrics", header: "Metrics", num: true, render: (p) => fmtInt(p.metrics) },
211+ { key: "max_revision", header: "Max rev", num: true },
212+ { key: "restated", header: "Restated", render: (p) => (p.restated ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },
213+ ]}
214+ empty="No periods."
215+ />
216+ </Card>
217+ )}
218+
219+ {tab === "earnings" && (
220+ <Card title="Earnings events">
221+ <DataTable
222+ rows={periods?.earnings}
223+ rowKey={(e) => e.event_date}
224+ mobile="scroll"
225+ maxHeight="60dvh"
226+ columns={[
227+ { key: "event_date", header: "Date", render: (e) => <span className="tnum">{e.event_date}</span> },
228+ { key: "release_time", header: "Release", render: (e) => e.release_time ?? "—" },
229+ { key: "eps_estimate", header: "EPS est.", num: true, render: (e) => fmtNum(e.eps_estimate, 2) },
230+ { key: "eps_reported", header: "EPS rep.", num: true, render: (e) => fmtNum(e.eps_reported, 2) },
231+ { key: "surprise", header: "Surprise", num: true, render: (e) => (e.eps_estimate !== null && e.eps_reported !== null ? fmtNum(e.eps_reported - e.eps_estimate, 2) : "—") },
232+ ]}
233+ empty="No earnings events."
234+ />
235+ </Card>
236+ )}
237+ </>
238+ )}
239+ </>
240+ );
241+}
242+
243+function fmtValue(f: Fact): string {
244+ if (f.value === null || f.value === undefined) return "—";
245+ const u = (f.unit ?? "").toLowerCase();
246+ if (u.includes("ratio") || u.includes("per_share") || u === "pct" || u === "percent" || Math.abs(f.value) < 1000) return fmtNum(f.value, 2);
247+ return fmtCompact(f.value);
248+}
modified apps/web/src/app/globals.css +1 −1
@@ -104,7 +104,7 @@ body {
104 ::selection { background: var(--accent-soft); } 104 ::selection { background: var(--accent-soft); }
105 a { color: inherit; } 105 a { color: inherit; }
106 code, pre, kbd, .mono { font-family: var(--font-mono); } 106 code, pre, kbd, .mono { font-family: var(--font-mono); }
107−.tnum { font-variant-numeric: tabular-nums; } 107+@utility tnum { font-variant-numeric: tabular-nums; }
108 108
109 /* ------------------------------------------------------------------ components */ 109 /* ------------------------------------------------------------------ components */
110 @layer components { 110 @layer components {
modified apps/web/src/app/playground/page.tsx +2 −2
@@ -16,7 +16,7 @@ export default function PlaygroundPage() {
16 const [maxRows, setMaxRows] = useState(1000); 16 const [maxRows, setMaxRows] = useState(1000);
17 const [out, setOut] = useState<QueryOut | null>(null); 17 const [out, setOut] = useState<QueryOut | null>(null);
18 const [busy, setBusy] = useState(false); 18 const [busy, setBusy] = useState(false);
19− const [error, setError] = useState<unknown>(null); 19+ const [error, setError] = useState<Error | null>(null);
20 const toast = useToast(); 20 const toast = useToast();
21 const ta = useRef<HTMLTextAreaElement>(null); 21 const ta = useRef<HTMLTextAreaElement>(null);
22 const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []); 22 const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []);
@@ -34,7 +34,7 @@ export default function PlaygroundPage() {
34 const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows }); 34 const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows });
35 setOut(o); 35 setOut(o);
36 } catch (e) { 36 } catch (e) {
37− setError(e); 37+ setError(e instanceof Error ? e : new Error(String(e)));
38 setOut(null); 38 setOut(null);
39 } finally { 39 } finally {
40 setBusy(false); 40 setBusy(false);
added apps/web/src/app/settings/page.tsx +199 −0
@@ -0,0 +1,199 @@
1+"use client";
2+/* eslint-disable @next/next/no-img-element */
3+import { useEffect, useState, type FormEvent } from "react";
4+import { QrCode, Radar, Save, ShieldCheck } from "lucide-react";
5+import { api, type SettingsOut } from "@/lib/api";
6+import { useAsync } from "@/lib/hooks";
7+import { useAuth } from "@/lib/auth";
8+import { useToast } from "@/lib/toast";
9+import { fmtInt, fmtTs, splitList } from "@/lib/format";
10+import { Banner, Card, Empty, ErrorState, Field, KV, PageHeader, StatusBadge } from "@/components/ui";
11+import { JsonView } from "@/components/JsonView";
12+import { CopyButton } from "@/components/CopyButton";
13+
14+export default function SettingsPage() {
15+ const { me, isOwner, refresh } = useAuth();
16+ const toast = useToast();
17+ const settings = useAsync((s) => api.get<SettingsOut>("/admin/settings", undefined, s), [], { enabled: isOwner });
18+
19+ // TOTP
20+ const [setup, setSetup] = useState<{ otpauth_uri: string; qr_png_data_url: string; secret: string } | null>(null);
21+ const [code, setCode] = useState("");
22+ const [busy, setBusy] = useState(false);
23+
24+ async function startSetup() {
25+ setBusy(true);
26+ try {
27+ setSetup(await api.post("/auth/totp/setup"));
28+ } catch (e) {
29+ toast.error(e);
30+ } finally {
31+ setBusy(false);
32+ }
33+ }
34+ async function verify(e: FormEvent) {
35+ e.preventDefault();
36+ setBusy(true);
37+ try {
38+ await api.post("/auth/totp/verify", { code: code.replace(/\s+/g, "") });
39+ toast.success("Two-factor authentication enabled");
40+ setSetup(null);
41+ setCode("");
42+ await refresh();
43+ } catch (err) {
44+ toast.error(err);
45+ } finally {
46+ setBusy(false);
47+ }
48+ }
49+
50+ // Tracked tickers
51+ const [tickers, setTickers] = useState("");
52+ const [tickersDirty, setTickersDirty] = useState(false);
53+ useEffect(() => {
54+ const row = settings.data?.settings.find((s) => s.key === "fundamentals_tracked_tickers");
55+ if (row && !tickersDirty) setTickers(Array.isArray(row.value) ? (row.value as string[]).join("\n") : "");
56+ }, [settings.data, tickersDirty]);
57+ async function saveTickers() {
58+ setBusy(true);
59+ try {
60+ const out = await api.put<{ tickers: string[] }>("/admin/settings/tracked-tickers", { tickers: splitList(tickers) });
61+ toast.success("Tracked tickers saved", `${out.tickers.length} tickers`);
62+ setTickers(out.tickers.join("\n"));
63+ setTickersDirty(false);
64+ settings.reload();
65+ } catch (e) {
66+ toast.error(e);
67+ } finally {
68+ setBusy(false);
69+ }
70+ }
71+
72+ // Discovery
73+ const [disc, setDisc] = useState<unknown>(null);
74+ async function discover() {
75+ setBusy(true);
76+ setDisc(null);
77+ try {
78+ setDisc(await api.post("/admin/discover"));
79+ toast.success("Discovery finished");
80+ } catch (e) {
81+ toast.error(e);
82+ } finally {
83+ setBusy(false);
84+ }
85+ }
86+
87+ const env = settings.data?.env;
88+ const otherSettings = (settings.data?.settings ?? []).filter((s) => s.key !== "fundamentals_tracked_tickers");
89+
90+ return (
91+ <>
92+ <PageHeader title="Settings" subtitle="Account security, environment flags and ingestion preferences" />
93+ <div className="grid gap-4 lg:grid-cols-2">
94+ <Card title="Account">
95+ <KV
96+ items={[
97+ ["E-mail", me?.email ?? "—"],
98+ ["Role", <StatusBadge key="r" status="neutral" label={me?.role ?? "—"} icon={false} />],
99+ ["Scopes", <div key="s" className="flex flex-wrap gap-1">{(me?.scopes ?? []).map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div>],
100+ ["Two-factor (TOTP)", me?.totp_enabled ? <StatusBadge key="t" status="ok" label="enabled" /> : <StatusBadge key="t" status="warning" label="not enabled" />],
101+ ]}
102+ />
103+ <div className="mt-4 border-t border-hairline pt-3">
104+ {!setup ? (
105+ <button className="btn" onClick={startSetup} disabled={busy}>
106+ <ShieldCheck className="h-4 w-4" /> {me?.totp_enabled ? "Re-enrol authenticator" : "Enable two-factor authentication"}
107+ </button>
108+ ) : (
109+ <form onSubmit={verify} className="flex flex-col gap-3 sm:flex-row">
110+ <div className="shrink-0 rounded-md border border-border bg-white p-2 self-start">
111+ <img src={setup.qr_png_data_url} alt="TOTP enrolment QR code" width={160} height={160} />
112+ </div>
113+ <div className="flex flex-1 flex-col gap-2">
114+ <p className="text-sm text-ink-2">
115+ <QrCode className="mr-1 inline h-4 w-4" /> Scan with your authenticator, or enter the secret manually:
116+ </p>
117+ <div className="flex items-center gap-2">
118+ <code className="break-all rounded bg-surface-2 px-2 py-1 text-xs">{setup.secret}</code>
119+ <CopyButton text={setup.secret} />
120+ </div>
121+ <Field label="Code from the app">
122+ <input className="input font-mono tracking-[0.3em]" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required />
123+ </Field>
124+ <div className="flex gap-2">
125+ <button className="btn btn-primary" disabled={busy || code.replace(/\s+/g, "").length < 6}>
126+ Verify & enable
127+ </button>
128+ <button type="button" className="btn" onClick={() => setSetup(null)}>
129+ Cancel
130+ </button>
131+ </div>
132+ </div>
133+ </form>
134+ )}
135+ </div>
136+ </Card>
137+
138+ <Card title="Environment">
139+ {!isOwner && <Empty>Environment flags are visible to the owner only.</Empty>}
140+ {settings.error && <ErrorState error={settings.error} retry={settings.reload} />}
141+ {env && (
142+ <KV
143+ items={[
144+ ["Environment", <StatusBadge key="e" status={env.env === "production" ? "ok" : "warning"} label={env.env} />],
145+ ["Lake path", <code key="l" className="text-xs">{env.lake}</code>],
146+ ["Public URL", <a key="u" href={env.public_url} className="text-accent-ink hover:underline break-all" target="_blank" rel="noopener">{env.public_url}</a>],
147+ ["Backup target", <code key="b" className="text-xs">{env.backup_target}</code>],
148+ ["FirstRate Data vendor", <StatusBadge key="v" status={env.vendor_configured ? "ok" : "error"} label={env.vendor_configured ? "configured" : "not configured"} />],
149+ ["SEC EDGAR", <StatusBadge key="s" status={env.sec_edgar_configured ? "ok" : "pending_configuration"} label={env.sec_edgar_configured ? "configured" : "pending configuration"} />],
150+ ["AI providers", <span key="a" className="flex gap-1"><StatusBadge status={env.ai_providers.anthropic ? "ok" : "neutral"} label={`anthropic ${env.ai_providers.anthropic ? "yes" : "no"}`} icon={false} /><StatusBadge status={env.ai_providers.openai ? "ok" : "neutral"} label={`openai ${env.ai_providers.openai ? "yes" : "no"}`} icon={false} /></span>],
151+ ]}
152+ />
153+ )}
154+ <p className="mt-3 text-[11px] text-ink-3">Secrets are never sent to the browser; only configured yes/no flags are shown.</p>
155+ </Card>
156+
157+ {isOwner && (
158+ <Card title="Tracked tickers (fundamentals)" actions={<span className="text-xs text-ink-3">{fmtInt(splitList(tickers).length)} tickers</span>}>
159+ <textarea className="input min-h-40 font-mono uppercase" value={tickers} onChange={(e) => { setTickers(e.target.value); setTickersDirty(true); }} placeholder={"AAPL\nMSFT\nNVDA"} />
160+ <div className="mt-2 flex items-center justify-between gap-2">
161+ <span className="text-xs text-ink-3">Used by <code>fundamentals</code> jobs when no tickers are given. Max 2 000, deduplicated and upper-cased.</span>
162+ <button className="btn btn-primary" onClick={saveTickers} disabled={busy || !tickersDirty}>
163+ <Save className="h-4 w-4" /> Save
164+ </button>
165+ </div>
166+ </Card>
167+ )}
168+
169+ {isOwner && (
170+ <Card title="Vendor discovery" actions={<button className="btn btn-sm" onClick={discover} disabled={busy}><Radar className="h-3.5 w-3.5" /> {busy ? "Probing…" : "Run discovery"}</button>}>
171+ <p className="text-xs text-ink-2">Live, sanitized probe of FirstRate Data and SEC EDGAR (no bulk download). Output is redacted server-side and recorded in the audit log.</p>
172+ <div className="mt-2">{disc ? <JsonView value={disc} maxHeight="28rem" /> : <Empty>No discovery run yet in this session.</Empty>}</div>
173+ </Card>
174+ )}
175+
176+ {isOwner && otherSettings.length > 0 && (
177+ <Card title="Other settings (read-only)" className="lg:col-span-2">
178+ <ul className="flex flex-col gap-2">
179+ {otherSettings.map((s) => (
180+ <li key={s.key} className="text-sm">
181+ <div className="flex items-center justify-between gap-2">
182+ <code className="text-xs">{s.key}</code>
183+ <span className="tnum text-xs text-ink-3">{fmtTs(s.updated_at)}</span>
184+ </div>
185+ <JsonView value={s.value} collapsed />
186+ </li>
187+ ))}
188+ </ul>
189+ </Card>
190+ )}
191+ </div>
192+ {!me?.totp_enabled && (
193+ <Banner tone="warn" className="mt-4">
194+ Two-factor authentication is not enabled on this account. Enable it above — the owner role controls jobs, keys and storage.
195+ </Banner>
196+ )}
197+ </>
198+ );
199+}
modified apps/web/src/components/charts.tsx +1 −1
@@ -22,7 +22,7 @@ function TipBox({ title, rows }: { title: string; rows: { label: string; value:
22 ); 22 );
23 } 23 }
24 24
25−type TipPayload = { payload?: Record<string, unknown>; dataKey?: string | number; value?: unknown }[]; 25+type TipPayload = ReadonlyArray<{ payload?: unknown; value?: unknown }>;
26 26
27 // ---------------------------------------------------------------- price (close) line 27 // ---------------------------------------------------------------- price (close) line
28 export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) { 28 export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) {
modified apps/web/src/lib/hooks.ts +2 −2
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
5 export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) { 5 export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) {
6 const enabled = opts.enabled ?? true; 6 const enabled = opts.enabled ?? true;
7 const [data, setData] = useState<T | null>(null); 7 const [data, setData] = useState<T | null>(null);
8− const [error, setError] = useState<unknown>(null); 8+ const [error, setError] = useState<Error | null>(null);
9 const [loading, setLoading] = useState<boolean>(enabled); 9 const [loading, setLoading] = useState<boolean>(enabled);
10 const [tick, setTick] = useState(0); 10 const [tick, setTick] = useState(0);
11 const fnRef = useRef(fn); 11 const fnRef = useRef(fn);
@@ -28,7 +28,7 @@ export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unkno
28 }) 28 })
29 .catch((e) => { 29 .catch((e) => {
30 if (!alive || ctl.signal.aborted) return; 30 if (!alive || ctl.signal.aborted) return;
31− setError(e); 31+ setError(e instanceof Error ? e : new Error(String(e)));
32 }) 32 })
33 .finally(() => { 33 .finally(() => {
34 if (alive) setLoading(false); 34 if (alive) setLoading(false);
modified services/api/pyproject.toml +1 −1
@@ -4,7 +4,7 @@ version = "0.1.0"
4 requires-python = ">=3.12" 4 requires-python = ">=3.12"
5 dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate", 5 dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate",
6 "fastapi>=0.115", "email-validator>=2.2", "uvicorn[standard]>=0.32", "pydantic>=2.9", "argon2-cffi>=23.1", "pyotp>=2.9", "qrcode>=8.0", 6 "fastapi>=0.115", "email-validator>=2.2", "uvicorn[standard]>=0.32", "pydantic>=2.9", "argon2-cffi>=23.1", "pyotp>=2.9", "qrcode>=8.0",
7− "prometheus-client>=0.21", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"] 7+ "prometheus-client>=0.21", "anthropic>=1.0", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"]
8 [tool.uv.sources] 8 [tool.uv.sources]
9 findata-core = { workspace = true } 9 findata-core = { workspace = true }
10 findata-schema = { workspace = true } 10 findata-schema = { workspace = true }
modified services/api/src/findata_api/main.py +2 −1
@@ -19,6 +19,7 @@ from findata_api import metrics as M
19 from findata_api.ratelimit import bucket 19 from findata_api.ratelimit import bucket
20 from findata_api.routers import ( 20 from findata_api.routers import (
21 admin, 21 admin,
22+ assistant,
22 auth, 23 auth,
23 catalog, 24 catalog,
24 coverage, 25 coverage,
@@ -111,7 +112,7 @@ def create_app() -> FastAPI:
111 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500) 112 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)
112 113
113 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router, 114 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,
114− coverage.router, quality.router, health.router, admin.router): 115+ coverage.router, quality.router, health.router, admin.router, assistant.router):
115 app.include_router(r, prefix=API_PREFIX) 116 app.include_router(r, prefix=API_PREFIX)
116 117
117 @app.get("/metrics", include_in_schema=False) 118 @app.get("/metrics", include_in_schema=False)
added services/api/src/findata_api/routers/assistant.py +137 −0
@@ -0,0 +1,137 @@
1+"""Optional AI data assistant (phase 6): natural language → *validated* read-only SQL over the catalog views.
2+
3+Guarantees: the model never executes anything — it only proposes SQL; FinData validates it with the same sandbox as
4+the playground (single SELECT, allow-listed directories, timeout, row cap), shows the SQL + EXPLAIN plan, cites the
5+dataset versions used, and refuses questions about data that is not in the catalog. Server-side BYOK (Anthropic)
6+only; status is `pending_configuration` until ANTHROPIC_API_KEY is set and the capability test has been recorded.
7+"""
8+from __future__ import annotations
9+
10+import json
11+import time
12+from typing import Any
13+
14+from fastapi import APIRouter, Depends, Request
15+from findata_core.config import get_settings
16+from findata_core.db import connection
17+from findata_ingest.reader import dataset_views, run_sql
18+from pydantic import BaseModel, Field
19+
20+from findata_api import auth as A
21+from findata_api.errors import ApiError
22+
23+router = APIRouter(prefix="/assistant", tags=["assistant"])
24+MODEL = "claude-opus-5-5"
25+
26+
27+class AskIn(BaseModel):
28+ question: str = Field(..., min_length=3, max_length=2000)
29+ max_rows: int = Field(200, ge=1, le=5000)
30+ execute: bool = True
31+
32+
33+def _catalog_context() -> tuple[str, dict[str, Any]]:
34+ with connection() as conn:
35+ ds = conn.execute("SELECT id, name, interval, adjustment, status, current_version, timestamp_convention, timezone FROM datasets WHERE kind IN ('market_bars','derived') ORDER BY id").fetchall()
36+ cov = conn.execute("SELECT dataset_id, min(session_date) d0, max(session_date) d1, count(DISTINCT symbol) syms FROM coverage GROUP BY 1").fetchall()
37+ covm = {c["dataset_id"]: c for c in cov}
38+ lines = ["Available DuckDB views (one per dataset; columns: instrument_id, symbol_as_reported, asset_class, interval, timestamp_utc (bar OPEN, UTC), session_date, open, high, low, close, volume, currency, trade_count, vwap, adjustment_type, source_id, ingested_at, quality_flags, revision_id):"]
39+ versions = {}
40+ for d in ds:
41+ c = covm.get(d["id"])
42+ v = f"view {d['id'].replace('.', '_')}: {d['name']} — interval {d['interval']}, adjustment {d['adjustment']}, status {d['status']}, version {d['current_version']}"
43+ if c:
44+ v += f", sessions {c['d0']}→{c['d1']}, {c['syms']} symbols"
45+ else:
46+ v += ", NO DATA LOADED"
47+ lines.append(v)
48+ versions[d["id"]] = d["current_version"]
49+ lines.append("Fundamentals are NOT available through SQL views (use the fundamentals endpoint); say so if asked.")
50+ return "\n".join(lines), versions
51+
52+
53+SYSTEM = """You translate research questions about a private market-data lake into ONE read-only DuckDB SQL statement.
54+Rules: SELECT/WITH only; use only the listed views and columns; never invent datasets, symbols, dates or columns that are not listed; if the question needs data that is not available (e.g. a dataset with NO DATA LOADED, fundamentals, options, ticks, a date outside coverage), set "sql" to null and explain in "refusal". Prices are raw (unadjusted) unless the view says otherwise. timestamp_utc marks the bar open; convert with timezone 'America/New_York' when the user talks about Eastern time. Always LIMIT results reasonably.
55+Respond with JSON only: {"sql": string|null, "explanation": string, "assumptions": [string], "refusal": string|null}."""
56+
57+
58+def _ask_model(question: str, context: str) -> dict[str, Any]:
59+ import anthropic
60+ s = get_settings()
61+ client = anthropic.Anthropic(api_key=s.anthropic_api_key.get_secret_value(), max_retries=1, timeout=90.0)
62+ try:
63+ resp = client.messages.create(
64+ model=MODEL, max_tokens=4000, output_config={"effort": "medium"},
65+ system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": context}],
66+ messages=[{"role": "user", "content": question}],
67+ )
68+ except anthropic.AuthenticationError:
69+ raise ApiError(502, "provider_auth", "Anthropic rejected the configured key") from None
70+ except anthropic.RateLimitError:
71+ raise ApiError(503, "provider_rate_limited", "Anthropic rate limit; retry later") from None
72+ except anthropic.APIStatusError as e:
73+ raise ApiError(502, "provider_error", f"Anthropic error {e.status_code}") from None
74+ except anthropic.APIConnectionError:
75+ raise ApiError(502, "provider_unreachable", "Could not reach Anthropic") from None
76+ if resp.stop_reason == "refusal":
77+ return {"sql": None, "explanation": "", "assumptions": [], "refusal": "The model declined this request."}
78+ text = "".join(b.text for b in resp.content if b.type == "text").strip()
79+ if text.startswith("```"):
80+ text = text.strip("`").split("\n", 1)[1] if "\n" in text else text.strip("`")
81+ text = text.rsplit("```", 1)[0]
82+ try:
83+ out = json.loads(text)
84+ except json.JSONDecodeError:
85+ return {"sql": None, "explanation": text[:500], "assumptions": [], "refusal": "Model output was not valid JSON"}
86+ out["_usage"] = {"input_tokens": resp.usage.input_tokens, "output_tokens": resp.usage.output_tokens,
87+ "cache_read_input_tokens": getattr(resp.usage, "cache_read_input_tokens", None), "model": resp.model}
88+ return out
89+
90+
91+@router.get("/status")
92+def status(p: A.Principal = Depends(A.require("query"))):
93+ s = get_settings()
94+ configured = bool(s.anthropic_api_key.get_secret_value())
95+ return {"status": "active" if configured else "pending_configuration", "provider": "anthropic" if configured else None, "model": MODEL if configured else None,
96+ "note": None if configured else "Set ANTHROPIC_API_KEY in the node .env (server-side BYOK) and run the capability/cost test described in docs/STATUS.md.",
97+ "guarantees": ["model proposes SQL only; FinData validates and executes in the read-only sandbox", "generated SQL and EXPLAIN plan are always shown",
98+ "dataset versions used are cited", "questions about data not in the catalog are refused"]}
99+
100+
101+@router.post("")
102+def ask(body: AskIn, request: Request, p: A.Principal = Depends(A.require("query"))):
103+ s = get_settings()
104+ if not s.anthropic_api_key.get_secret_value():
105+ raise ApiError(409, "pending_configuration", "AI assistant is not configured (ANTHROPIC_API_KEY missing on the server)")
106+ context, versions = _catalog_context()
107+ t0 = time.monotonic()
108+ proposal = _ask_model(body.question, context)
109+ out: dict[str, Any] = {"question": body.question, "sql": proposal.get("sql"), "explanation": proposal.get("explanation"),
110+ "assumptions": proposal.get("assumptions") or [], "refusal": proposal.get("refusal"),
111+ "dataset_versions": versions, "model_usage": proposal.get("_usage"), "executed": False}
112+ if not proposal.get("sql"):
113+ A.audit(request, "assistant.ask", None, refused=True, question=body.question[:300])
114+ return out
115+ views = dataset_views()
116+ try:
117+ plan, _ = run_sql(f"EXPLAIN {proposal['sql']}", 50, 10, views)
118+ out["plan"] = "\n".join(str(r.get("explain_value") or r) for r in plan.to_pylist())
119+ except Exception as e:
120+ out["refusal"] = f"Generated SQL rejected by the sandbox: {str(e)[:300]}"
121+ A.audit(request, "assistant.ask", None, rejected=True, question=body.question[:300], sql=proposal["sql"][:500])
122+ return out
123+ if body.execute:
124+ try:
125+ tbl, meta = run_sql(proposal["sql"], min(body.max_rows, s.query_max_rows), s.query_timeout_seconds, views)
126+ except Exception as e:
127+ out["refusal"] = f"Execution failed: {str(e)[:300]}"
128+ return out
129+ rows = tbl.to_pylist()
130+ for r in rows:
131+ for k, v in r.items():
132+ if hasattr(v, "isoformat"):
133+ r[k] = v.isoformat()
134+ out.update({"executed": True, "columns": [{"name": f.name, "type": str(f.type)} for f in tbl.schema], "rows": rows,
135+ "truncated": meta.get("truncated"), "elapsed_ms": int((time.monotonic() - t0) * 1000)})
136+ A.audit(request, "assistant.ask", None, question=body.question[:300], sql=proposal["sql"][:500], rows=len(out.get("rows", [])))
137+ return out
modified services/ingest/src/findata_ingest/reader.py +2 −1
@@ -139,7 +139,8 @@ def run_sql(sql: str, max_rows: int, timeout_s: int, views: dict[str, list[str]]
139 timer = threading.Timer(timeout_s, con.interrupt) 139 timer = threading.Timer(timeout_s, con.interrupt)
140 timer.start() 140 timer.start()
141 try: 141 try:
142− tbl = _arrow(con, f"SELECT * FROM ({sql.strip().rstrip(';')}) q LIMIT {int(max_rows) + 1}", None) 142+ body = sql.strip().rstrip(";")
143+ tbl = _arrow(con, body if is_explain else f"SELECT * FROM ({body}) q LIMIT {int(max_rows) + 1}", None)
143 except duckdb.InterruptException as e: 144 except duckdb.InterruptException as e:
144 raise TimeoutError(f"query exceeded {timeout_s}s") from e 145 raise TimeoutError(f"query exceeded {timeout_s}s") from e
145 finally: 146 finally:
modified tests/test_z_api_sdk.py +26 −0
@@ -272,3 +272,29 @@ def test_vendor_secret_never_in_responses_or_openapi(app_client, api_key):
272 r = c.get("/api/v1/health") 272 r = c.get("/api/v1/health")
273 assert r.headers["X-Content-Type-Options"] == "nosniff" and r.headers["X-Frame-Options"] == "DENY" and "default-src 'self'" in r.headers["Content-Security-Policy"] 273 assert r.headers["X-Content-Type-Options"] == "nosniff" and r.headers["X-Frame-Options"] == "DENY" and "default-src 'self'" in r.headers["Content-Security-Policy"]
274 assert c.get("/api/docs").status_code == 200 274 assert c.get("/api/docs").status_code == 200
275+
276+
277+def test_assistant_pending_without_key_and_validated_with_stub(app_client, api_key, monkeypatch):
278+ c = app_client
279+ H = {"Authorization": f"Bearer {api_key}"}
280+ st = c.get("/api/v1/assistant/status", headers=H).json()
281+ assert st["status"] == "pending_configuration"
282+ r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H)
283+ assert r.status_code == 409 and r.json()["error"]["code"] == "pending_configuration"
284+ # stub the provider: the platform must still validate/execute the SQL itself and refuse unsafe statements
285+ from findata_api.routers import assistant as AS
286+ from findata_core.config import get_settings
287+ from pydantic import SecretStr
288+ monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr("sk-test"))
289+ monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "SELECT avg(close) AS avg_close FROM market_bars_stock_1m_raw WHERE symbol_as_reported='AAPL' AND session_date=DATE '2026-10-30'", "explanation": "mean of 1m closes", "assumptions": ["raw prices"], "refusal": None, "_usage": {"input_tokens": 1, "output_tokens": 1}})
290+ r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H)
291+ assert r.status_code == 200, r.text
292+ b = r.json()
293+ assert b["executed"] and 300 < b["rows"][0]["avg_close"] < 400 and "plan" in b and b["dataset_versions"]["market_bars.stock.1m.raw"] >= 1
294+ monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "COPY (SELECT 1) TO '/tmp/x'", "explanation": "", "assumptions": [], "refusal": None})
295+ b = c.post("/api/v1/assistant", json={"question": "exfiltrate"}, headers=H).json()
296+ assert not b["executed"] and "rejected by the sandbox" in b["refusal"]
297+ monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": None, "explanation": "", "assumptions": [], "refusal": "Options data is not loaded"})
298+ b = c.post("/api/v1/assistant", json={"question": "SPY option chain"}, headers=H).json()
299+ assert b["sql"] is None and "not loaded" in b["refusal"]
300+ monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr(""))
modified uv.lock +182 −0
@@ -41,6 +41,25 @@ wheels = [
41 { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, 41 { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
42 ] 42 ]
43 43
44+[[package]]
45+name = "anthropic"
46+version = "2.3.0"
47+source = { registry = "https://pypi.org/simple" }
48+dependencies = [
49+ { name = "anyio" },
50+ { name = "docstring-parser" },
51+ { name = "httpx2" },
52+ { name = "jiter" },
53+ { name = "opentelemetry-api" },
54+ { name = "pydantic" },
55+ { name = "sniffio" },
56+ { name = "typing-extensions" },
57+]
58+sdist = { url = "https://files.pythonhosted.org/packages/3e/ba/4c91947e0d0062d66d371db371597287153f7685fbed512d3294d0cde3c7/anthropic-2.3.0.tar.gz", hash = "sha256:edfdea1e3fa7b3ce25df1511953408b2f03bfa61eb6f6268f766101a9e18ad53", size = 1869656, upload-time = "2026-10-23T23:18:47.649Z" }
59+wheels = [
60+ { url = "https://files.pythonhosted.org/packages/3f/29/eb3558ab6bf252a07ac41c76ac5b0ea24a0aa0c23f87a40aaf31a5f8dba4/anthropic-2.3.0-py3-none-any.whl", hash = "sha256:aa6a3984f9276f307933d42dc6f58b6efc821a5ecafbc8d1bb5993a066767622", size = 2216980, upload-time = "2026-10-23T23:18:45.779Z" },
61+]
62+
44 [[package]] 63 [[package]]
45 name = "anyio" 64 name = "anyio"
46 version = "4.16.0" 65 version = "4.16.0"
@@ -413,6 +432,15 @@ wheels = [
413 { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" }, 432 { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" },
414 ] 433 ]
415 434
435+[[package]]
436+name = "docstring-parser"
437+version = "0.18.0"
438+source = { registry = "https://pypi.org/simple" }
439+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
440+wheels = [
441+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
442+]
443+
416 [[package]] 444 [[package]]
417 name = "duckdb" 445 name = "duckdb"
418 version = "2.0.0" 446 version = "2.0.0"
@@ -489,6 +517,7 @@ name = "findata-api"
489 version = "0.1.0" 517 version = "0.1.0"
490 source = { editable = "services/api" } 518 source = { editable = "services/api" }
491 dependencies = [ 519 dependencies = [
520+ { name = "anthropic" },
492 { name = "argon2-cffi" }, 521 { name = "argon2-cffi" },
493 { name = "duckdb" }, 522 { name = "duckdb" },
494 { name = "email-validator" }, 523 { name = "email-validator" },
@@ -511,6 +540,7 @@ dependencies = [
511 540
512 [package.metadata] 541 [package.metadata]
513 requires-dist = [ 542 requires-dist = [
543+ { name = "anthropic", specifier = ">=1.0" },
514 { name = "argon2-cffi", specifier = ">=23.1" }, 544 { name = "argon2-cffi", specifier = ">=23.1" },
515 { name = "duckdb", specifier = ">=1.1" }, 545 { name = "duckdb", specifier = ">=1.1" },
516 { name = "email-validator", specifier = ">=2.2" }, 546 { name = "email-validator", specifier = ">=2.2" },
@@ -762,6 +792,19 @@ wheels = [
762 { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 792 { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
763 ] 793 ]
764 794
795+[[package]]
796+name = "httpcore2"
797+version = "2.16.0"
798+source = { registry = "https://pypi.org/simple" }
799+dependencies = [
800+ { name = "h11", marker = "sys_platform != 'emscripten'" },
801+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
802+]
803+sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
804+wheels = [
805+ { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
806+]
807+
765 [[package]] 808 [[package]]
766 name = "httptools" 809 name = "httptools"
767 version = "0.9.0" 810 version = "0.9.0"
@@ -863,6 +906,32 @@ wheels = [
863 { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 906 { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
864 ] 907 ]
865 908
909+[[package]]
910+name = "httpx2"
911+version = "2.16.0"
912+source = { registry = "https://pypi.org/simple" }
913+dependencies = [
914+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
915+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
916+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
917+ { name = "idna" },
918+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
919+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
920+]
921+sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
922+wheels = [
923+ { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
924+]
925+
926+[[package]]
927+name = "httpx2-jsfetch"
928+version = "1.0"
929+source = { registry = "https://pypi.org/simple" }
930+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
931+wheels = [
932+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
933+]
934+
866 [[package]] 935 [[package]]
867 name = "idna" 936 name = "idna"
868 version = "3.20" 937 version = "3.20"
@@ -890,6 +959,101 @@ wheels = [
890 { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, 959 { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
891 ] 960 ]
892 961
962+[[package]]
963+name = "jiter"
964+version = "0.18.0"
965+source = { registry = "https://pypi.org/simple" }
966+sdist = { url = "https://files.pythonhosted.org/packages/63/5b/f68319f4fe624311a744a33d10fdbcac4c971991e348223cba60d31780b2/jiter-0.18.0.tar.gz", hash = "sha256:a0ca63fd9450a212e4e658d4cfe0ab4977c4f9b0003c68828b843c65e27662ff", size = 234470, upload-time = "2026-10-23T13:51:13.103Z" }
967+wheels = [
968+ { url = "https://files.pythonhosted.org/packages/45/0e/075bfa4fa46820da9ac8cf8a67c435b0586012279fba15deccde0e73b9ce/jiter-0.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:77a01cdf4381a1becbe5b827f682006f47b712581667451c5b1eb259b9ae7041", size = 290095, upload-time = "2026-10-23T13:49:14.118Z" },
969+ { url = "https://files.pythonhosted.org/packages/2e/e2/f4000a4d9c519ca019bf0e2922cc0c6b6db347be6303161382eda7635be7/jiter-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b41cd64ee6b54db94bea6274e6e37002ed526aa06c114a8be6f36ffe21c184e", size = 316319, upload-time = "2026-10-23T13:49:15.263Z" },
970+ { url = "https://files.pythonhosted.org/packages/51/93/9aaef4d18d19446fd044b9cff0323b1b45b5526f077f1a80418567a6eef8/jiter-0.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7424194e68ef44e94290961f59319850d7ce11a329a3957f91709c15ded6618", size = 335631, upload-time = "2026-10-23T13:49:16.551Z" },
971+ { url = "https://files.pythonhosted.org/packages/fd/9e/d2dc5e8179e248df7ef1c7c8822554f4c7f694a33be30790bb523f1e657a/jiter-0.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a484fce032a8b2c21dec0410a805d7b16573eb34297156a3b4d242c507d2b4", size = 322596, upload-time = "2026-10-23T13:49:17.875Z" },
972+ { url = "https://files.pythonhosted.org/packages/22/ca/28bfd20743be0b2b37c1ddfe069a637d5180e535f1c8faad555cf7ac0b98/jiter-0.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:860ac97858116bda0ac02ef5f50c541fc1a84ad7b9368cac68d4de5697a9012d", size = 333858, upload-time = "2026-10-23T13:49:19.343Z" },
973+ { url = "https://files.pythonhosted.org/packages/69/89/e42f7dba54dcf7787aa4be3e22cdcb1f14d9635b6527949652ab490d6f8e/jiter-0.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39ef656cf371971486ec545ca0ae2da8d037d5479bc906121e3e46de86ee73c4", size = 348656, upload-time = "2026-10-23T13:49:20.796Z" },
974+ { url = "https://files.pythonhosted.org/packages/74/6a/b13407f609ddaf9a1f0cdf1336326dc5c8c7fc56943e8450a04a542f5edf/jiter-0.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c741e6b778c951a6861964ee475118812a33208426c54fb92bf4e318bda834", size = 336286, upload-time = "2026-10-23T13:49:22.152Z" },
975+ { url = "https://files.pythonhosted.org/packages/b2/7e/b560f97495a485a569e7fa338a13587328854f6a40cd2e93602c327ca4a6/jiter-0.18.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:1277ac083a58d2f49a24ff9a28fd9876a6443a1992d4467a1db80fb45e354b18", size = 314415, upload-time = "2026-10-23T13:49:23.645Z" },
976+ { url = "https://files.pythonhosted.org/packages/8b/97/457b6ec16f1f3f25ec760f23728f43a2b20ddf934fcd8f02c81a2183597e/jiter-0.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccf224d2e706be81d464b59c2bc3f98ba65d12c092c1789e625127e64488944e", size = 332078, upload-time = "2026-10-23T13:49:24.917Z" },
977+ { url = "https://files.pythonhosted.org/packages/0e/d9/6f616071650f7bb8fcbce674d4dde7e59cfbd8a4d30f5c71c03630425d6e/jiter-0.18.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5c7fc8e5357d1ad082bb177be71e398fcd3d458726439255ca5cb0d706060269", size = 480354, upload-time = "2026-10-23T13:49:26.221Z" },
978+ { url = "https://files.pythonhosted.org/packages/42/72/52a3630c8213cdc8093cc86dc6ad50f2bc89ccfa97ef272205d38a24e16d/jiter-0.18.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a6bba0404b46157a651d1fe512cf2c59c8e42836a9284c35cdff35310758ba4f", size = 515322, upload-time = "2026-10-23T13:49:27.652Z" },
979+ { url = "https://files.pythonhosted.org/packages/31/54/987cfee52bdf8a286e9c1c58192a9bb64f8773a6be36e4ec46959f67fb7e/jiter-0.18.0-cp312-cp312-win32.whl", hash = "sha256:07ae058bc052ead609b49bbae78c01bf4dee8f5f9d21705cbae9bfe59b33efc9", size = 185341, upload-time = "2026-10-23T13:49:29.147Z" },
980+ { url = "https://files.pythonhosted.org/packages/c5/c9/b1279eecf68e5f62c918af3f7f856a071ae7db0c90b8fd2235b1b6020c8c/jiter-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:79d1fe529bd7065c6f3f50279a08c2ab82eaa0631a6f5dfafa9c65105eeaaafa", size = 225889, upload-time = "2026-10-23T13:49:30.306Z" },
981+ { url = "https://files.pythonhosted.org/packages/e0/14/715f84be58a7fc97779aa40c37164acfa25ccb2cee89bfb69358e7071ff3/jiter-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f8b3b3d8761dee3cddcb5d3e99eaccc0155bbefc345affcdedcc758a4933a1f", size = 181852, upload-time = "2026-10-23T13:49:31.811Z" },
982+ { url = "https://files.pythonhosted.org/packages/50/94/aa275cdb099fd84e402977f8fc8628e2cee2f7c8eafe1d1d5c5e722f5ee5/jiter-0.18.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1517648b5f6268513dea206241a99d9991d6ffcbdbc406848e77a7f02ed3ccf1", size = 289648, upload-time = "2026-10-23T13:49:33.059Z" },
983+ { url = "https://files.pythonhosted.org/packages/8d/30/3eaf67279c6183393d3608b6713c93668ae82ea59f0589ea59d8176d8741/jiter-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7161217e2190b9b2c8c79b81699be29d67010f6f77e09a53a3b62261792d5489", size = 315919, upload-time = "2026-10-23T13:49:34.457Z" },
984+ { url = "https://files.pythonhosted.org/packages/f8/2b/d18adcfec3516b97b24c0d5cd1b0ced22cada8ea9806f21bece75af49ffe/jiter-0.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9623fc90f1a4754452a525383ce70476476c16556098944709b8cdbd9ad905f0", size = 335231, upload-time = "2026-10-23T13:49:35.776Z" },
985+ { url = "https://files.pythonhosted.org/packages/41/e8/42cff1077883ea5311621dc9384a1ede4799bc3c01411798ac75f7a71f15/jiter-0.18.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9083a7c2a4ee6358bc0401c46b4d5090f208f6fadfb6275c7b5fe3217e13506", size = 321590, upload-time = "2026-10-23T13:49:36.997Z" },
986+ { url = "https://files.pythonhosted.org/packages/1d/4d/2fbedfce563223d4783a872cead5f5bb74d2d1e418f963410ca28e4b7394/jiter-0.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4632f5172800da4cd42fd53f4553c772acd08f1c33dd7ac135e29d735080fa0d", size = 332960, upload-time = "2026-10-23T13:49:38.615Z" },
987+ { url = "https://files.pythonhosted.org/packages/04/a0/b082b2d8bd3bef9a888cc0a0f060a24644f24b5195f2e8227a40507e6b71/jiter-0.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c00b1f8fc1dd06c610305be36778b38148ed1e9a532bdd81189c3c0d9905c888", size = 348149, upload-time = "2026-10-23T13:49:39.826Z" },
988+ { url = "https://files.pythonhosted.org/packages/64/11/babc8d3c004bb2f11b7e3adfe27a5bc7c1e13cdfc6360d424c57355c55ff/jiter-0.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e3350b7431f7b8315b925d9ca0f29a44e8fb6ed45c1fe552392cb6297966bb9", size = 335978, upload-time = "2026-10-23T13:49:41.165Z" },
989+ { url = "https://files.pythonhosted.org/packages/3b/21/c50bf83c39d60f0df896ebcb025b316b670a703397d08bea2c69b9615101/jiter-0.18.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3b49071fb7917cdbc6d80c81a491c6ad146c1a8fd6cb3076cd3c1bfeb6e266f6", size = 313767, upload-time = "2026-10-23T13:49:42.538Z" },
990+ { url = "https://files.pythonhosted.org/packages/af/19/3f66480d5bfd3f49571a073eefac4d890dd590a8eb3c78b15e0293909cc6/jiter-0.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04e210f5ddcccdbde484821040216a112381e6484abc549c20c38e9698ab07ca", size = 331070, upload-time = "2026-10-23T13:49:43.782Z" },
991+ { url = "https://files.pythonhosted.org/packages/5a/3d/b5662c84b14475c8b018ed1ca2ea44069194b7d97a03bac81ae6b46f6b9f/jiter-0.18.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5b339f6ae14fe7be92f391cc65cc5c80ff76e8da76d63b18fc14664f09b7cece", size = 479939, upload-time = "2026-10-23T13:49:45.033Z" },
992+ { url = "https://files.pythonhosted.org/packages/80/b8/081c0414379ee2564aa7808f78e963538a2c4aed2f8d31775e2423e1cdbf/jiter-0.18.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:5cb43336244e063f9cc11e44730e4b92dea70c6eb9b649d268c5ebebc4bc7685", size = 514734, upload-time = "2026-10-23T13:49:46.236Z" },
993+ { url = "https://files.pythonhosted.org/packages/0f/1c/6f7bf98786851ae424a1d88562586302536cc137848fd12d2add3960fab3/jiter-0.18.0-cp313-cp313-win32.whl", hash = "sha256:9656d0e9001f69c4af6523874ff445ab4e5db7b835f59733cc5ff51e73a9aa57", size = 184699, upload-time = "2026-10-23T13:49:47.398Z" },
994+ { url = "https://files.pythonhosted.org/packages/38/53/5803216c024a241a0bf41ed2668713d7228f92c5593e40d5e873e3b20e0b/jiter-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f26198220165eb9627847401fff4d0ca8bebf3ef840a88c6312918d2291c33a", size = 222493, upload-time = "2026-10-23T13:49:48.533Z" },
995+ { url = "https://files.pythonhosted.org/packages/fe/8e/484919ccb041c9a1e84059be02bd37ff086b8b084e21630e0958d3ae41a3/jiter-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:bef1fe687dc6af17bef9f644a95d465f15f0efa20b743c858e0b6a14e2c0c73e", size = 181250, upload-time = "2026-10-23T13:49:49.777Z" },
996+ { url = "https://files.pythonhosted.org/packages/74/0e/45a502771d73d4a939132d8bf85d7b049c7ca06a07f84b414b525c3dc084/jiter-0.18.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:248e939f67a9f8030e2624260a9f417decfe275af37aa96728d256468a9c4086", size = 291757, upload-time = "2026-10-23T13:49:50.95Z" },
997+ { url = "https://files.pythonhosted.org/packages/21/f6/d9cfa2bf2a969fb8e002fb8d8a6cd98d49a6248cb7e4ffe2247766af13fb/jiter-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0390fa550fcafbf69fb97ccb287bd71192f90410a1c7dd600ca4000aa9aabcf2", size = 318585, upload-time = "2026-10-23T13:49:52.407Z" },
998+ { url = "https://files.pythonhosted.org/packages/46/38/ea1dae56f123e3a864188cbbc577314293c0dcc019a880fa9c0a8097934a/jiter-0.18.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8013186f2b9a8c27ededa601050392527aa9d27ffd35d49aae424fbd2062f38", size = 338099, upload-time = "2026-10-23T13:49:53.658Z" },
999+ { url = "https://files.pythonhosted.org/packages/1f/d9/fcc5c53a32f98d52f4fd46fda62b69f5c7c2c975a582c0e18501250a4f78/jiter-0.18.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd4316df8e2074fcf5173f6001f1fc9596e7c1e8c4bf499b02f9ebe92babdfb1", size = 324016, upload-time = "2026-10-23T13:49:54.999Z" },
1000+ { url = "https://files.pythonhosted.org/packages/d4/63/c24cd08907f08e6a3ba3173616ad268a3d32dd81c63ba34b991d08577d28/jiter-0.18.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9e474da7a9f65d3b460cca93cc8a0f4ec0220d1fa41f1f7a3709195ead60056", size = 335904, upload-time = "2026-10-23T13:49:56.214Z" },
1001+ { url = "https://files.pythonhosted.org/packages/0b/78/19ca1c8fe87bd3c8540b91504ff2dd52c9c538233044c4cf787fc1fab39b/jiter-0.18.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d55acd326db8ecfc94f21951abd20902c7939d35925444e48d94527e287bead7", size = 350523, upload-time = "2026-10-23T13:49:57.552Z" },
1002+ { url = "https://files.pythonhosted.org/packages/c3/27/84be9cc1c2b781d2da7c694fd7608311af3aaab91f20eb00d4999285b351/jiter-0.18.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a01642d403b8925f2b1e1bb51b4ba49ece8a2bdc9b9560fc8ed0b1f2b35b567a", size = 338324, upload-time = "2026-10-23T13:49:58.842Z" },
1003+ { url = "https://files.pythonhosted.org/packages/9a/ac/7b3bfb49b19e42ebc2ae56ec307c372602dd1e17892f7e051c9bf97cd7e6/jiter-0.18.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:89ba6b674d4bd7290f9943bbeba0e1bf35955bbb1e07e41ba94691648cf133d5", size = 316484, upload-time = "2026-10-23T13:50:00.257Z" },
1004+ { url = "https://files.pythonhosted.org/packages/f9/6a/e37f50885aa4daa1dfe413784692ead6b48a1d9e857cd394a61bf345189c/jiter-0.18.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:feadebdfee32c61e91066433f4aa74c891752e26e43032c1feef08dda6d9b6ed", size = 334225, upload-time = "2026-10-23T13:50:01.542Z" },
1005+ { url = "https://files.pythonhosted.org/packages/8b/f8/1026bc92f0fd443a1137993d1448a7d7c4b7a34bec2722a1533302ebc4b4/jiter-0.18.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd7e57efd54a3580620f635c48a3226f910e23649b7eb83ffbc6f0f3386293b8", size = 482157, upload-time = "2026-10-23T13:50:02.849Z" },
1006+ { url = "https://files.pythonhosted.org/packages/1b/2d/61f3c35736873c34eeee3d0008067febd88ded8e8cf6f9a0ab090348a787/jiter-0.18.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ea69e23481497c2143775a98d273ccc43de5c92a6387df18c903d2d37bc2b181", size = 516956, upload-time = "2026-10-23T13:50:04.19Z" },
1007+ { url = "https://files.pythonhosted.org/packages/6b/bf/9d14be58e76324f4927c7f8e5dbe2f9fd04faaec884a6465173f8142eac1/jiter-0.18.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d9b61af76a3cd90ad9656a331737d97dab8c05facb2d04cb41fb16c9adadc966", size = 112087, upload-time = "2026-10-23T13:50:05.423Z" },
1008+ { url = "https://files.pythonhosted.org/packages/c6/7d/cff751c560d514da283bae595fe154714f285ec5ac15e2b16e9e0c0c8948/jiter-0.18.0-cp314-cp314-win32.whl", hash = "sha256:fe42726d7fe171cd8fc1966d4457a6aae03bd408d519d2cc5e7726805460b51e", size = 186562, upload-time = "2026-10-23T13:50:06.81Z" },
1009+ { url = "https://files.pythonhosted.org/packages/04/70/4e7f51e517be4146400fd1ce642d535380a38c7db5ab475d38bbcd16a82d/jiter-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:f1e738b851019d6bb6ff4a42c6319bdede967dec958ef3ec6feb7ab9d12408f7", size = 225448, upload-time = "2026-10-23T13:50:08.302Z" },
1010+ { url = "https://files.pythonhosted.org/packages/78/43/02bfb85f63e04c87c406731197e41f2e6b415035e41497428e0422fb326e/jiter-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:80a961b1c5c7786bc03b332deb7f7ebe818b7baa6e26685e2bf480548ca128b4", size = 182907, upload-time = "2026-10-23T13:50:09.795Z" },
1011+ { url = "https://files.pythonhosted.org/packages/9c/66/5cbde35cbb14c8339af39c9bc61cbda08b212116f96603d23b7c9cc84a4d/jiter-0.18.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b066f37dd9ec19fc7931d79dee38f56b6f849217bbe824a7dad4518ef96ee515", size = 318962, upload-time = "2026-10-23T13:50:10.91Z" },
1012+ { url = "https://files.pythonhosted.org/packages/eb/d1/dccc47ed6070d3b25622823048a80a49fe87ccd737e5d66b802ca20e4fde/jiter-0.18.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccdc09887b04f50f1761eedcfd19da882d9d834096bc1215049af637023f7db7", size = 336718, upload-time = "2026-10-23T13:50:12.149Z" },
1013+ { url = "https://files.pythonhosted.org/packages/de/a5/8d4ec06d7a0f2bccbde8508ed847ccd323a9dd1fbab5900cda3fc5bd71f8/jiter-0.18.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6133abcd0f332964fbb8145adb1ce775cd0312c9eaf6e4bf26b7a5c9b6f7cca7", size = 322945, upload-time = "2026-10-23T13:50:13.591Z" },
1014+ { url = "https://files.pythonhosted.org/packages/7f/50/49d06214b30c685d64ffdd40fdb50a98b6ccff4b012fc9ed491d44de9ef1/jiter-0.18.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca381b4a466023d1fdd04c889c759b46f7d9654f622b28bb01e65abf31d07bba", size = 335325, upload-time = "2026-10-23T13:50:14.807Z" },
1015+ { url = "https://files.pythonhosted.org/packages/f4/9a/4ecb866a7a01a4e76d9ffbde3fac2cd66ad978c8c797953d44f91fbf2040/jiter-0.18.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b961fedf589ffa182fe302fc4371acf4606d3ed4a6a48a151fbfd724d811f5a", size = 350578, upload-time = "2026-10-23T13:50:16.125Z" },
1016+ { url = "https://files.pythonhosted.org/packages/16/7e/c8b5ecb6af4c9454376263f167f3f8bb5dec41b10fac4f48ea4557b9f115/jiter-0.18.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b77eabfbae1ac47872be0076899e66340bbef39b862971c3d6c9a4b7c91fd10", size = 338791, upload-time = "2026-10-23T13:50:17.703Z" },
1017+ { url = "https://files.pythonhosted.org/packages/8a/04/45ba31ce79022706323fd2291f87d956fd51fdfcba2ba8924b3de62f0ce2/jiter-0.18.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92a16933d0fa852133500837d33cd6a4f75e4414d77389c58269e12bafc2ee02", size = 316169, upload-time = "2026-10-23T13:50:18.915Z" },
1018+ { url = "https://files.pythonhosted.org/packages/62/c1/fd36126144f4c32d1b76939278e9d3b7b798075c0478b641d6066621bafd/jiter-0.18.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f7ba518893a4ccef5afb38e41bc3e126bf652354417ec4e75e5a5b000fb1ef6", size = 334754, upload-time = "2026-10-23T13:50:20.169Z" },
1019+ { url = "https://files.pythonhosted.org/packages/cc/e2/30a5ac1a3ee9e01bda75a4fcb55bbeddcf8875f8ce7e680b787c177f14b5/jiter-0.18.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:04d67a44b040a6662a35edc9869030f7aa566bf5baaf3549c99d847aa44213e2", size = 481094, upload-time = "2026-10-23T13:50:21.481Z" },
1020+ { url = "https://files.pythonhosted.org/packages/33/de/5e736a36eff6933da012dc440540f79e3ccfc06e27f557638280657df023/jiter-0.18.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e4d4819398d5330636bc0b91ccc0048dba782076fb99257038257ba6f121c840", size = 515524, upload-time = "2026-10-23T13:50:22.872Z" },
1021+ { url = "https://files.pythonhosted.org/packages/0a/ea/1e8bc5b0c920a13e5b698106dbb2d2da5c5e3474668746246f649abb6d5f/jiter-0.18.0-cp314-cp314t-win32.whl", hash = "sha256:c435e795a7ea4b372166a058a5b49a25caeb9c94e469a1414868b14c83ee5c41", size = 185058, upload-time = "2026-10-23T13:50:24.098Z" },
1022+ { url = "https://files.pythonhosted.org/packages/1e/0f/a742b397699c3af52dd95443332164884fe6204e5faa5321b7c073beab0f/jiter-0.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a3d5397da7ae46c7a9601ef02d2c9ec47a6c3abe15e1ea940eaf93e675ee20e", size = 225598, upload-time = "2026-10-23T13:50:25.44Z" },
1023+ { url = "https://files.pythonhosted.org/packages/b3/b7/08ae26347dca48d5cadca33be2f2f5b6b1ae790b322109083d6f95959feb/jiter-0.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4b81b474e4a8ec745ab20a6762275a360e1147cb70cf08872ec14e1aa5debc37", size = 183052, upload-time = "2026-10-23T13:50:26.675Z" },
1024+ { url = "https://files.pythonhosted.org/packages/c2/d6/0d2a8a9747ad918141827fabdc05abc3bf73986f7f36647fb8c19708e21e/jiter-0.18.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:30f8ae18425e0247ffff4e4a5753ec1952e1b81ed4e7949b84e5ecfaf8315833", size = 291908, upload-time = "2026-10-23T13:50:27.94Z" },
1025+ { url = "https://files.pythonhosted.org/packages/54/b4/19c582727e22681c6069db037c3b3e354c6fbdce7fbc5290a7bc7bc5884f/jiter-0.18.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:93150a3bb0317d8f5a94d4c3ea68db1cb434b94ac819e08c443cea97b7645f2f", size = 316544, upload-time = "2026-10-23T13:50:29.167Z" },
1026+ { url = "https://files.pythonhosted.org/packages/c3/62/5d34e93dc5d1e707788a378335efc876cdc731ceb2111b0a9d7c6f9fdada/jiter-0.18.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c8a61d6227cdcb0a67c678f30a020837c9f3dd7e0b3f916781defa157e4eb9", size = 338101, upload-time = "2026-10-23T13:50:30.457Z" },
1027+ { url = "https://files.pythonhosted.org/packages/75/e2/0b16a6ca5479cd9efa3c59497cd42f1979a539b6c664a5b9c87e3632f176/jiter-0.18.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:285cc15ff9ffcd4ce7f0b9ce02fcbc65e22fcf7dfbda1a3d71ccac8d7b73f8a1", size = 324213, upload-time = "2026-10-23T13:50:31.695Z" },
1028+ { url = "https://files.pythonhosted.org/packages/85/9d/620a97457a0210092bf9faa1eb30f5da094fab2de07a9478af7c254e22fd/jiter-0.18.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8f69ef2e28f8c1759bc1522385ab3e4ade0b1056053cecf67449a947c4525a8", size = 335777, upload-time = "2026-10-23T13:50:32.984Z" },
1029+ { url = "https://files.pythonhosted.org/packages/76/08/a3514df093824f10a61dc1d0bbc22285ed779ba42f15d727499a8c877e16/jiter-0.18.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eec2da60423287bd60377b0fad1723560dcc51cfb2b99615be8431ad425c4438", size = 350490, upload-time = "2026-10-23T13:50:34.421Z" },
1030+ { url = "https://files.pythonhosted.org/packages/cd/60/8f8b51bae62259b429e83d0adff69d93e39e4fadbc4bd1856dc2a0950639/jiter-0.18.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5db2cdae6a2f01e8f52ab2154886dcf5bb1844b4f31245440bf623ec91b65b08", size = 338591, upload-time = "2026-10-23T13:50:35.759Z" },
1031+ { url = "https://files.pythonhosted.org/packages/2e/ed/5307f0eb051d987510fdd1020c904927c0b364a4937d372817bdefaced31/jiter-0.18.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:029da1f29852beac2f9001f20209b73b29b4d7bdc748c7f8028895769e2cc0d8", size = 316623, upload-time = "2026-10-23T13:50:37.316Z" },
1032+ { url = "https://files.pythonhosted.org/packages/f9/0e/4bdc945ffbf77c60f07c726fbeb45508018322df9d9942d262256397062e/jiter-0.18.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:67c6662b1b7e5728a507db06ad1b8a7ff8ade04c2a74f092d467209ef744d3a4", size = 334720, upload-time = "2026-10-23T13:50:38.507Z" },
1033+ { url = "https://files.pythonhosted.org/packages/4b/33/fb3e8f9354e7edab05f3752383eac58ce6e785f2f1626b697dd5dd0a86e4/jiter-0.18.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:4db12daef4a231df528a16f70d5ff7f5e3be21f70e8ad904e4c61442366e8a10", size = 482130, upload-time = "2026-10-23T13:50:40.023Z" },
1034+ { url = "https://files.pythonhosted.org/packages/2e/3d/3aef2be95155bbacadd8691208b6f0f7371cc0cefe80241a1245e38e308d/jiter-0.18.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:4654ea93f525ea96adf289afcf1ead32d773b8325ec326862b4f81f5f4570835", size = 516986, upload-time = "2026-10-23T13:50:41.279Z" },
1035+ { url = "https://files.pythonhosted.org/packages/3c/f6/2aa54fd5da3d0a93de2655e2525426263168454259ba3f2fb70d1b36fbc9/jiter-0.18.0-cp315-cp315-win32.whl", hash = "sha256:f035d01b450ac4e141502f1ce142462cee638198ff5a0237c80e6683798b3b87", size = 186645, upload-time = "2026-10-23T13:50:42.526Z" },
1036+ { url = "https://files.pythonhosted.org/packages/b5/77/5b54ca8c292b83991cafbac6b022f515e46f1ae77dd5f4a97cc33644b235/jiter-0.18.0-cp315-cp315-win_amd64.whl", hash = "sha256:4a18ef4fe95685b000d95eca797cc683e0c178a4b364f2e39f6c79ab107a26e6", size = 225087, upload-time = "2026-10-23T13:50:43.779Z" },
1037+ { url = "https://files.pythonhosted.org/packages/9f/f3/53b7f39b83c757380ab57d9bc3f934431d6b6751f007e8122f7f63c10c2c/jiter-0.18.0-cp315-cp315-win_arm64.whl", hash = "sha256:c73922e4ff4b1ad2929cd795c98223c6688a43c5fd42e9deb8955e4d43b00abb", size = 183120, upload-time = "2026-10-23T13:50:44.985Z" },
1038+ { url = "https://files.pythonhosted.org/packages/77/f8/3a3ef55c528722f3b4be0e7b9686a37ddbe86ed96510bc1c2a63dab7f14b/jiter-0.18.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0ccf55f6fcdcee82fe40ae617e9d64b8e5f433fa6cb5bf7d2969e584e6df0e5d", size = 317996, upload-time = "2026-10-23T13:50:46.266Z" },
1039+ { url = "https://files.pythonhosted.org/packages/e1/15/b06c1c17bed30b5c5dabb40f80a56a3b71b89c16ae86552307f7ccedc93a/jiter-0.18.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:163a55e77f8e92d739b1f4ef212f1d445ddc58ecdf0da1feca44ef424b5331f8", size = 336638, upload-time = "2026-10-23T13:50:47.444Z" },
1040+ { url = "https://files.pythonhosted.org/packages/87/79/b7bcd3e8c850326a323d18a5607334ea40659e22a4fc321e315d1d230007/jiter-0.18.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9de6c9133be12a51ea2d8c270f39a04ed45ee369802bb6270649b952e6cec256", size = 323116, upload-time = "2026-10-23T13:50:48.715Z" },
1041+ { url = "https://files.pythonhosted.org/packages/5e/99/4c53c9d46462cd0691c3c188f89612e63a424d19f5befd1b0631f8f90638/jiter-0.18.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ea4f2ed18eba7454182d4ff727444788ae51ad11bf5a25ad892f5f0ad3e754b", size = 335162, upload-time = "2026-10-23T13:50:49.987Z" },
1042+ { url = "https://files.pythonhosted.org/packages/6f/ab/ef873065a3a1c442a4e62558b77bfa338066317dbafdfdc991121201f5ad/jiter-0.18.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:626d5c9fb8b57babc6ce9d03224846ecb094e1d22ff5bff6080398e7f8aee162", size = 350560, upload-time = "2026-10-23T13:50:51.478Z" },
1043+ { url = "https://files.pythonhosted.org/packages/19/7e/48e78558d7cb3d289211da0397b7ed31cfe3efc98dd85f2324abd574c07c/jiter-0.18.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c3318cc6ef857b6554b43479c30e8249fc143189d500d4137cde9c58412e6d", size = 337848, upload-time = "2026-10-23T13:50:52.961Z" },
1044+ { url = "https://files.pythonhosted.org/packages/2c/35/b0a093e72def7caec970dd8536c3d42af3add3aedfb3c06ac0588effde26/jiter-0.18.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:9a70f25f6de76a01e309e109c9bcf2143c5c126b3488dd17a25c7aa404b28f16", size = 315870, upload-time = "2026-10-23T13:50:54.152Z" },
1045+ { url = "https://files.pythonhosted.org/packages/48/a4/fd7ca4d1ce8b876be7a260f5e31615854aed5b041553a63e85c4a265cb63/jiter-0.18.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22d8dd757e603224e46786d63cb7d7bfbddc8f579a02e7b082840468d18c06f3", size = 335253, upload-time = "2026-10-23T13:50:55.427Z" },
1046+ { url = "https://files.pythonhosted.org/packages/f5/71/ccd7154afecd4687b22a8195d5dc6460f89f6e17537ec78edb58a2faac34/jiter-0.18.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:552a8714f26ddcb43115c82da6d045b3b9f2fa5997d8b21b2f8ec3721503bebb", size = 480775, upload-time = "2026-10-23T13:50:56.91Z" },
1047+ { url = "https://files.pythonhosted.org/packages/c8/b9/bad5391d7fb734cd6ebc383c8603bc324518bf4ce865f5d813908ae42596/jiter-0.18.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:8060a206e290410d3bfc3bd91715f3ec90a499246f8335d48927e0b3b6a6ccc5", size = 515619, upload-time = "2026-10-23T13:50:58.231Z" },
1048+ { url = "https://files.pythonhosted.org/packages/c8/5c/9596452ad56340e6ed395c5fe27ab102bb2aa0c4b59de2e6a8b3dc3b03a7/jiter-0.18.0-cp315-cp315t-win32.whl", hash = "sha256:91d463858f11ee76ce8e39f391bbcbf1d4c3cc4703a1496fed3a5666dcd5e01d", size = 185204, upload-time = "2026-10-23T13:50:59.456Z" },
1049+ { url = "https://files.pythonhosted.org/packages/08/c8/62c798e2a1a6f827eac1d45232c93b1b0e387a22d559c8483dc7302d5fd5/jiter-0.18.0-cp315-cp315t-win_amd64.whl", hash = "sha256:6ee49daab0b826279c152ad8d61171ea17f4197f91a5e14251d0d5d4adf51059", size = 224948, upload-time = "2026-10-23T13:51:00.624Z" },
1050+ { url = "https://files.pythonhosted.org/packages/64/42/cdfa494914b6d512431e795d8d6226b84b77b733bb8b5eccafd5a1a86dad/jiter-0.18.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bec676a0dc24d4dabd4bdfb3d1f1bf80d4d3dd892fc5c5b3f8f953e2459dbfce", size = 183182, upload-time = "2026-10-23T13:51:01.797Z" },
1051+ { url = "https://files.pythonhosted.org/packages/a0/c2/49ee8f4743c27e61c52b4746ba636b43bedda97131a14043dc6f5ce5bb9d/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:118c1fd995f349f1cad0557f31d6860f336b4aeef7f002dedbc068027f68e2c3", size = 287302, upload-time = "2026-10-23T13:51:08.073Z" },
1052+ { url = "https://files.pythonhosted.org/packages/60/72/394036294ac3226216f73bfa4b14df920eb5baf7fb80fa7e0a0d7ef442a6/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:44d0d719cd7845e66b01a0cf346ec9882073e710a05e89a5b6d87be47a9dc69c", size = 277797, upload-time = "2026-10-23T13:51:09.324Z" },
1053+ { url = "https://files.pythonhosted.org/packages/ce/80/f0dadedaa9a109f31e8978c7db355593916968d5956f382188a3f8c0888c/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d1d2dc4d930ebc225f488b6b3790acebb2e51b3a165a4a42cc1d05bb927f83e", size = 300192, upload-time = "2026-10-23T13:51:10.551Z" },
1054+ { url = "https://files.pythonhosted.org/packages/93/51/e57dbc6c264ec6557a677189755fe4525d83439237418e3ae59771ac1cde/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb0c346b6c5d9c033fe9e9b6ed6a1ff6dac0288fe681c3434dde6eb9c264fa1", size = 301141, upload-time = "2026-10-23T13:51:11.882Z" },
1055+]
1056+
893 [[package]] 1057 [[package]]
894 name = "korean-lunar-calendar" 1058 name = "korean-lunar-calendar"
895 version = "0.4.0" 1059 version = "0.4.0"
@@ -1790,6 +1954,15 @@ wheels = [
1790 { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, 1954 { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
1791 ] 1955 ]
1792 1956
1957+[[package]]
1958+name = "sniffio"
1959+version = "1.3.1"
1960+source = { registry = "https://pypi.org/simple" }
1961+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
1962+wheels = [
1963+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
1964+]
1965+
1793 [[package]] 1966 [[package]]
1794 name = "sortedcontainers" 1967 name = "sortedcontainers"
1795 version = "2.4.0" 1968 version = "2.4.0"
@@ -1902,6 +2075,15 @@ wheels = [
1902 { url = "https://files.pythonhosted.org/packages/db/17/4c8beb6c8c4176c6bf143bfd7e1e4dd6719b00ced90738c7ac471b71c1df/toolz-1.2.0-py3-none-any.whl", hash = "sha256:890f820b1cb8152785aaf9386d8707770110809035800985ca65cb24ce1120ef", size = 60789, upload-time = "2026-10-07T04:16:24.173Z" }, 2075 { url = "https://files.pythonhosted.org/packages/db/17/4c8beb6c8c4176c6bf143bfd7e1e4dd6719b00ced90738c7ac471b71c1df/toolz-1.2.0-py3-none-any.whl", hash = "sha256:890f820b1cb8152785aaf9386d8707770110809035800985ca65cb24ce1120ef", size = 60789, upload-time = "2026-10-07T04:16:24.173Z" },
1903 ] 2076 ]
1904 2077
2078+[[package]]
2079+name = "truststore"
2080+version = "0.10.5"
2081+source = { registry = "https://pypi.org/simple" }
2082+sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
2083+wheels = [
2084+ { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
2085+]
2086+
1905 [[package]] 2087 [[package]]
1906 name = "typer" 2088 name = "typer"
1907 version = "0.27.3" 2089 version = "0.27.3"
1908 2090
Menu

Theme