aggregate_api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. aggregate_api/__init__.py +41 -0
  2. aggregate_api/__main__.py +154 -0
  3. aggregate_api/app.py +206 -0
  4. aggregate_api/audit.py +395 -0
  5. aggregate_api/bounds.py +331 -0
  6. aggregate_api/cache.py +319 -0
  7. aggregate_api/capability.py +823 -0
  8. aggregate_api/completion.py +219 -0
  9. aggregate_api/config.py +363 -0
  10. aggregate_api/cors.py +61 -0
  11. aggregate_api/examples.py +620 -0
  12. aggregate_api/layer_pricing.py +840 -0
  13. aggregate_api/library.py +94 -0
  14. aggregate_api/library_notes.py +96 -0
  15. aggregate_api/models.py +1407 -0
  16. aggregate_api/net.py +281 -0
  17. aggregate_api/pnl.py +101 -0
  18. aggregate_api/pricing.py +778 -0
  19. aggregate_api/resources.py +257 -0
  20. aggregate_api/routes/__init__.py +8 -0
  21. aggregate_api/routes/decl.py +327 -0
  22. aggregate_api/routes/examples.py +82 -0
  23. aggregate_api/routes/meta.py +282 -0
  24. aggregate_api/routes/objects.py +4119 -0
  25. aggregate_api/routes/status.py +466 -0
  26. aggregate_api/serializers.py +565 -0
  27. aggregate_api/sessions.py +353 -0
  28. aggregate_api/static/aggregate-api-logo-512.png +0 -0
  29. aggregate_api/static/aggregate-api-logo.png +0 -0
  30. aggregate_api/static/aggregate-api-trim.png +0 -0
  31. aggregate_api/static/android-chrome-192x192.png +0 -0
  32. aggregate_api/static/android-chrome-512x512.png +0 -0
  33. aggregate_api/static/apple-touch-icon.png +0 -0
  34. aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
  35. aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
  36. aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
  37. aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
  38. aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
  39. aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
  40. aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
  41. aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
  42. aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
  43. aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
  44. aggregate_api/static/assets/main-CmoEiPit.js +9 -0
  45. aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
  46. aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
  47. aggregate_api/static/favicon-16x16.png +0 -0
  48. aggregate_api/static/favicon-32x32.png +0 -0
  49. aggregate_api/static/favicon.ico +0 -0
  50. aggregate_api/static/index.html +912 -0
  51. aggregate_api/static/lite.html +83 -0
  52. aggregate_api/static/logo.png +0 -0
  53. aggregate_api/static/site.webmanifest +14 -0
  54. aggregate_api/static/sw.js +78 -0
  55. aggregate_api/status.py +536 -0
  56. aggregate_api/status_page.html +546 -0
  57. aggregate_api/tables.py +316 -0
  58. aggregate_api-1.0.0.dist-info/METADATA +187 -0
  59. aggregate_api-1.0.0.dist-info/RECORD +63 -0
  60. aggregate_api-1.0.0.dist-info/WHEEL +5 -0
  61. aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
  62. aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
  63. aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
