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
@@ -0,0 +1,257 @@
1
+ """What the process costs, from ``psutil`` when it is installed and stdlib when not.
2
+
3
+ ``psutil`` is an optional extra (``uv sync --extra status``), so this module has
4
+ two implementations of the same small block of numbers and says which one it
5
+ used. Everything here is a read of counters the operating system already
6
+ maintains; nothing walks a Python object or touches the cache.
7
+
8
+ **A field that cannot be filled renders "unavailable" with the reason, never
9
+ zero.** A resource panel reporting 0 bytes resident is worse than one reporting
10
+ nothing, because the first is a number an operator will act on. So every
11
+ absence carries why it is absent, and the page prints the reason beside the
12
+ blank.
13
+
14
+ The extra is worth declaring even though ``psutil`` is usually already there.
15
+ It arrives transitively through ``ipython``, which ``aggregate`` pulls, so the
16
+ import succeeds today on any box with the dev environment: by accident rather
17
+ than by intent, and one unrelated dependency change away from not. Declaring it
18
+ also means the stdlib path is never exercised by accident, which is why the
19
+ tests force it.
20
+
21
+ CPU percent must not block
22
+ --------------------------
23
+
24
+ ``psutil.Process.cpu_percent(interval=...)`` sleeps for the interval and then
25
+ reports. The ``interval=None`` form reports the share since the previous call on
26
+ the same object instead, costing nothing, and :func:`seed` makes the first such
27
+ call at startup so the first page load has a denominator. The stdlib path does
28
+ the same arithmetic by hand off :func:`time.process_time`. A status page that
29
+ blocks a worker for a second is a status page that lies about latency.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import shutil
36
+ import sys
37
+ import threading
38
+ import time
39
+ from pathlib import Path
40
+
41
+ try: # pragma: no cover (the branch taken depends on the environment)
42
+ import psutil
43
+ except ImportError: # pragma: no cover
44
+ psutil = None
45
+
46
+ #: Set by :func:`seed`. Held rather than created per call because
47
+ #: ``cpu_percent(interval=None)`` measures against the previous call on **this
48
+ #: object**, so a fresh Process each time would report zero forever.
49
+ _process = None
50
+
51
+ #: The stdlib path's previous sample, ``(monotonic, process_time)``.
52
+ _cpu_mark: tuple[float, float] | None = None
53
+
54
+ _lock = threading.Lock()
55
+
56
+ #: Reason strings, spelled once so the tests can assert on them.
57
+ NO_PSUTIL = "psutil not installed; install the status extra"
58
+ NO_PROC = "no /proc on this platform"
59
+ NO_LOADAVG = "no system load average on this platform"
60
+
61
+
62
+ def available() -> bool:
63
+ """True when ``psutil`` imported, which decides which path :func:`snapshot` takes."""
64
+ return psutil is not None
65
+
66
+
67
+ def seed() -> None:
68
+ """Prime the CPU measurement so the first page load has a denominator.
69
+
70
+ Notes
71
+ -----
72
+ Called from :func:`aggregate_api.app.create_app`. Both paths need a previous
73
+ sample to measure against, and without one the first reading is either zero
74
+ (``psutil``) or a division by a zero interval (stdlib). Seeding at startup
75
+ means the first reading is the share since boot, which is a fair number
76
+ rather than a placeholder.
77
+ """
78
+ global _process, _cpu_mark
79
+ with _lock:
80
+ _cpu_mark = (time.monotonic(), time.process_time())
81
+ if psutil is None:
82
+ return
83
+ try:
84
+ _process = psutil.Process()
85
+ _process.cpu_percent(interval=None)
86
+ except Exception: # noqa: BLE001 (a resource panel must never break boot)
87
+ _process = None
88
+
89
+
90
+ def _cpu_percent_stdlib() -> float | None:
91
+ """CPU share since the previous call, from :func:`time.process_time`.
92
+
93
+ Notes
94
+ -----
95
+ ``time.process_time`` is stdlib and cross-platform, unlike
96
+ ``resource.getrusage``, and it counts system plus user CPU for the process,
97
+ which is what ``psutil`` reports too. The result exceeds 100 on a
98
+ multi-core box running several threads hot, which is the same convention
99
+ ``psutil`` uses and is left uncapped for that reason.
100
+ """
101
+ global _cpu_mark
102
+ now, cpu = time.monotonic(), time.process_time()
103
+ with _lock:
104
+ previous = _cpu_mark
105
+ _cpu_mark = (now, cpu)
106
+ if previous is None:
107
+ return None
108
+ elapsed = now - previous[0]
109
+ if elapsed <= 0:
110
+ return None
111
+ return round(100.0 * (cpu - previous[1]) / elapsed, 1)
112
+
113
+
114
+ def _load_average(unavailable: dict) -> list[float] | None:
115
+ """System load average, or ``None`` with a reason recorded.
116
+
117
+ Notes
118
+ -----
119
+ ``os.getloadavg`` rather than ``psutil.getloadavg``, on both paths and
120
+ deliberately. psutil emulates the figure on Windows by sampling in a
121
+ background thread, and an emulated load average on a page whose whole
122
+ argument is that it reports honestly is worse than a blank. On the Linux VPS
123
+ the two agree because psutil reads the same source.
124
+ """
125
+ try:
126
+ return [round(value, 2) for value in os.getloadavg()]
127
+ except (OSError, AttributeError):
128
+ unavailable["load_average"] = NO_LOADAVG
129
+ return None
130
+
131
+
132
+ def _disk(path: str, unavailable: dict) -> tuple[int | None, int | None]:
133
+ """Free and total bytes on the volume holding ``path``.
134
+
135
+ Notes
136
+ -----
137
+ Walks up to the first parent that exists. The audit database is created
138
+ lazily, so on a fresh install the configured path and often its directory
139
+ are both absent, and reporting the volume they *would* land on is the useful
140
+ answer rather than a blank.
141
+ """
142
+ candidate = Path(path).expanduser()
143
+ for parent in [candidate, *candidate.parents]:
144
+ if parent.exists():
145
+ try:
146
+ usage = shutil.disk_usage(parent)
147
+ except OSError as exc:
148
+ unavailable["disk"] = str(exc)
149
+ return None, None
150
+ return usage.free, usage.total
151
+ unavailable["disk"] = f"no existing parent of {path}"
152
+ return None, None
153
+
154
+
155
+ def _proc_memory(unavailable: dict) -> tuple[int | None, int | None]:
156
+ """Resident and virtual bytes from ``/proc/self/statm``, the stdlib path.
157
+
158
+ Notes
159
+ -----
160
+ ``statm`` reports pages, first field virtual and second resident, so both
161
+ are multiplied by the page size. Linux only, which covers the VPS; the
162
+ Windows development box gets the reason instead, which is the case the extra
163
+ exists to fix.
164
+ """
165
+ try:
166
+ fields = Path("/proc/self/statm").read_text().split()
167
+ except OSError:
168
+ unavailable["memory"] = NO_PROC if sys.platform != "linux" else NO_PSUTIL
169
+ return None, None
170
+ try:
171
+ page = os.sysconf("SC_PAGE_SIZE")
172
+ return int(fields[1]) * page, int(fields[0]) * page
173
+ except (ValueError, IndexError, OSError, AttributeError) as exc:
174
+ unavailable["memory"] = str(exc)
175
+ return None, None
176
+
177
+
178
+ def _proc_open_files(unavailable: dict) -> int | None:
179
+ """Open file descriptors from ``/proc/self/fd``, the stdlib path."""
180
+ try:
181
+ return len(os.listdir("/proc/self/fd"))
182
+ except OSError:
183
+ unavailable["open_files"] = NO_PROC if sys.platform != "linux" else NO_PSUTIL
184
+ return None
185
+
186
+
187
+ def snapshot(audit_db: str) -> dict:
188
+ """The resource block.
189
+
190
+ Parameters
191
+ ----------
192
+ audit_db : str
193
+ Configured path of the audit database, used to pick the volume whose
194
+ free space is reported. It is the one file this process grows without
195
+ bound, so it is the one worth watching.
196
+
197
+ Returns
198
+ -------
199
+ dict
200
+ ``source`` names which implementation answered. Every metric is present
201
+ as a key with ``None`` where it could not be read, and ``unavailable``
202
+ maps the missing ones to why. Callers render the reason; nothing here
203
+ substitutes a zero.
204
+ """
205
+ unavailable: dict[str, str] = {}
206
+ free, total = _disk(audit_db, unavailable)
207
+ block = {
208
+ "source": "psutil" if _process is not None else "stdlib",
209
+ "platform": sys.platform,
210
+ "rss_bytes": None,
211
+ "vms_bytes": None,
212
+ "cpu_percent": None,
213
+ "threads": None,
214
+ "open_files": None,
215
+ "load_average": _load_average(unavailable),
216
+ "disk_free_bytes": free,
217
+ "disk_total_bytes": total,
218
+ "unavailable": unavailable,
219
+ }
220
+
221
+ if _process is not None:
222
+ try:
223
+ memory = _process.memory_info()
224
+ block["rss_bytes"] = int(memory.rss)
225
+ block["vms_bytes"] = int(memory.vms)
226
+ block["cpu_percent"] = round(float(_process.cpu_percent(interval=None)), 1)
227
+ block["threads"] = int(_process.num_threads())
228
+ except Exception as exc: # noqa: BLE001 (the process can vanish under us)
229
+ unavailable.setdefault("process", str(exc))
230
+ # Descriptors are ``num_fds`` on Unix and ``num_handles`` on Windows, and
231
+ # the two count different things, so the label travels with the number.
232
+ counter = getattr(_process, "num_fds", None) or getattr(_process, "num_handles", None)
233
+ if counter is None:
234
+ unavailable["open_files"] = "no descriptor count on this platform"
235
+ else:
236
+ try:
237
+ block["open_files"] = int(counter())
238
+ block["open_files_kind"] = ("handles" if sys.platform == "win32"
239
+ else "descriptors")
240
+ except Exception as exc: # noqa: BLE001
241
+ unavailable["open_files"] = str(exc)
242
+ return block
243
+
244
+ if psutil is None:
245
+ unavailable["source"] = NO_PSUTIL
246
+ rss, vms = _proc_memory(unavailable)
247
+ block["rss_bytes"] = rss
248
+ block["vms_bytes"] = vms
249
+ block["cpu_percent"] = _cpu_percent_stdlib()
250
+ # ``threading.active_count`` counts Python threads only, so it misses the
251
+ # ones uvicorn's C extensions own. Named so the page can say which it is.
252
+ block["threads"] = threading.active_count()
253
+ block["threads_kind"] = "python"
254
+ block["open_files"] = _proc_open_files(unavailable)
255
+ if block["open_files"] is not None:
256
+ block["open_files_kind"] = "descriptors"
257
+ return block
@@ -0,0 +1,8 @@
1
+ """Route modules for the api.
2
+
3
+ Each submodule defines a single :class:`fastapi.APIRouter` named
4
+ ``router`` that the app factory mounts under ``/v1``.
5
+
6
+ Flask users: an ``APIRouter`` is FastAPI's analogue of a
7
+ :class:`flask.Blueprint`.
8
+ """
@@ -0,0 +1,327 @@
1
+ """DecL-helpers routes: the family that asks the grammar about text.
2
+
3
+ None of these builds anything. They cost a parse, not a convolution, and
4
+ they create no object and no cache entry.
5
+
6
+ Routes
7
+ ------
8
+
9
+ * ``POST /v1/decl/complete``: accepts ``(decl, cursor)`` and returns
10
+ terminal-level completion candidates for the editor.
11
+ * ``POST /v1/decl/lex``: accepts ``decl`` and returns the token stream,
12
+ for client-side syntax highlighting that prefers authoritative tokens
13
+ over a TextMate or language-server clone.
14
+ * ``POST /v1/decl/parse``: accepts ``decl`` and returns the
15
+ ``(kind, name, spec)`` triple the parser understood, one entry per
16
+ statement. Added at a192 for an external client that needs the
17
+ structure of a program and must not read it back out of text.
18
+ * ``POST /v1/decl/format``: accepts ``decl`` and returns the program
19
+ re-rendered canonically, echoing the input on any failure.
20
+ * ``GET /v1/decl/grammar``: serves ``decl.lark`` verbatim as
21
+ ``text/plain``. Lets a future docs page embed the live grammar without a
22
+ build-time include step.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from importlib.resources import files
28
+
29
+ from fastapi import APIRouter, HTTPException
30
+ from fastapi.responses import PlainTextResponse
31
+
32
+ from aggregate.parser_errors import format_error
33
+
34
+ from .. import models
35
+ from ..completion import complete, lex
36
+ from ..serializers import spec_to_payload
37
+
38
+
39
+ router = APIRouter()
40
+
41
+
42
+ class _StatementParseError(Exception):
43
+ """A statement the parser refused, carrying its index in the program.
44
+
45
+ Parameters
46
+ ----------
47
+ index : int
48
+ Zero-based position of the failing statement in the preprocessed
49
+ program. A program that will not even split reports 0.
50
+ statement : str
51
+ The text actually handed to the parser, which is the preprocessed
52
+ statement, or the whole program when the split itself failed. This is
53
+ the right ``source`` for a report, because it is what the column and
54
+ caret are measured against.
55
+ cause : BaseException
56
+ What the parser raised. ``aggregate.parser_errors.format_error`` turns
57
+ it into the rich report.
58
+ """
59
+
60
+ def __init__(self, index: int, statement: str, cause: BaseException) -> None:
61
+ super().__init__(f"statement {index} did not parse")
62
+ self.index = index
63
+ self.statement = statement
64
+ self.cause = cause
65
+
66
+
67
+ def _parse_statements(decl: str) -> list[tuple[str, str, dict]]:
68
+ """Split ``decl`` into statements and parse each one, in source order.
69
+
70
+ Parameters
71
+ ----------
72
+ decl : str
73
+ A DecL program, one statement or several.
74
+
75
+ Returns
76
+ -------
77
+ list of tuple
78
+ One ``(kind, name, spec)`` triple per statement, the parser's own
79
+ return value, untouched.
80
+
81
+ Raises
82
+ ------
83
+ _StatementParseError
84
+ On the first statement that will not parse, or when the program will
85
+ not split at all.
86
+
87
+ Notes
88
+ -----
89
+ The two library names are both public and both the ones the writer itself
90
+ uses, so this predicts the writer's behavior rather than guessing at it.
91
+ ``UnderwritingLexer.preprocess`` splits on **blank** lines, not on every
92
+ newline: a single newline plus indentation is a continuation and folds into
93
+ one space, which is how a program spread over five lines stays one
94
+ statement.
95
+
96
+ Each statement is parsed against ``aggregate.build`` alone, so a name a
97
+ statement declares is not in scope for the statements after it. See
98
+ :func:`_every_statement_parses`, which is where that costs something.
99
+ """
100
+ try:
101
+ from aggregate import build
102
+ from aggregate.parser import UnderwritingLexer
103
+ statements = UnderwritingLexer.preprocess(decl)
104
+ except Exception as exc: # noqa: BLE001 (a program that will not even split)
105
+ raise _StatementParseError(0, decl, exc) from exc
106
+ parsed: list[tuple[str, str, dict]] = []
107
+ for index, statement in enumerate(statements):
108
+ try:
109
+ parsed.append(build.parser.parse(statement))
110
+ except Exception as exc: # noqa: BLE001 (parse error, or unresolved name)
111
+ raise _StatementParseError(index, statement, exc) from exc
112
+ return parsed
113
+
114
+
115
+ def _every_statement_parses(decl: str) -> bool:
116
+ """Whether every statement in ``decl`` parses, which is what Reformat needs.
117
+
118
+ Parameters
119
+ ----------
120
+ decl : str
121
+ A DecL program, one statement or several.
122
+
123
+ Returns
124
+ -------
125
+ bool
126
+ True when the writer will canonicalize the whole program, False when
127
+ at least one statement will come back as source text instead.
128
+
129
+ Notes
130
+ -----
131
+ **This exists because ``format_program``'s fallback is not the no-op it
132
+ reads as, and a137 is where that cost the author a program.** The writer
133
+ renders statement by statement through ``_render_statement``, which catches
134
+ every exception and returns the statement instead of a render node. The
135
+ statement it returns is not the source, though: it is the source after
136
+ ``UnderwritingLexer.preprocess``, which is what splits the program at all,
137
+ and preprocessing folds every newline and every run of indentation into one
138
+ space. So a program the writer cannot read comes back **collapsed onto one
139
+ line**, the SPA sees text that differs from what it sent, writes it into the
140
+ editor, and the reader's carefully spread program is gone. Pressing the
141
+ button that formats a program is how you lose its formatting.
142
+
143
+ Three ways in, all reachable from an ordinary session: a statement using a
144
+ spelling a grammar change retired, a statement naming something the default
145
+ underwriter cannot resolve, and, the one that surprises, a second statement
146
+ referring to a name the **first** statement defines. The writer parses each
147
+ statement against ``aggregate.build`` alone, so ``sev MySev ...`` followed by
148
+ ``agg A ... sev.MySev ...`` renders the first and collapses the second.
149
+
150
+ Asking the same parser the same question first is what makes the route's
151
+ standing promise ("a malformed program reformats to itself") true. It parses
152
+ the program twice on the way to a canonical answer, which is the right
153
+ trade for a button a reader presses by hand: correctness for a few
154
+ milliseconds nobody can perceive.
155
+
156
+ ``UnderwritingLexer.preprocess`` and ``Underwriter.parser`` are both public
157
+ names, and they are the two the writer itself uses, so this predicts the
158
+ fallback rather than guessing at it. The remaining gap is a statement that
159
+ parses and then fails while rendering; that is a library defect when it
160
+ happens rather than a program the reader can fix, and it is raised upstream
161
+ rather than guarded here.
162
+ """
163
+ try:
164
+ _parse_statements(decl)
165
+ except _StatementParseError:
166
+ return False
167
+ return True
168
+
169
+
170
+ def _format_decl(decl: str) -> str:
171
+ """Canonicalize a DecL program, echoing the original on any failure.
172
+
173
+ ``format_program`` lives in ``aggregate.decl_writer`` and parses and builds
174
+ the program internally, so it can raise on malformed input. That must never
175
+ 500 the format helper, so a failure falls back to the input and a malformed
176
+ program reformats to itself.
177
+
178
+ Notes
179
+ -----
180
+ **The gate in front of it, which is what a137 added.** The echo has to be
181
+ the text that came in, and until a137 it was not: the writer answers an
182
+ unreadable statement with its own preprocessed copy, which is the statement
183
+ with every line break folded into a space, and the SPA wrote that into the
184
+ editor. See :func:`_every_statement_parses` for the three ways a program
185
+ reaches that path and why the answer is to ask the parser first.
186
+
187
+ **``trailer=True``, which is the whole of what a126 changed here.** The
188
+ writer's signature is
189
+ ``(spec_or_text, *, fmt='text', layout='spread', trailer=False)`` and it
190
+ emits the trailer as a unit, so at the default every ``note{}``, ``hints{}``
191
+ and ``tags{}`` in the program is silently dropped. That made Reformat delete
192
+ the clause Sharpen had just written: press one to pin a grid, press the
193
+ other to tidy the layout, and the pinning is gone. A grid a program does not
194
+ state is a grid the next build is free to choose differently, so this was
195
+ losing meaning rather than formatting.
196
+
197
+ A reader who types their own ``tags{}`` now keeps it through a Reformat too,
198
+ on the same rule as the note (author ruling, 2026-08-24). This does not fight
199
+ the Examples library: a loaded entry arrives with note and tags already
200
+ stripped, so there is nothing on that path for Reformat to restore.
201
+
202
+ ``format_program`` still round trips through the spec, so Reformat continues
203
+ to rewrite ``ph 2/3`` as a float and ``dsev [1:6]`` as six values. That is
204
+ the writer working as designed and is tracked upstream as
205
+ ``[Unparser-Reference-Gaps]``; it is why the Examples menu serves
206
+ ``Recipe.as_read`` instead of coming through here.
207
+ """
208
+ if not _every_statement_parses(decl):
209
+ return decl
210
+ try:
211
+ from aggregate.decl_writer import format_program
212
+ out = format_program(decl, fmt="text", trailer=True)
213
+ return out or decl
214
+ except Exception: # noqa: BLE001 (formatting is best-effort)
215
+ return decl
216
+
217
+
218
+ @router.post("/decl/complete", response_model=models.CompletionsResponse)
219
+ def decl_complete(req: models.DeclCompleteRequest) -> dict:
220
+ """Return completion candidates for ``decl[:cursor]``.
221
+
222
+ FastAPI deserializes the JSON body into ``req`` automatically;
223
+ we just unpack and call into the completion module.
224
+ """
225
+ return {"completions": complete(req.decl, req.cursor)}
226
+
227
+
228
+ @router.post("/decl/lex", response_model=models.LexResponse)
229
+ def decl_lex(req: models.DeclLexRequest) -> dict:
230
+ """Return the token stream for ``req.decl``.
231
+
232
+ On a tokenization error returns an empty list. Callers should hit
233
+ ``POST /v1/objects`` to see the structured :class:`ErrorReport`.
234
+ """
235
+ return {"tokens": lex(req.decl)}
236
+
237
+
238
+ @router.post("/decl/format", response_model=models.DeclFormatResponse)
239
+ def decl_format(req: models.DeclFormatRequest) -> dict:
240
+ """Return ``req.decl`` re-rendered in canonical form.
241
+
242
+ Notes
243
+ -----
244
+ Two callers, and neither is the one this docstring used to name. It said
245
+ the SPA standardizes a program loaded from the Examples library, which
246
+ stopped being true at a122: a library entry now arrives as its own file's
247
+ text and needs no round trip, and putting it through one would undo the
248
+ spellings the entry exists to teach. What is left is the **Reformat**
249
+ button and the ``grossceded`` prefix path, which reformats on the way
250
+ through because it is rewriting the program anyway.
251
+
252
+ Best-effort: malformed input echoes back unchanged.
253
+ """
254
+ return {"decl": _format_decl(req.decl)}
255
+
256
+
257
+ @router.post("/decl/parse", response_model=models.DeclParseResponse)
258
+ def decl_parse(req: models.DeclParseRequest) -> dict:
259
+ """Return what the parser understood about ``req.decl``, as data.
260
+
261
+ One ``(kind, name, spec)`` entry per statement, in source order. Nothing is
262
+ built, nothing is cached, nothing is audited as a build: this is the
263
+ grammar answering a question about text, like its three siblings above, and
264
+ it costs a parse rather than a convolution.
265
+
266
+ Notes
267
+ -----
268
+ **The spec is the library's vocabulary**, served as an open object rather
269
+ than a mirrored schema, for the reason
270
+ :class:`~aggregate_api.models.ParsedStatement` gives. Two coercions a
271
+ client has to expect, both from
272
+ :func:`~aggregate_api.serializers.spec_to_payload`: a tuple arrives as an
273
+ array, so a reinsurance layer reads as ``[share, limit, attach]``; and a
274
+ non-finite float arrives as the string ``"inf"``, ``"-inf"`` or ``"nan"``,
275
+ which is the spelling the grammar accepts for an unlimited layer and the
276
+ only form that keeps an unlimited limit apart from an absent one. ``inf``
277
+ is not exotic: ``sev_ub`` carries it on the plainest one-line ``agg``.
278
+
279
+ **Statements are split on blank lines**, the rule
280
+ ``UnderwritingLexer.preprocess`` has always applied, so a program spread
281
+ over several indented lines is one statement and two programs separated by
282
+ an empty line are two.
283
+
284
+ **Each statement resolves against the default underwriter**, as
285
+ :func:`_every_statement_parses` and ``format_program`` both do, so a
286
+ ``sev.X`` or ``agg.X`` reference into the recipe base resolves and a
287
+ builtin reference resolves inline. The consequence worth stating: a program
288
+ whose second statement refers to a name its **first** statement declares
289
+ does not parse, because nothing here feeds the first statement's result
290
+ back into scope for the second.
291
+
292
+ **A parse failure is the structured error path**, not a 200 with an empty
293
+ list: HTTP 422 whose ``detail`` is the ``aggregate.parser_errors``
294
+ ``ErrorReport`` dict that ``POST /v1/objects`` already returns, with
295
+ ``line``, ``column``, ``source``, ``caret`` and ``suggestions``, plus one
296
+ added key, ``statement_index``, naming which statement failed. ``source``
297
+ is that statement rather than the whole program, because the column and the
298
+ caret are measured against what the parser was handed.
299
+ """
300
+ try:
301
+ parsed = _parse_statements(req.decl)
302
+ except _StatementParseError as exc:
303
+ detail = format_error(exc.statement, exc.cause).to_dict()
304
+ detail["statement_index"] = exc.index
305
+ raise HTTPException(status_code=422, detail=detail) from None
306
+ return {
307
+ "statements": [
308
+ {"kind": kind, "name": name, "spec": spec_to_payload(spec)}
309
+ for kind, name, spec in parsed
310
+ ]
311
+ }
312
+
313
+
314
+ @router.get(
315
+ "/decl/grammar",
316
+ response_class=PlainTextResponse,
317
+ responses={200: {"content": {"text/plain": {}}}},
318
+ )
319
+ def decl_grammar() -> PlainTextResponse:
320
+ """Return the bundled ``decl.lark`` as plain text.
321
+
322
+ Reads via ``importlib.resources`` so the result is the installed
323
+ package's grammar, which works whether the package is installed as a
324
+ regular site-package, a zip, or an editable install.
325
+ """
326
+ text = files("aggregate").joinpath("decl.lark").read_text(encoding="utf-8")
327
+ return PlainTextResponse(text)
@@ -0,0 +1,82 @@
1
+ """Example-library routes.
2
+
3
+ Two endpoints over ``aggregate``'s recipe base (``library.agg``):
4
+
5
+ * ``GET /v1/examples``: the whole library, one flat list in file order.
6
+ * ``GET /v1/examples/heroes``: just the ``role:hero`` entries, for the
7
+ landing gallery.
8
+
9
+ Both loaders are ``lru_cache``d in :mod:`aggregate_api.examples`, so the recipe
10
+ base is walked once per process. The routes are thin wrappers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from fastapi import APIRouter, Query
16
+
17
+ from .. import models
18
+ from ..examples import (
19
+ filter_examples, load_examples, load_hero_sparklines, load_heroes,
20
+ )
21
+
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ @router.get("/examples", response_model=models.ExamplesResponse)
27
+ def list_examples(
28
+ kind: list[str] | None = Query(
29
+ None, description="Keep only these recipe kinds (agg, port, sev, ...)."
30
+ ),
31
+ topic: list[str] | None = Query(
32
+ None, description="Keep only these topic: values, unprefixed."
33
+ ),
34
+ role: list[str] | None = Query(
35
+ None, description="Keep only these role: values, unprefixed."
36
+ ),
37
+ ) -> dict:
38
+ """Return the example library, one flat list in the library's own order.
39
+
40
+ See :class:`ExamplesResponse` for the payload shape. Every entry appears
41
+ exactly once, in the order ``library.agg`` declares it.
42
+
43
+ Notes
44
+ -----
45
+ The three filters are repeatable and compose as OR within a namespace and
46
+ AND across them, so ``?role=intro&role=intermediate&topic=severity`` is the
47
+ easy half of the severity entries.
48
+
49
+ **The SPA does not pass them.** The whole payload is 151 entries fetched
50
+ once and filtered in the browser, where the pills and the list stay in step
51
+ with no round trip. These exist for a notebook reading the api directly.
52
+
53
+ ``?group=topic|kind|role`` stood here through a121 and is gone rather than
54
+ deprecated: the api is private and pre-release, so a clean break costs
55
+ nothing and leaving a grouped view behind would have kept the ordering
56
+ tables it needed.
57
+ """
58
+ return filter_examples(load_examples(), kind=kind, topic=topic, role=role)
59
+
60
+
61
+ @router.get("/examples/heroes", response_model=models.HeroesResponse)
62
+ def list_heroes() -> dict:
63
+ """Return the entries tagged ``role:hero``, the landing-page gallery.
64
+
65
+ Nothing is built here: ``discover``'s directory path filters the recipe
66
+ frame, so this is as cheap as the full listing.
67
+ """
68
+ return load_heroes()
69
+
70
+
71
+ @router.get("/examples/heroes/sparklines", response_model=models.SparklinesResponse)
72
+ def hero_sparklines() -> dict:
73
+ """Density silhouettes for the hero cards, keyed by entry name.
74
+
75
+ **Slow on the first call**, which is the whole reason it is a separate
76
+ endpoint: it builds every hero, and one of them carries ``hints{log2=16}``.
77
+ Cached for the process afterwards.
78
+
79
+ The SPA calls this *after* first paint and leaves the cards on their
80
+ placeholder art until it resolves, so the landing page never waits on it.
81
+ """
82
+ return load_hero_sparklines()