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.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- 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,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()
|