aggregate_api/audit.py ADDED
@@ -0,0 +1,395 @@
1
+ """SQLite-backed audit log for build attempts.
2
+
3
+ One row per ``POST /v1/objects`` request, regardless of outcome.
4
+ The log captures the DecL source, build knobs, status, error
5
+ message (if any), elapsed time, client IP, and timestamp.
6
+
7
+ Why SQLite
8
+ ----------
9
+
10
+ * Stdlib (zero extra deps in the ``[api]`` extra).
11
+ * WAL mode lets readers and the audit writer not block each other.
12
+ * Trivially inspectable from the shell with ``sqlite3 audit.db
13
+ "select * from builds order by ts desc limit 20"``.
14
+
15
+ The team-deploy assumption is that audit reads are infrequent and
16
+ manual; there's no admin route in v1.
17
+
18
+ Schema design notes
19
+ -------------------
20
+
21
+ * ``object_id`` is NULLABLE because failed builds (parse errors,
22
+ timeouts) never produce one.
23
+ * ``kind`` is NULLABLE for the same reason.
24
+ * ``elapsed_ms`` is captured even on failure -- a 9000 ms parse
25
+ error is qualitatively different from a 5 ms one.
26
+ * ``status`` is a free-form text label (not an enum) so we can
27
+ add new states ('rate_limited' etc.) without a migration.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import sqlite3
33
+ import threading
34
+ from contextlib import closing
35
+ from datetime import datetime, timedelta, timezone
36
+ from pathlib import Path
37
+
38
+ # ----------------------------------------------------------------------
39
+ # DDL
40
+ # ----------------------------------------------------------------------
41
+ # Wrapped in IF NOT EXISTS so :meth:`AuditLog._ensure_schema` is
42
+ # idempotent -- every connection re-runs it cheaply and the schema
43
+ # survives server restarts without explicit migrations.
44
+ _SCHEMA = """
45
+ CREATE TABLE IF NOT EXISTS builds (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ ts TEXT NOT NULL,
48
+ ip TEXT NOT NULL,
49
+ object_id TEXT,
50
+ kind TEXT,
51
+ decl TEXT NOT NULL,
52
+ log2 INTEGER,
53
+ bs REAL,
54
+ status TEXT NOT NULL,
55
+ error_msg TEXT,
56
+ elapsed_ms INTEGER NOT NULL,
57
+ session_id TEXT,
58
+ key_scope TEXT
59
+ );
60
+ CREATE INDEX IF NOT EXISTS builds_ts ON builds(ts);
61
+ CREATE INDEX IF NOT EXISTS builds_ip ON builds(ip);
62
+ """
63
+
64
+ # Columns added after the table shipped. ``CREATE TABLE IF NOT EXISTS`` leaves an
65
+ # existing table alone, so a database written before a110 has neither of these
66
+ # and every insert would fail on the column count. SQLite has no
67
+ # ``ADD COLUMN IF NOT EXISTS``, so the check is a table read and the add is
68
+ # guarded by it.
69
+ _ADDED_COLUMNS = (
70
+ ("session_id", "TEXT"),
71
+ ("key_scope", "TEXT"),
72
+ )
73
+
74
+
75
+ class AuditLog:
76
+ """Append-only SQLite log.
77
+
78
+ Connections aren't shared across threads (sqlite3 forbids it
79
+ by default and the cost of a fresh connection per write is
80
+ irrelevant for our volume). A single lock serializes writes
81
+ so concurrent build endpoints don't race on the AUTOINCREMENT
82
+ sequence.
83
+
84
+ Set ``journal_mode=WAL`` on each connection so the audit file
85
+ can be inspected with the ``sqlite3`` CLI while the server is
86
+ running.
87
+ """
88
+
89
+ def __init__(self, db_path: str | Path) -> None:
90
+ self.db_path = Path(db_path)
91
+ self._lock = threading.Lock()
92
+ # Ensure the parent directory exists. The default audit-db
93
+ # path lives under ``~/.aggregate/api/`` which probably
94
+ # doesn't exist on a fresh install.
95
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
96
+ # Trigger initial schema creation, set WAL mode.
97
+ with closing(self._connect()) as conn:
98
+ conn.executescript(_SCHEMA)
99
+ self._add_missing_columns(conn)
100
+
101
+ @staticmethod
102
+ def _add_missing_columns(conn: sqlite3.Connection) -> None:
103
+ """Bring a pre-existing table up to the current column list.
104
+
105
+ Notes
106
+ -----
107
+ Deliberately not a migration framework. The table is append-only and
108
+ every column added since it shipped is nullable, so "add what is
109
+ missing" is the whole of it, and a database from any earlier version
110
+ reaches the current shape in one pass. Old rows keep NULL, which reads
111
+ correctly as "written before the api knew about sessions".
112
+ """
113
+ held = {row[1] for row in conn.execute("PRAGMA table_info(builds)")}
114
+ for column, sql_type in _ADDED_COLUMNS:
115
+ if column not in held:
116
+ conn.execute(f"ALTER TABLE builds ADD COLUMN {column} {sql_type}")
117
+
118
+ def _connect(self) -> sqlite3.Connection:
119
+ """Open a new connection with sensible defaults.
120
+
121
+ ``isolation_level=None`` puts the connection in autocommit
122
+ mode -- we use explicit transactions when we want them.
123
+ ``check_same_thread=False`` is *not* set: each call
124
+ produces a fresh connection that lives only for the
125
+ duration of the caller's ``with`` block.
126
+
127
+ Notes
128
+ -----
129
+ **Every caller wraps this in** :func:`contextlib.closing`, and the
130
+ wrapper is not decoration. ``with sqlite3.connect(...) as conn`` is
131
+ sqlite3's *transaction* context manager: it commits on a clean exit and
132
+ rolls back on an exception, and it does **not** close the connection.
133
+ So the plain form, which is what this class used through a70, leaked one
134
+ connection per build and one per read, each of them held until the
135
+ garbage collector got to it. The finalizer is what then printed
136
+ ``ResourceWarning: unclosed database in <sqlite3.Connection ...>``, and
137
+ because ``pricing.py`` was capturing warnings unscoped at the time, that
138
+ line reached the reader's status strip as though their program had
139
+ provoked it. Both halves are fixed; this is the half that stops the
140
+ connection leaking in the first place.
141
+ """
142
+ conn = sqlite3.connect(self.db_path)
143
+ # WAL gives readers a stable snapshot while writes proceed,
144
+ # so ``sqlite3 audit.db`` from the shell never deadlocks.
145
+ conn.execute("PRAGMA journal_mode=WAL")
146
+ # NORMAL trades durability of the last ~few writes for ~10x
147
+ # write throughput -- fine for an audit log.
148
+ conn.execute("PRAGMA synchronous=NORMAL")
149
+ conn.row_factory = sqlite3.Row
150
+ return conn
151
+
152
+ def record_build(
153
+ self,
154
+ *,
155
+ ip: str,
156
+ decl: str,
157
+ log2: int | None,
158
+ bs: float | None,
159
+ status: str,
160
+ object_id: str | None = None,
161
+ kind: str | None = None,
162
+ error_msg: str | None = None,
163
+ elapsed_ms: int = 0,
164
+ session_id: str | None = None,
165
+ key_scope: str | None = None,
166
+ ) -> None:
167
+ """Append one row.
168
+
169
+ Parameters
170
+ ----------
171
+ ip : str
172
+ Client address from ``routes.objects._client_ip``, which reads the
173
+ forwarded chain and falls back to the peer. ``"-"`` when neither
174
+ says anything. Rows written before a112 read the peer alone, so
175
+ every one of them from behind Caddy says ``127.0.0.1``.
176
+ decl : str
177
+ The raw DecL submitted (not the canonicalized form).
178
+ log2, bs : int|None, float|None
179
+ Build knobs as requested -- may be None when omitted.
180
+ status : str
181
+ One of ``'ok' | 'value' | 'parse_error' | 'build_error' | 'timeout'
182
+ | 'limit_exceeded'``. ``'value'`` is a program that meant a number
183
+ (``(2+2)``), which builds and answers but leaves no object; it has
184
+ its own status so the operator's page does not read arithmetic as
185
+ object builds. The summary groups by this column, so a new status
186
+ appears as its own row rather than disturbing an existing one.
187
+ object_id : str|None
188
+ Set on success.
189
+ kind : str|None
190
+ ``'agg'`` or ``'port'`` on success; None on failure.
191
+ error_msg : str|None
192
+ One-line error summary (e.g. ``ErrorReport.message``).
193
+ elapsed_ms : int
194
+ Wall-clock time, including parse + cache check + build.
195
+ session_id : str|None
196
+ The caller's session, or ``'anonymous'`` for a headerless client.
197
+ key_scope : str|None
198
+ ``'shared'`` or ``'session'``: which cache key this request used.
199
+ Recorded because it is the one number that says whether the
200
+ qualification rule is working. A demo where nearly every build is
201
+ ``'session'`` means the rule is firing on programs that do not need
202
+ it, and the room is paying for builds it could have shared.
203
+ """
204
+ # ISO 8601 with UTC; chosen for sortability and unambiguous TZ.
205
+ ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
206
+ with self._lock, closing(self._connect()) as conn:
207
+ conn.execute(
208
+ """INSERT INTO builds
209
+ (ts, ip, object_id, kind, decl, log2, bs, status, error_msg,
210
+ elapsed_ms, session_id, key_scope)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
212
+ (ts, ip, object_id, kind, decl, log2, bs, status, error_msg,
213
+ elapsed_ms, session_id, key_scope),
214
+ )
215
+ conn.commit()
216
+
217
+ def recent(self, n: int = 100) -> list[dict]:
218
+ """Most recent ``n`` rows, newest first."""
219
+ with closing(self._connect()) as conn:
220
+ rows = conn.execute(
221
+ "SELECT * FROM builds ORDER BY ts DESC LIMIT ?", (n,)
222
+ ).fetchall()
223
+ return [dict(r) for r in rows]
224
+
225
+ def by_ip(self, ip: str, n: int = 100) -> list[dict]:
226
+ """Recent rows from a specific client."""
227
+ with closing(self._connect()) as conn:
228
+ rows = conn.execute(
229
+ "SELECT * FROM builds WHERE ip = ? ORDER BY ts DESC LIMIT ?",
230
+ (ip, n),
231
+ ).fetchall()
232
+ return [dict(r) for r in rows]
233
+
234
+ @staticmethod
235
+ def _cutoff(window_s: float) -> str:
236
+ """The ``ts`` value marking the start of a window ``window_s`` back.
237
+
238
+ Notes
239
+ -----
240
+ A string comparison, not a date function. Every row's ``ts`` is written
241
+ by :meth:`record_build` as an ISO 8601 UTC timestamp of fixed width, and
242
+ fixed-width ISO 8601 in one timezone sorts lexicographically in time
243
+ order, so ``ts >= ?`` is both correct and able to use the ``builds_ts``
244
+ index. Calling SQLite's ``datetime()`` on the column instead would be
245
+ correct and would scan the table.
246
+ """
247
+ start = datetime.now(timezone.utc) - timedelta(seconds=window_s)
248
+ return start.isoformat(timespec="milliseconds")
249
+
250
+ def window_summaries(self, windows, *, top: int = 5,
251
+ slowest: int = 5) -> dict:
252
+ """Several windows over one connection.
253
+
254
+ Parameters
255
+ ----------
256
+ windows : iterable of (str, float)
257
+ Label and window length in seconds, for example
258
+ ``(('hour', 3600.0), ('day', 86400.0))``.
259
+ top, slowest : int
260
+ Passed through to :meth:`window_summary`.
261
+
262
+ Returns
263
+ -------
264
+ dict
265
+ Label to summary.
266
+
267
+ Notes
268
+ -----
269
+ One connection for the lot. Each :meth:`_connect` runs two ``PRAGMA``
270
+ statements before the first query, which on the status route's two
271
+ windows was the larger half of the cost: the queries themselves are
272
+ indexed and answer in microseconds. The connection is still per call
273
+ rather than held, which is the pattern the rest of this class uses and
274
+ the reason it is safe across threads.
275
+ """
276
+ with closing(self._connect()) as conn:
277
+ return {label: self.window_summary(window, top=top, slowest=slowest,
278
+ conn=conn)
279
+ for label, window in windows}
280
+
281
+ def window_summary(self, window_s: float, *, top: int = 5,
282
+ slowest: int = 5, conn: sqlite3.Connection | None = None) -> dict:
283
+ """Everything ``GET /v1/status`` says about builds in one time window.
284
+
285
+ Parameters
286
+ ----------
287
+ window_s : float
288
+ How far back to look, in seconds.
289
+ top : int
290
+ How many error messages and how many clients to name.
291
+ slowest : int
292
+ How many slow builds to list.
293
+ conn : sqlite3.Connection, optional
294
+ An open connection to reuse. Given by :meth:`window_summaries` so
295
+ several windows share one; ``None`` opens and closes its own.
296
+
297
+ Returns
298
+ -------
299
+ dict
300
+ ``window_s``, ``total``, ``by_status``, ``by_key_scope``,
301
+ ``p50_ms``, ``p95_ms``, ``slowest``, ``top_errors``,
302
+ ``distinct_clients`` and ``top_clients``.
303
+
304
+ Notes
305
+ -----
306
+ **Seven small queries rather than one Python pass over the table.** The
307
+ audit database grows without bound by design, so reading rows into
308
+ Python to count them is a page whose cost rises with the age of the
309
+ deployment. Every query here is bounded by the window, is served by the
310
+ ``builds_ts`` index, and carries a ``LIMIT``.
311
+
312
+ The percentiles are offsets into an ordered window rather than an
313
+ interpolated quantile, because SQLite has no percentile function and the
314
+ alternative is loading the column. Nearest rank on a few thousand rows
315
+ is the same number to the millisecond the page prints.
316
+
317
+ ``by_key_scope`` covers only rows written since a110, which is when the
318
+ column arrived. Older rows read ``NULL`` and are grouped under
319
+ ``'unknown'`` rather than silently folded into either scope.
320
+ """
321
+ if conn is None:
322
+ with closing(self._connect()) as owned:
323
+ return self.window_summary(window_s, top=top, slowest=slowest,
324
+ conn=owned)
325
+ cutoff = self._cutoff(window_s)
326
+ total = conn.execute(
327
+ "SELECT COUNT(*) FROM builds WHERE ts >= ?", (cutoff,)
328
+ ).fetchone()[0]
329
+ by_status = {
330
+ row[0]: row[1] for row in conn.execute(
331
+ "SELECT status, COUNT(*) FROM builds WHERE ts >= ? "
332
+ "GROUP BY status ORDER BY COUNT(*) DESC LIMIT 20", (cutoff,))
333
+ }
334
+ by_key_scope = {
335
+ (row[0] or "unknown"): row[1] for row in conn.execute(
336
+ "SELECT key_scope, COUNT(*) FROM builds WHERE ts >= ? "
337
+ "GROUP BY key_scope ORDER BY COUNT(*) DESC LIMIT 20", (cutoff,))
338
+ }
339
+ percentiles = {}
340
+ for label, q in (("p50_ms", 0.50), ("p95_ms", 0.95)):
341
+ if total == 0:
342
+ percentiles[label] = None
343
+ continue
344
+ offset = min(total - 1, max(0, int(round(q * (total - 1)))))
345
+ percentiles[label] = conn.execute(
346
+ "SELECT elapsed_ms FROM builds WHERE ts >= ? "
347
+ "ORDER BY elapsed_ms LIMIT 1 OFFSET ?", (cutoff, offset)
348
+ ).fetchone()[0]
349
+ slow = [dict(row) for row in conn.execute(
350
+ "SELECT ts, elapsed_ms, status, kind, object_id, session_id "
351
+ "FROM builds WHERE ts >= ? ORDER BY elapsed_ms DESC LIMIT ?",
352
+ (cutoff, slowest))]
353
+ errors = [{"error_msg": row[0], "count": row[1]} for row in conn.execute(
354
+ "SELECT error_msg, COUNT(*) FROM builds WHERE ts >= ? "
355
+ "AND error_msg IS NOT NULL GROUP BY error_msg "
356
+ "ORDER BY COUNT(*) DESC LIMIT ?", (cutoff, top))]
357
+ distinct = conn.execute(
358
+ "SELECT COUNT(DISTINCT ip) FROM builds WHERE ts >= ?", (cutoff,)
359
+ ).fetchone()[0]
360
+ clients = [{"ip": row[0], "count": row[1]} for row in conn.execute(
361
+ "SELECT ip, COUNT(*) FROM builds WHERE ts >= ? GROUP BY ip "
362
+ "ORDER BY COUNT(*) DESC LIMIT ?", (cutoff, top))]
363
+ return {
364
+ "window_s": window_s,
365
+ "total": total,
366
+ "by_status": by_status,
367
+ "by_key_scope": by_key_scope,
368
+ **percentiles,
369
+ "slowest": slow,
370
+ "top_errors": errors,
371
+ "distinct_clients": distinct,
372
+ "top_clients": clients,
373
+ }
374
+
375
+ def size_bytes(self) -> int | None:
376
+ """Bytes on disk for the database and its write-ahead log, or ``None``.
377
+
378
+ Notes
379
+ -----
380
+ The WAL is counted because it is real disk and can be the larger of the
381
+ two between checkpoints, and the page exists partly so that the audit
382
+ database's growth becomes obvious: section 8 question 4 of
383
+ ``dev/done/plan-site-status-page.md`` is the retention decision this number
384
+ is meant to prompt.
385
+ """
386
+ total = 0
387
+ seen = False
388
+ for suffix in ("", "-wal", "-shm"):
389
+ path = Path(str(self.db_path) + suffix)
390
+ try:
391
+ total += path.stat().st_size
392
+ seen = True
393
+ except OSError:
394
+ continue
395
+ return total if seen else None