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,282 @@
1
+ """Meta routes: health check, config introspection, and the plot style.
2
+
3
+ Routes
4
+ ------
5
+
6
+ * ``GET /v1/health``: liveness probe (``{ok: true, version,
7
+ aggregate_version}``). Used by Caddy / k8s health checks and by
8
+ the SPA's "server up?" splash logic.
9
+ * ``GET /v1/meta``: runtime config (log2_cap, build_timeout, etc.)
10
+ so the SPA can configure its form widgets (e.g. set the log2
11
+ slider's max to ``log2_cap``), plus the loaded plugin manifest,
12
+ which is what tells the SPA whether the Lab tab exists and what
13
+ sits under it.
14
+ * ``GET /v1/meta/style``: the house plot style, read off
15
+ ``aggregate.style``, so the SPA's interactive charts and the
16
+ server-rendered matplotlib plots cannot drift apart.
17
+ * ``GET /v1/assets/{name}``: the ``greater_tables`` table-document
18
+ walker and its stylesheet, served out of the installed package so
19
+ the renderer cannot skew from the documents this process emits.
20
+
21
+ These are cheap reads; they don't touch the cache or audit log.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from functools import lru_cache
27
+ from importlib.metadata import version as _pkg_version
28
+ from importlib.resources import files
29
+
30
+ from aggregate import plugins as agg_plugins
31
+ from fastapi import APIRouter, HTTPException, Request
32
+ from fastapi.responses import Response
33
+
34
+ from .. import models
35
+ from ..config import get_settings
36
+
37
+
38
+ # APIRouter is FastAPI's analogue of a Flask Blueprint -- a group
39
+ # of routes that the app factory mounts at a prefix.
40
+ router = APIRouter()
41
+
42
+
43
+ @router.get("/health", response_model=models.HealthResponse)
44
+ async def health() -> dict:
45
+ """Trivial liveness check.
46
+
47
+ Returns both the api package version (``version``) and the
48
+ underlying ``aggregate`` library version (``aggregate_version``)
49
+ so a curl-from-prod debugging session can confirm which build of
50
+ each it's talking to without an OpenAPI fetch.
51
+
52
+ ``async def``, and with no dependencies, **so that it answers when the
53
+ thread pool is exhausted**. FastAPI runs a sync route in an anyio worker
54
+ thread, which needs one of 40 tokens; a sync route therefore cannot be
55
+ served at all once 40 requests are stuck, which is exactly the moment
56
+ somebody asks whether the server is alive. This route does no blocking work,
57
+ so running it on the event loop costs nothing and removes it from that
58
+ failure entirely. A sync *dependency* would reintroduce the problem, which is
59
+ why there is not one here. See ``_locked_entry`` in ``routes/objects.py`` for
60
+ the failure this protects against.
61
+ """
62
+ return {
63
+ "ok": True,
64
+ "version": _pkg_version("aggregate_api"),
65
+ "aggregate_version": _pkg_version("aggregate"),
66
+ }
67
+
68
+
69
+ # Fallbacks for a style key ``aggregate.style`` does not set. These are the
70
+ # values the shipped style uses today, so a miss degrades to the same look
71
+ # rather than to browser defaults.
72
+ _STYLE_FALLBACK = {
73
+ "colors": ["#0d6efd", "#dc3545", "#198754", "#eaab00",
74
+ "#6f42c1", "#0aa2c0", "#d63384", "#6c757d"],
75
+ "grid_color": "#dee2e6",
76
+ "text_color": "#212529",
77
+ "line_width": 1.4,
78
+ "font_size": 8.5,
79
+ # The house panel size in inches. Only the *ratio* travels to the SPA (a
80
+ # browser panel is sized in CSS pixels), but both are served so the client
81
+ # never has to hardcode a divisor and a future absolute use has the numbers.
82
+ "fig_w": 3.5,
83
+ "fig_h": 2.45,
84
+ }
85
+
86
+
87
+ @lru_cache(maxsize=1)
88
+ def _plot_style() -> dict:
89
+ """Read the house plot style out of ``aggregate.style``.
90
+
91
+ Returns
92
+ -------
93
+ dict
94
+ Matches :class:`aggregate_api.models.StyleResponse`.
95
+
96
+ Notes
97
+ -----
98
+ This exists so the SPA's interactive charts and the server-rendered
99
+ matplotlib plots are the same colors **by construction** rather than by two
100
+ copies of the same hex list drifting apart. The color cycle is the one
101
+ thing that would be noticed immediately if it did.
102
+
103
+ ``rc_params()`` returns a matplotlib rc mapping, where the cycle is an
104
+ ``axes.prop_cycle`` ``Cycler`` rather than a plain list, so it is walked
105
+ once here and cached. Every key is read defensively: a style that stops
106
+ setting one falls back rather than 500ing a route the front page calls on
107
+ load.
108
+
109
+ ``fig_w`` / ``fig_h`` come from ``aggregate.constants`` rather than from the
110
+ rc mapping, because ``figure.figsize`` is not what the library's own plots
111
+ use: they pass explicit multiples of ``FIG_W`` / ``FIG_H`` per panel
112
+ (``figsize=(2 * FIG_W, FIG_H)`` and so on). The SPA wants the **per-panel**
113
+ aspect, which is exactly that pair.
114
+ """
115
+ style = dict(_STYLE_FALLBACK)
116
+ try:
117
+ from aggregate.constants import FIG_H, FIG_W
118
+
119
+ style["fig_w"] = float(FIG_W)
120
+ style["fig_h"] = float(FIG_H)
121
+ except Exception: # noqa: BLE001 (fall back to the shipped ratio)
122
+ pass
123
+
124
+ try:
125
+ from aggregate import style as agg_style
126
+
127
+ rc = agg_style.rc_params()
128
+ except Exception: # noqa: BLE001 (a missing style must not break the page)
129
+ return style
130
+
131
+ cycle = rc.get("axes.prop_cycle")
132
+ if cycle is not None:
133
+ colors = [entry.get("color") for entry in cycle if entry.get("color")]
134
+ if colors:
135
+ style["colors"] = colors
136
+ for key, rc_key in (
137
+ ("grid_color", "grid.color"),
138
+ ("text_color", "text.color"),
139
+ ("line_width", "lines.linewidth"),
140
+ ("font_size", "font.size"),
141
+ ):
142
+ value = rc.get(rc_key)
143
+ if value is not None:
144
+ style[key] = float(value) if isinstance(value, (int, float)) else str(value)
145
+ return style
146
+
147
+
148
+ @router.get("/meta/style", response_model=models.StyleResponse)
149
+ def plot_style() -> dict:
150
+ """The house plot style, so the SPA charts match the native plots."""
151
+ return _plot_style()
152
+
153
+
154
+ @router.get("/meta", response_model=models.MetaResponse)
155
+ async def meta() -> dict:
156
+ """Echo the live config knobs the SPA needs.
157
+
158
+ ``async def`` with the settings read in the body rather than through
159
+ ``Depends(get_settings)``, for the reason given on :func:`health`: a sync
160
+ dependency runs in the thread pool and needs one of anyio's 40 tokens, so a
161
+ route carrying one stops answering at exactly the moment the pool is
162
+ exhausted and somebody wants to know what this process is. ``get_settings``
163
+ is an ``lru_cache`` singleton, so calling it directly is a dict lookup and
164
+ the dependency bought nothing here beyond the token.
165
+
166
+ Flask users: ``Depends`` is FastAPI's dependency-injection primitive, the
167
+ closest analogue to ``flask.current_app.config`` but typed and validated. It
168
+ is the right tool nearly everywhere; this route is the exception, and why is
169
+ above.
170
+ """
171
+ settings = get_settings()
172
+ return {
173
+ "version": _pkg_version("aggregate_api"),
174
+ "aggregate_version": _pkg_version("aggregate"),
175
+ "tables_version": _pkg_version("greater-tables"),
176
+ "log2_cap": settings.log2_cap,
177
+ "log2_default": settings.log2_default,
178
+ "build_timeout_s": settings.build_timeout_s,
179
+ "cache_max": settings.cache_max,
180
+ "plugins": _plugin_manifest(),
181
+ }
182
+
183
+
184
+ def _plugin_manifest() -> list[dict]:
185
+ """The loaded plugins, their versions, their leaves and any load failure.
186
+
187
+ Returns
188
+ -------
189
+ list of dict
190
+ Matching :class:`aggregate_api.models.PluginInfo`, in the order
191
+ :func:`aggregate.plugins.loaded_plugins` reports, which is plugin name
192
+ alphabetically. Empty on a stock install and whenever
193
+ ``AGGAPI_PLUGINS_ENABLED`` is off.
194
+
195
+ Notes
196
+ -----
197
+ Read per request rather than cached, unlike :func:`_plot_style`. It is a list
198
+ comprehension over a handful of frozen dataclasses, and a cache would add a
199
+ second place for a stale answer to live on the one route whose whole job is
200
+ to say what this process currently is.
201
+
202
+ Only the **last line** of a failure's traceback travels. See
203
+ :class:`aggregate_api.models.PluginInfo` for why.
204
+ """
205
+ return [
206
+ {
207
+ "name": plugin.name,
208
+ "version": plugin.version,
209
+ "source": plugin.source,
210
+ "leaves": [
211
+ {
212
+ "name": leaf.name,
213
+ "kind": leaf.kind,
214
+ "label": leaf.label,
215
+ "hint": leaf.hint,
216
+ "why": leaf.why,
217
+ }
218
+ for leaf in plugin.leaves
219
+ ],
220
+ "error": _one_line(plugin.error),
221
+ }
222
+ for plugin in agg_plugins.loaded_plugins()
223
+ ]
224
+
225
+
226
+ def _one_line(error: str | None) -> str | None:
227
+ """The last non-empty line of a traceback, which is the exception itself."""
228
+ if not error:
229
+ return None
230
+ lines = [line.strip() for line in str(error).strip().splitlines()]
231
+ return next((line for line in reversed(lines) if line), None)
232
+
233
+
234
+ # ----------------------------------------------------------------------
235
+ # GET /v1/assets/{name} -- the table-document walker, out of the package
236
+ # ----------------------------------------------------------------------
237
+ # The SPA renders static tables from the IR that `tables.py` emits, using a
238
+ # walker that ships inside `greater_tables` itself. Serving it from the
239
+ # installed package rather than bundling a copy is what makes version skew
240
+ # between the document and its renderer impossible: one install ships both, so
241
+ # they move together or not at all.
242
+ #
243
+ # Deliberately not a StaticFiles mount. Two files, an allow-list, and an
244
+ # explicit media type is less machinery than a mount plus the traversal
245
+ # reasoning a mount invites.
246
+ _ASSETS = {
247
+ "gt-render.esm.js": "text/javascript",
248
+ "gt.css": "text/css",
249
+ }
250
+
251
+
252
+ @lru_cache(maxsize=None)
253
+ def _asset(name: str) -> bytes:
254
+ """Read one packaged asset. Cached: these are small and never change."""
255
+ return (files("greater_tables") / "assets" / name).read_bytes()
256
+
257
+
258
+ @router.get("/assets/{name}")
259
+ def asset(name: str, request: Request) -> Response:
260
+ """Serve a ``greater_tables`` front-end asset.
261
+
262
+ Notes
263
+ -----
264
+ Revalidation rather than cache busting. The ETag is the package version, and
265
+ ``no-cache`` asks the browser to check it every load, so a `uv sync` that
266
+ moves ``greater_tables`` is picked up on the next reload with no ``?v=``
267
+ for the client to compute and no stale-asset window.
268
+ """
269
+ media_type = _ASSETS.get(name)
270
+ if media_type is None:
271
+ raise HTTPException(
272
+ status_code=404,
273
+ detail=f"unknown asset {name!r}; expected one of {sorted(_ASSETS)}",
274
+ )
275
+ etag = f'"{_pkg_version("greater-tables")}"'
276
+ if request.headers.get("if-none-match") == etag:
277
+ return Response(status_code=304, headers={"ETag": etag})
278
+ return Response(
279
+ content=_asset(name),
280
+ media_type=media_type,
281
+ headers={"ETag": etag, "Cache-Control": "no-cache"},
282
+ )