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,219 @@
|
|
|
1
|
+
"""DecL completion + lex helpers for the editor pane.
|
|
2
|
+
|
|
3
|
+
Two endpoints feed off this module:
|
|
4
|
+
|
|
5
|
+
* ``POST /v1/decl/complete`` -- given DecL text and a cursor
|
|
6
|
+
position, return the set of terminals that could legally appear
|
|
7
|
+
next (keyword completions for the SPA's CodeMirror integration).
|
|
8
|
+
* ``POST /v1/decl/lex`` -- given DecL text, return the token stream
|
|
9
|
+
produced by Lark's lexer (used by syntax-highlighting UIs that
|
|
10
|
+
prefer server-side tokenization).
|
|
11
|
+
|
|
12
|
+
Both go through Lark's :meth:`Lark.parse_interactive` API, which
|
|
13
|
+
returns an :class:`~lark.parsers.lalr_interactive_parser.InteractiveParser`
|
|
14
|
+
that exposes the live parse state. ``.accepts()`` returns the set
|
|
15
|
+
of terminal names that would succeed at the current position --
|
|
16
|
+
that's the completion menu.
|
|
17
|
+
|
|
18
|
+
V1 ships keyword/terminal completions only. Knowledge-base
|
|
19
|
+
identifier completions (severity names, calibrated distortions)
|
|
20
|
+
are flagged in the plan as a v1.1 enhancement.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from lark.exceptions import UnexpectedInput
|
|
28
|
+
|
|
29
|
+
# Reuse the parser instance already constructed by ``parser.py`` so
|
|
30
|
+
# we don't duplicate the (somewhat expensive) Lark.open() call.
|
|
31
|
+
# (Currently only used by the lex endpoint -- see notes on
|
|
32
|
+
# ``complete`` for why interactive parsing isn't available.)
|
|
33
|
+
from aggregate.parser import _PARSER
|
|
34
|
+
|
|
35
|
+
# Reuse the curated terminal -> label table from Plan B so suggestion
|
|
36
|
+
# labels are consistent with parse-error displays.
|
|
37
|
+
from aggregate.parser_errors import _TERMINAL_LABELS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ----------------------------------------------------------------------
|
|
41
|
+
# Completion classification
|
|
42
|
+
# ----------------------------------------------------------------------
|
|
43
|
+
# Terminals that represent generic value categories rather than fixed
|
|
44
|
+
# strings. Used to tag a Completion's ``kind`` so the editor can render
|
|
45
|
+
# (e.g.) keyword pills differently from "expects a number" hints.
|
|
46
|
+
_LITERAL_TERMINALS = {
|
|
47
|
+
"NUMBER", "SIGNED_NUMBER", "INT", "SIGNED_INT", "FLOAT",
|
|
48
|
+
"STRING", "ESCAPED_STRING",
|
|
49
|
+
}
|
|
50
|
+
_IDENTIFIER_TERMINALS = {"NAME", "CNAME", "ID"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _classify(terminal: str) -> str:
|
|
54
|
+
"""Map a terminal name to ``'keyword' | 'identifier' | 'literal'``.
|
|
55
|
+
|
|
56
|
+
Anything not in the explicit identifier/literal sets is treated
|
|
57
|
+
as a keyword (it's either a curated DecL keyword from
|
|
58
|
+
``decl.lark`` or an anonymous terminal -- the editor renders
|
|
59
|
+
both as "keyword" tokens).
|
|
60
|
+
"""
|
|
61
|
+
if terminal in _LITERAL_TERMINALS:
|
|
62
|
+
return "literal"
|
|
63
|
+
if terminal in _IDENTIFIER_TERMINALS:
|
|
64
|
+
return "identifier"
|
|
65
|
+
return "keyword"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: A curated label that **starts with a quoted token**, and whatever follows it.
|
|
69
|
+
#: The three shapes in the table, in the order they matter here:
|
|
70
|
+
#:
|
|
71
|
+
#: * ``"'agg'"``, a bare token;
|
|
72
|
+
#: * ``"'approximate' (or 'approx')"``, a token and a parenthesized gloss;
|
|
73
|
+
#: * ``"'**' or '^'"``, a token and an unparenthesized alternative.
|
|
74
|
+
#:
|
|
75
|
+
#: A fourth shape has no quoted token at all and is a **description** rather
|
|
76
|
+
#: than a literal: ``"a builtin aggregate (agg.X)"``, ``"a frequency name
|
|
77
|
+
#: (poisson, binomial, ...)"``. Those name a category the static pool cannot
|
|
78
|
+
#: enumerate, so there is nothing to insert and :func:`complete` drops them.
|
|
79
|
+
_LABEL_TOKEN = re.compile(r"^'(?P<token>[^']*)'\s*(?P<rest>.*)$")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _split_label(terminal: str) -> tuple[str, str | None]:
|
|
83
|
+
"""Split a curated label into the token to insert and its gloss.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
terminal : str
|
|
88
|
+
Lark terminal name.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
(str, str or None)
|
|
93
|
+
The bare token, and the gloss with any wrapping parentheses removed.
|
|
94
|
+
The token is ``''`` when there is nothing insertable, which is an
|
|
95
|
+
anonymous terminal (``__ANON_*``) or a descriptive label; callers drop
|
|
96
|
+
those rather than offering a phrase as if it were DecL.
|
|
97
|
+
|
|
98
|
+
Notes
|
|
99
|
+
-----
|
|
100
|
+
This used to be ``_TERMINAL_LABELS[terminal].strip("'")``, and ``strip``
|
|
101
|
+
cannot do the job: it removes matching characters from the **ends** of a
|
|
102
|
+
string, so a label ending in ``)`` kept its interior quote and
|
|
103
|
+
``'after' (profit-commission allowance)`` came back as
|
|
104
|
+
``after' (profit-commission allowance)``. That string was then handed to
|
|
105
|
+
editors as the text to insert, so accepting the completion put a stray
|
|
106
|
+
apostrophe and a parenthetical into the program. It reads as a formatting
|
|
107
|
+
slip and it was a correctness one.
|
|
108
|
+
|
|
109
|
+
Where a label offers alternatives (``'**' or '^'``) the **first** is the
|
|
110
|
+
token and the rest becomes the gloss: both spellings parse, so either is a
|
|
111
|
+
defensible insertion, and the one the label leads with is the house form.
|
|
112
|
+
"""
|
|
113
|
+
if terminal.startswith("__"):
|
|
114
|
+
return "", None
|
|
115
|
+
raw = _TERMINAL_LABELS.get(terminal)
|
|
116
|
+
if raw is None:
|
|
117
|
+
return terminal.lower(), None
|
|
118
|
+
m = _LABEL_TOKEN.match(raw.strip())
|
|
119
|
+
if m is None:
|
|
120
|
+
return "", None # a description, not a token
|
|
121
|
+
rest = m.group("rest").strip()
|
|
122
|
+
if rest.startswith("(") and rest.endswith(")"):
|
|
123
|
+
rest = rest[1:-1].strip()
|
|
124
|
+
return m.group("token"), rest or None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Lark's ``parse_interactive`` is LALR-only; DecL uses Earley + dynamic
|
|
128
|
+
# lexer, so we can't ask the live parser "what accepts next?". V1 ships
|
|
129
|
+
# a static keyword set sourced from Plan B's terminal-label table --
|
|
130
|
+
# good enough for the SPA's "show me available keywords" dropdown but
|
|
131
|
+
# not context-aware. Grammar-driven completions are flagged in the plan
|
|
132
|
+
# as a v1.1 enhancement (would require building a parallel LALR
|
|
133
|
+
# grammar or stepping through token-by-token).
|
|
134
|
+
_STATIC_KEYWORDS = sorted(
|
|
135
|
+
{
|
|
136
|
+
t for t in _TERMINAL_LABELS
|
|
137
|
+
if t.isupper() and t.isascii() and not t.startswith("__")
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def complete(decl: str, cursor: int) -> list[dict]:
|
|
143
|
+
"""Return completion candidates for the given cursor position.
|
|
144
|
+
|
|
145
|
+
V1 implementation: filter the static keyword pool against the
|
|
146
|
+
identifier-shaped word ending at ``cursor`` (case-insensitive
|
|
147
|
+
prefix match). When the cursor sits on whitespace / start of
|
|
148
|
+
input, the full keyword pool is returned.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
decl : str
|
|
153
|
+
Current editor content.
|
|
154
|
+
cursor : int
|
|
155
|
+
Zero-indexed character position.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
list[dict]
|
|
160
|
+
Sorted list of ``{text, label, detail, terminal, kind}`` dicts. Empty
|
|
161
|
+
only when no keyword starts with the current prefix.
|
|
162
|
+
|
|
163
|
+
Notes
|
|
164
|
+
-----
|
|
165
|
+
``text`` is the bare token and is what an editor inserts; ``label`` is the
|
|
166
|
+
same token (they differ only in that ``label`` is what a menu shows) and
|
|
167
|
+
``detail`` carries the gloss where the terminal has one. Matching is on the
|
|
168
|
+
**token**, never on the gloss, so typing ``pr`` offers ``premium`` and does
|
|
169
|
+
not also offer everything whose parenthetical happens to contain a ``pr``.
|
|
170
|
+
"""
|
|
171
|
+
prefix = decl[:cursor]
|
|
172
|
+
# Identify the word the cursor is currently inside (or at the
|
|
173
|
+
# end of). Walk backwards while we still see identifier chars.
|
|
174
|
+
i = len(prefix)
|
|
175
|
+
while i > 0 and (prefix[i - 1].isalnum() or prefix[i - 1] in "._-:~"):
|
|
176
|
+
i -= 1
|
|
177
|
+
word = prefix[i:].lower()
|
|
178
|
+
|
|
179
|
+
out: list[dict] = []
|
|
180
|
+
for term in _STATIC_KEYWORDS:
|
|
181
|
+
text, detail = _split_label(term)
|
|
182
|
+
if not text:
|
|
183
|
+
continue
|
|
184
|
+
if word and not text.lower().startswith(word):
|
|
185
|
+
continue
|
|
186
|
+
out.append({"text": text, "label": text, "detail": detail,
|
|
187
|
+
"terminal": term, "kind": _classify(term)})
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def lex(decl: str) -> list[dict]:
|
|
192
|
+
"""Tokenize ``decl`` via Lark's lexer and return token records.
|
|
193
|
+
|
|
194
|
+
Returns one dict per token::
|
|
195
|
+
|
|
196
|
+
{"type": "MIXED", "value": "mixed", "start": 7, "end": 12,
|
|
197
|
+
"line": 1, "column": 8}
|
|
198
|
+
|
|
199
|
+
A failure to tokenize (e.g. an unexpected character partway
|
|
200
|
+
through) surfaces an empty list -- callers should hit
|
|
201
|
+
``/v1/objects`` for a proper :class:`ErrorReport`.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
tokens = list(_PARSER.lex(decl))
|
|
205
|
+
except UnexpectedInput:
|
|
206
|
+
return []
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
209
|
+
out: list[dict] = []
|
|
210
|
+
for tok in tokens:
|
|
211
|
+
out.append({
|
|
212
|
+
"type": tok.type,
|
|
213
|
+
"value": str(tok),
|
|
214
|
+
"start": tok.start_pos or 0,
|
|
215
|
+
"end": tok.end_pos or 0,
|
|
216
|
+
"line": tok.line or 1,
|
|
217
|
+
"column": tok.column or 1,
|
|
218
|
+
})
|
|
219
|
+
return out
|
aggregate_api/config.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Settings for the api, driven by environment variables.
|
|
2
|
+
|
|
3
|
+
The :class:`Settings` class is built on ``pydantic-settings`` --
|
|
4
|
+
a small wrapper around Pydantic that reads env vars (prefixed
|
|
5
|
+
``AGGAPI_``) and validates them through the usual Pydantic
|
|
6
|
+
machinery. The end result is a typed, validated config object
|
|
7
|
+
loaded once at process start.
|
|
8
|
+
|
|
9
|
+
Flask users: this replaces ``app.config[...]``. The pattern of
|
|
10
|
+
"build a settings object once, inject it everywhere" is a FastAPI
|
|
11
|
+
idiom -- routes pull the live settings via the
|
|
12
|
+
:func:`get_settings` dependency (used with ``Depends``), not via
|
|
13
|
+
a global.
|
|
14
|
+
|
|
15
|
+
To override a value at runtime, set the env var before launching
|
|
16
|
+
``aggregate-api`` (e.g. ``AGGAPI_LOG2_CAP=20 aggregate-api``) or
|
|
17
|
+
pass it through ``monkeypatch.setenv`` in tests.
|
|
18
|
+
|
|
19
|
+
The defaults are the team-deploy preset: localhost-only bind,
|
|
20
|
+
modest log2 cap, 10 s build timeout, 50 objects in the cache.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import warnings
|
|
27
|
+
from functools import lru_cache
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from pydantic import AliasChoices, Field
|
|
31
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
32
|
+
|
|
33
|
+
from .net import DEFAULT_PRIVATE_CIDRS, parse_cidrs
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Settings(BaseSettings):
|
|
37
|
+
"""Process-wide configuration.
|
|
38
|
+
|
|
39
|
+
All knobs come from env vars beginning with ``AGGAPI_``.
|
|
40
|
+
Empty strings on list-typed fields mean "unset" rather than
|
|
41
|
+
"single empty element" -- see :meth:`_parse_cors_origins`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# ``model_config`` is the Pydantic v2 idiom for class-level
|
|
45
|
+
# config (it replaces v1's inner ``class Config``). ``env_prefix``
|
|
46
|
+
# tells pydantic-settings to look for AGGAPI_HOST -> ``host``,
|
|
47
|
+
# AGGAPI_PORT -> ``port``, etc. ``extra='ignore'`` lets us run
|
|
48
|
+
# in an environment with unrelated env vars without complaint.
|
|
49
|
+
model_config = SettingsConfigDict(
|
|
50
|
+
env_prefix="AGGAPI_",
|
|
51
|
+
case_sensitive=False,
|
|
52
|
+
extra="ignore",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Network / server
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
host: str = "127.0.0.1"
|
|
59
|
+
port: int = 8000
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Build pipeline
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Default log2 if the client omits it.
|
|
65
|
+
log2_default: int = 16
|
|
66
|
+
# Hard cap: requests above this fail with HTTP 422 (limit_exceeded).
|
|
67
|
+
# The cap exists because 2**N grid points * sizeof(float) is the
|
|
68
|
+
# FFT memory cost; 2**20 is ~8 MB per density array and is well
|
|
69
|
+
# past anything legitimate users need on a team-deploy box.
|
|
70
|
+
log2_cap: int = 18
|
|
71
|
+
# Per-build wall-clock timeout; expires via threadpool.future.
|
|
72
|
+
build_timeout_s: float = 10.0
|
|
73
|
+
|
|
74
|
+
# How long a request waits for a cached object's lock before giving up with
|
|
75
|
+
# HTTP 503.
|
|
76
|
+
#
|
|
77
|
+
# A timeout rather than an unbounded wait, because an unbounded one could
|
|
78
|
+
# take the whole process down. `_locked_entry` holds a CacheEntry's lock for
|
|
79
|
+
# the length of a request, and FastAPI runs a sync dependency and a sync
|
|
80
|
+
# handler in two separate threadpool calls, each needing one of anyio's 40
|
|
81
|
+
# tokens. Forty requests blocked on one lock therefore hold every token, and
|
|
82
|
+
# the request that holds the lock can never get a token to reach its handler
|
|
83
|
+
# and release it. Measured at a200: 45 concurrent reads of one object left
|
|
84
|
+
# the process at 0% CPU with every sync route, `/v1/health` included,
|
|
85
|
+
# permanently unreachable. 39 survived. See dev/plan-demo-load.md item 0.
|
|
86
|
+
#
|
|
87
|
+
# Ten seconds matches the build timeout, so the two ways a request can be
|
|
88
|
+
# told "not now" expire on the same clock.
|
|
89
|
+
entry_lock_timeout_s: float = 10.0
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Cache
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
cache_max: int = 50
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Sessions
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# How many forked recipe bases to hold, and how long an idle one lives.
|
|
100
|
+
# A fork is microseconds and a few hundred kilobytes, so the count is
|
|
101
|
+
# deliberately generous and the TTL is what bounds the memory. Eight hours
|
|
102
|
+
# outlives any sitting a browser tab survives; ttl 0 disables expiry.
|
|
103
|
+
session_max: int = 500
|
|
104
|
+
session_ttl_s: float = 28800.0
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Audit log
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Default is per-user data dir; resolved lazily in audit.py so the
|
|
110
|
+
# directory is only created when an AuditLog is actually opened.
|
|
111
|
+
audit_db: str = str(Path.home() / ".aggregate" / "api" / "audit.db")
|
|
112
|
+
|
|
113
|
+
# No plotting settings any more. `plot_default_format` chose svg or png for
|
|
114
|
+
# the server-rendered figure route, which left with matplotlib at a60: every
|
|
115
|
+
# chart is a document now and the browser decides how to draw and export it,
|
|
116
|
+
# so there is no server-side image format to have an opinion about.
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Charts
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Ceiling on the chart route's ``detail`` parameter, the target cells
|
|
122
|
+
# per axis of a reduced surface grid.
|
|
123
|
+
#
|
|
124
|
+
# A setting rather than a constant because one route serves two cases
|
|
125
|
+
# that want opposite things. Over the wire the answer is a windowed
|
|
126
|
+
# grid of a few thousand cells and a payload in kilobytes; locally it
|
|
127
|
+
# is a drill-down into fine detail on a machine where bandwidth is not
|
|
128
|
+
# a constraint, and a hard cap there would prevent a use in order to
|
|
129
|
+
# prevent nothing. What stops a client misrepresenting what it got is
|
|
130
|
+
# the document (it reports the realized ``k``, ``bs``, ``nx``, ``ny``),
|
|
131
|
+
# not this number.
|
|
132
|
+
#
|
|
133
|
+
# The public deploy sets AGGAPI_MAX_CHART_DETAIL=256, because chart
|
|
134
|
+
# GETs sit outside the Caddy rate limiter and each parameter
|
|
135
|
+
# combination is a fresh reduction. VPN and local runs keep this
|
|
136
|
+
# default. See dev/plan-3d-plot.md sections 3.1 and 7 answer 2.
|
|
137
|
+
max_chart_detail: int = 1024
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# CORS
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Comma-separated list in the env var. Empty -> middleware skipped
|
|
143
|
+
# (same-origin deploys don't pay the per-request CORS handling).
|
|
144
|
+
# ``validation_alias`` overrides the auto-derived AGGAPI_CORS_ORIGINS_RAW
|
|
145
|
+
# name so the env var stays AGGAPI_CORS_ORIGINS (matches docs).
|
|
146
|
+
cors_origins_raw: str = Field(
|
|
147
|
+
default="",
|
|
148
|
+
validation_alias="AGGAPI_CORS_ORIGINS",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Static files (Plan D web build)
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# When set, overrides the importlib-resources discovery in
|
|
155
|
+
# app.py. Lets a developer point the running api at a Vite dev
|
|
156
|
+
# build sitting in a sibling tree without reinstalling.
|
|
157
|
+
static_dir: str = ""
|
|
158
|
+
|
|
159
|
+
# Whether to serve the SPA at all. False leaves the api headless: the /v1
|
|
160
|
+
# routers, /docs and /openapi.json, and nothing at /.
|
|
161
|
+
#
|
|
162
|
+
# A setting rather than a fact about the filesystem. Headless already worked
|
|
163
|
+
# by accident, because the mount in app.py is conditional on the bundle
|
|
164
|
+
# directory existing and `static/` is gitignored and built at deploy, so an
|
|
165
|
+
# install that never ran the web build is api-only. But "I did not build the
|
|
166
|
+
# SPA" is not a way to *say* headless: it cannot be set on a machine that has
|
|
167
|
+
# the bundle, it is invisible in the config, and the only explicit off was
|
|
168
|
+
# pointing static_dir at a path that does not exist. This states the
|
|
169
|
+
# intention instead.
|
|
170
|
+
#
|
|
171
|
+
# /docs is unaffected. FastAPI registers the doc routes in its constructor,
|
|
172
|
+
# before create_app mounts anything, and Starlette matches in registration
|
|
173
|
+
# order, so this only removes the catch-all at the end.
|
|
174
|
+
serve_spa: bool = True
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# The library
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# When set, the whole process reads its recipes from this .agg file instead
|
|
180
|
+
# of aggregate's bundled library.agg: the Examples dropdown, every build,
|
|
181
|
+
# and the .agg download all resolve against it (see library.py). Lets a
|
|
182
|
+
# deploy ship a curated set without rebuilding the SPA, since the menu is
|
|
183
|
+
# fetched at runtime from GET /v1/examples. Re-read on server restart.
|
|
184
|
+
#
|
|
185
|
+
# Named AGGAPI_EXAMPLES_FILE until a109, when it stopped being about
|
|
186
|
+
# examples: it feeds builds now, so it is the library. The old name is
|
|
187
|
+
# accepted for one release and warns, hence the two-name alias rather than a
|
|
188
|
+
# plain field. ``validation_alias`` bypasses ``env_prefix``, so both names
|
|
189
|
+
# are spelled in full.
|
|
190
|
+
library: str = Field(
|
|
191
|
+
default="",
|
|
192
|
+
validation_alias=AliasChoices("AGGAPI_LIBRARY", "AGGAPI_EXAMPLES_FILE"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# The status page
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Which client addresses may reach /v1/status and /v1/status/page. Loopback,
|
|
199
|
+
# IPv6 loopback, and the VPN subnet from human-hints.md.
|
|
200
|
+
#
|
|
201
|
+
# A setting rather than a constant because the VPN subnet is a deployment
|
|
202
|
+
# fact, and the list is the second of three layers rather than the only one:
|
|
203
|
+
# the public Caddy block 404s the /v1/status prefix before the app is
|
|
204
|
+
# reached at all. See net.py for why this cannot be written as "allow if the
|
|
205
|
+
# peer is loopback" (both front doors proxy to 127.0.0.1, so that rule would
|
|
206
|
+
# publish the page) and for the single-hop assumption the gate rests on.
|
|
207
|
+
#
|
|
208
|
+
# ``validation_alias`` so the env var stays AGGAPI_PRIVATE_CIDRS rather than
|
|
209
|
+
# the auto-derived AGGAPI_PRIVATE_CIDRS_RAW, matching the CORS field above.
|
|
210
|
+
private_cidrs_raw: str = Field(
|
|
211
|
+
default=DEFAULT_PRIVATE_CIDRS,
|
|
212
|
+
validation_alias="AGGAPI_PRIVATE_CIDRS",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Layer three: demand X-Aggapi-Zone: private, which the VPN Caddy block sets
|
|
216
|
+
# and the public block strips. Off by default (author ruling, 2026-08-18):
|
|
217
|
+
# layer two is sufficient, and this one couples the app to a Caddyfile edit
|
|
218
|
+
# in a way that fails closed but confusingly, the page simply stopping. It
|
|
219
|
+
# ships built and documented because it is the only layer that survives a
|
|
220
|
+
# mistake in the CIDR list, so turning it on is a setting rather than a
|
|
221
|
+
# change.
|
|
222
|
+
status_require_zone_header: bool = False
|
|
223
|
+
|
|
224
|
+
# Seconds between the page's automatic refreshes. The page carries a pause
|
|
225
|
+
# control, so this is the starting cadence rather than a policy.
|
|
226
|
+
status_refresh_s: float = 10.0
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Plugins
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Whether this process runs third-party `aggregate.plugins` registrations.
|
|
232
|
+
#
|
|
233
|
+
# The library deliberately does **not** auto-load on `import aggregate`, so
|
|
234
|
+
# that `build()` stays reproducible and a notebook's results are a function
|
|
235
|
+
# of the notebook's own text. The decision belongs to the host, and a server
|
|
236
|
+
# is a deployment, so it is a setting here rather than a fact about what
|
|
237
|
+
# happens to be installed.
|
|
238
|
+
#
|
|
239
|
+
# On by default: the case that exists is a local deployment running the
|
|
240
|
+
# author's own plugin packages. A plugin is Python in this process with full
|
|
241
|
+
# privileges and there is no sandbox, which is acceptable while the bind is
|
|
242
|
+
# 127.0.0.1 and the packages are the author's own, and is exactly why the
|
|
243
|
+
# allowlist below exists for the case where neither holds.
|
|
244
|
+
plugins_enabled: bool = True
|
|
245
|
+
|
|
246
|
+
# Which plugins to admit, by name. Empty means all of them, which is what a
|
|
247
|
+
# local deployment wants. A comma-separated list admits only those named, so
|
|
248
|
+
# a hosted deployment can state what it trusts rather than inheriting
|
|
249
|
+
# whatever is on the image.
|
|
250
|
+
#
|
|
251
|
+
# ``validation_alias`` so the env var stays AGGAPI_PLUGINS_ALLOW rather than
|
|
252
|
+
# the auto-derived AGGAPI_PLUGINS_ALLOW_RAW, matching the CORS and CIDR
|
|
253
|
+
# fields above.
|
|
254
|
+
plugins_allow_raw: str = Field(
|
|
255
|
+
default="",
|
|
256
|
+
validation_alias="AGGAPI_PLUGINS_ALLOW",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Derived properties
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
@property
|
|
263
|
+
def plugins_allow(self) -> list[str] | None:
|
|
264
|
+
"""Parse ``AGGAPI_PLUGINS_ALLOW`` into an allowlist, or None for all.
|
|
265
|
+
|
|
266
|
+
Returns
|
|
267
|
+
-------
|
|
268
|
+
list of str or None
|
|
269
|
+
None where the setting is empty, which is what
|
|
270
|
+
:func:`aggregate.plugins.load` takes to mean "admit everything
|
|
271
|
+
discovered". An empty *list* would mean the opposite, admit nothing,
|
|
272
|
+
so the distinction is load bearing and is not a tidied-away falsy
|
|
273
|
+
check.
|
|
274
|
+
"""
|
|
275
|
+
raw = self.plugins_allow_raw.strip()
|
|
276
|
+
if not raw:
|
|
277
|
+
return None
|
|
278
|
+
return [name.strip() for name in raw.split(",") if name.strip()]
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def cors_origins(self) -> list[str]:
|
|
282
|
+
"""Parse ``AGGAPI_CORS_ORIGINS`` into a list of origins."""
|
|
283
|
+
raw = self.cors_origins_raw.strip()
|
|
284
|
+
if not raw:
|
|
285
|
+
return []
|
|
286
|
+
return [o.strip() for o in raw.split(",") if o.strip()]
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def private_cidrs(self) -> tuple:
|
|
290
|
+
"""Parse ``AGGAPI_PRIVATE_CIDRS`` into networks.
|
|
291
|
+
|
|
292
|
+
Raises
|
|
293
|
+
------
|
|
294
|
+
ValueError
|
|
295
|
+
On a malformed entry, from :func:`aggregate_api.net.parse_cidrs`.
|
|
296
|
+
Loud on purpose: a typo here is a gate that allows the wrong set,
|
|
297
|
+
and a route that refuses to answer says so more clearly than a page
|
|
298
|
+
that has quietly stopped working.
|
|
299
|
+
|
|
300
|
+
Notes
|
|
301
|
+
-----
|
|
302
|
+
Parsed per read rather than cached on the instance, because ``Settings``
|
|
303
|
+
is itself the cached singleton and the list is a handful of networks. A
|
|
304
|
+
cached property here would only add a second place for a stale value to
|
|
305
|
+
live.
|
|
306
|
+
"""
|
|
307
|
+
return parse_cidrs(self.private_cidrs_raw)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@lru_cache(maxsize=1)
|
|
311
|
+
def get_settings() -> Settings:
|
|
312
|
+
"""Return the cached process-wide settings.
|
|
313
|
+
|
|
314
|
+
``lru_cache`` makes this an effective singleton without the
|
|
315
|
+
pitfalls of a module-level mutable -- and FastAPI's
|
|
316
|
+
``Depends(get_settings)`` knows how to use it directly.
|
|
317
|
+
|
|
318
|
+
Tests that flip env vars via ``monkeypatch.setenv`` must call
|
|
319
|
+
:func:`get_settings.cache_clear` (handled centrally by the
|
|
320
|
+
``client`` fixture in ``tests/api/conftest.py``) so the next
|
|
321
|
+
read re-evaluates the environment.
|
|
322
|
+
|
|
323
|
+
Notes
|
|
324
|
+
-----
|
|
325
|
+
The deprecation notice for ``AGGAPI_EXAMPLES_FILE`` lives here rather than
|
|
326
|
+
on the field, because a field validator sees only the value that won and
|
|
327
|
+
cannot tell which of the two names supplied it. Warning once per settings
|
|
328
|
+
read is right: the cache makes that once per process in production, and once
|
|
329
|
+
per case in a test that clears it.
|
|
330
|
+
"""
|
|
331
|
+
if "AGGAPI_EXAMPLES_FILE" in os.environ and "AGGAPI_LIBRARY" not in os.environ:
|
|
332
|
+
warnings.warn(
|
|
333
|
+
"AGGAPI_EXAMPLES_FILE is deprecated and will be removed after one "
|
|
334
|
+
"release; use AGGAPI_LIBRARY. The setting now feeds every build, "
|
|
335
|
+
"not just the Examples menu.",
|
|
336
|
+
DeprecationWarning,
|
|
337
|
+
stacklevel=2,
|
|
338
|
+
)
|
|
339
|
+
return Settings()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# Module-level alias for code that doesn't need DI -- e.g. ``__main__.py``
|
|
343
|
+
# launching uvicorn off a single ``settings.host``/``port`` read.
|
|
344
|
+
# Reaches through the cache so tests with cleared cache still see
|
|
345
|
+
# the right object.
|
|
346
|
+
def _settings_attr(name: str):
|
|
347
|
+
"""Pass-through accessor that always reads from the cached object."""
|
|
348
|
+
return getattr(get_settings(), name)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class _SettingsProxy:
|
|
352
|
+
"""Lazy attribute proxy so ``settings.host`` always hits live config.
|
|
353
|
+
|
|
354
|
+
Avoids the trap of ``settings = Settings()`` at import time, which
|
|
355
|
+
would lock in env-var values from before tests had a chance to
|
|
356
|
+
monkeypatch them.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
def __getattr__(self, name):
|
|
360
|
+
return _settings_attr(name)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
settings = _SettingsProxy()
|
aggregate_api/cors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""CORS middleware setup.
|
|
2
|
+
|
|
3
|
+
The web SPA (Plan D) can be served from the same origin as the
|
|
4
|
+
api (via the conditional ``StaticFiles`` mount in ``app.py``) or
|
|
5
|
+
from a different origin -- e.g. ``mynl.com/aggregate/`` calling
|
|
6
|
+
``api.mynl.com``. The second case needs the browser-side CORS
|
|
7
|
+
preflight handshake to succeed; this helper attaches the
|
|
8
|
+
:class:`fastapi.middleware.cors.CORSMiddleware` when the operator
|
|
9
|
+
has declared which origins are trusted.
|
|
10
|
+
|
|
11
|
+
Same-origin deploys don't pay the per-request CORS handling
|
|
12
|
+
overhead: empty ``allowed_origins`` → middleware skipped entirely.
|
|
13
|
+
|
|
14
|
+
Flask users: FastAPI middleware is closer to Starlette/ASGI
|
|
15
|
+
middleware than to ``@app.before_request``. The order of
|
|
16
|
+
``add_middleware`` calls matters -- the *last* one added is the
|
|
17
|
+
*outermost* in the request lifecycle.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def install_cors(app: FastAPI, allowed_origins: list[str]) -> None:
|
|
27
|
+
"""Attach ``CORSMiddleware`` if ``allowed_origins`` is non-empty.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
app : FastAPI
|
|
32
|
+
The application being configured.
|
|
33
|
+
allowed_origins : list[str]
|
|
34
|
+
Exact-match origins (``["http://localhost:5173",
|
|
35
|
+
"https://mynl.com"]``). No regex / wildcard support in v1
|
|
36
|
+
-- the team-deploy use case is two or three known origins.
|
|
37
|
+
|
|
38
|
+
Notes
|
|
39
|
+
-----
|
|
40
|
+
* ``allow_credentials=False`` because v1 has no cookies / auth.
|
|
41
|
+
With credentials enabled the browser also bars ``*`` in
|
|
42
|
+
``allow_origins``; explicit exact-match avoids that footgun.
|
|
43
|
+
* ``allow_methods`` lists only what the api uses; OPTIONS is
|
|
44
|
+
handled automatically by CORSMiddleware for preflights.
|
|
45
|
+
* ``allow_headers`` is conservative -- if a future endpoint
|
|
46
|
+
needs ``Authorization`` etc., extend this list.
|
|
47
|
+
* ``X-Aggregate-Session`` is on that list because the SPA sends it on every
|
|
48
|
+
request, and a browser will not send a header the preflight did not allow.
|
|
49
|
+
Same-origin deploys skip this middleware entirely, so leaving it off would
|
|
50
|
+
have broken only the split-origin deploy, which is precisely the one that
|
|
51
|
+
most needs sessions to work.
|
|
52
|
+
"""
|
|
53
|
+
if not allowed_origins:
|
|
54
|
+
return
|
|
55
|
+
app.add_middleware(
|
|
56
|
+
CORSMiddleware,
|
|
57
|
+
allow_origins=allowed_origins,
|
|
58
|
+
allow_credentials=False,
|
|
59
|
+
allow_methods=["GET", "POST", "DELETE"],
|
|
60
|
+
allow_headers=["Content-Type", "X-Aggregate-Session"],
|
|
61
|
+
)
|