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,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
@@ -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
+ )