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,41 @@
1
+ """FastAPI service for the :mod:`aggregate` library.
2
+
3
+ This package stands up an HTTP/JSON wrapper around ``build()``,
4
+ the live ``Aggregate`` / ``Portfolio`` objects, plotting, pricing,
5
+ and DecL helpers (completions, lexing, grammar).
6
+
7
+ Quickstart
8
+ ----------
9
+
10
+ ::
11
+
12
+ pip install aggregate_api
13
+ aggregate-api --port 8001
14
+
15
+ Then ``POST http://127.0.0.1:8001/v1/objects`` with body
16
+ ``{"decl": "agg Dice dfreq [3] dsev [1:6]"}`` to build and cache an
17
+ object, and follow up with ``GET /v1/objects/{id}/info`` etc.
18
+
19
+ Public surface
20
+ --------------
21
+
22
+ ``create_app()`` returns a configured :class:`fastapi.FastAPI`
23
+ instance. The ``aggregate-api`` console script launches it under
24
+ uvicorn. Tests build their own ``TestClient`` against
25
+ ``create_app()``.
26
+
27
+ Note for Flask users
28
+ --------------------
29
+
30
+ FastAPI uses an *application factory* (``create_app``) plus an ASGI
31
+ server (uvicorn) instead of Flask's ``app = Flask(__name__)`` + WSGI.
32
+ Routes are grouped on ``APIRouter`` objects (analogous to Flask
33
+ ``Blueprint``) and mounted onto the app in :func:`app.create_app`.
34
+ Request bodies are *validated* by Pydantic models (one model per
35
+ endpoint) rather than read out of ``request.json``; the model is a
36
+ function parameter and FastAPI deserializes for you.
37
+ """
38
+
39
+ from .app import create_app
40
+
41
+ __all__ = ["create_app"]
@@ -0,0 +1,154 @@
1
+ """Entry point for ``python -m aggregate_api`` and ``aggregate-api``.
2
+
3
+ This is the bootstrap that turns the :func:`create_app` factory
4
+ into a running uvicorn process. CLI flags (``--host``, ``--port``,
5
+ ``--reload``, ``--library``, ``--headless``) override the env-var-driven
6
+ config; with no flags the server picks up everything from ``AGGAPI_*``.
7
+
8
+ ``--library`` points the whole process at an alternate ``.agg``
9
+ instead of ``aggregate``'s shipped ``library.agg``: the Examples
10
+ dropdown, every build and the ``.agg`` download all read it. A short
11
+ library is how you get a list to review against.
12
+
13
+ ``--headless`` drops the web app and serves the api alone, for a deploy
14
+ whose front end is somebody else's.
15
+
16
+ Flask users
17
+ -----------
18
+
19
+ Equivalent to ``flask run`` -- but with the production server
20
+ (uvicorn) baked in. There's no equivalent of Flask's dev/prod
21
+ switch; uvicorn is fast enough to be both, and ``--reload``
22
+ gives you the dev-mode file-watching behavior.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+
33
+ def resolve_library(raw: str) -> str:
34
+ """Validate a ``--library`` path and return it absolute.
35
+
36
+ Parameters
37
+ ----------
38
+ raw : str
39
+ The path as typed.
40
+
41
+ Returns
42
+ -------
43
+ str
44
+ The resolved absolute path.
45
+
46
+ Raises
47
+ ------
48
+ SystemExit
49
+ If the path is not a readable file.
50
+
51
+ Notes
52
+ -----
53
+ Deliberately stricter than the environment variable it feeds.
54
+ ``library.py`` warns and falls back to the shipped library when
55
+ ``AGGAPI_LIBRARY`` names a missing file, which is right for a stale
56
+ setting on a server. It is wrong for a flag typed on purpose: falling back to
57
+ the full library is the exact outcome someone passing ``--library`` is trying
58
+ to avoid, and a warning scrolling past in a server log is not a refusal. So
59
+ this exits, naming the path it could not read.
60
+
61
+ Absolute because the api resolves the library through an ``Underwriter``
62
+ pointed at the file's directory, and a relative path would be read against
63
+ whatever the working directory happens to be by then.
64
+ """
65
+ path = Path(raw).expanduser()
66
+ try:
67
+ resolved = path.resolve(strict=True)
68
+ except (OSError, RuntimeError):
69
+ raise SystemExit(f"aggregate-api: --library: no such file: {path}") from None
70
+ if not resolved.is_file():
71
+ raise SystemExit(f"aggregate-api: --library: not a file: {resolved}")
72
+ return str(resolved)
73
+
74
+
75
+ def main() -> None:
76
+ """Parse CLI flags and launch uvicorn."""
77
+ parser = argparse.ArgumentParser(
78
+ prog="aggregate-api",
79
+ description="Launch the aggregate api (FastAPI + uvicorn).",
80
+ )
81
+ parser.add_argument(
82
+ "--host",
83
+ default=None,
84
+ help="Bind address (defaults to AGGAPI_HOST, then 127.0.0.1).",
85
+ )
86
+ parser.add_argument(
87
+ "--port",
88
+ type=int,
89
+ default=None,
90
+ help="TCP port (defaults to AGGAPI_PORT, then 8000).",
91
+ )
92
+ parser.add_argument(
93
+ "--reload",
94
+ action="store_true",
95
+ help="Auto-reload on source changes (dev only -- prohibits "
96
+ "multi-worker mode and adds a watchgod thread).",
97
+ )
98
+ parser.add_argument(
99
+ "--library",
100
+ default=None,
101
+ metavar="PATH",
102
+ help="Read the recipe base from this .agg file instead of "
103
+ "aggregate's shipped library.agg: the Examples menu, every build "
104
+ "and the .agg download. A short library makes review quicker. "
105
+ "Same as AGGAPI_LIBRARY, but it fails rather than falling back "
106
+ "when the path is wrong.",
107
+ )
108
+ parser.add_argument(
109
+ "--headless",
110
+ action="store_true",
111
+ help="Do not serve the web app. Everything under /v1 stays, as do "
112
+ "/docs and /openapi.json; only / stops answering. For a deploy "
113
+ "whose front end is somebody else's, which also wants "
114
+ "AGGAPI_CORS_ORIGINS. Same as AGGAPI_SERVE_SPA=0.",
115
+ )
116
+ args = parser.parse_args()
117
+
118
+ # Set the environment, not the Settings object. `--reload` builds the app in
119
+ # a *child* process, which inherits os.environ and would never see a value
120
+ # poked into this process's cached Settings. It also has to happen before the
121
+ # first `get_settings()` below, which caches.
122
+ if args.library is not None:
123
+ os.environ["AGGAPI_LIBRARY"] = resolve_library(args.library)
124
+ if args.headless:
125
+ os.environ["AGGAPI_SERVE_SPA"] = "0"
126
+
127
+ # Import inside ``main`` so ``aggregate-api --help`` doesn't pay
128
+ # the cost of loading FastAPI / uvicorn / aggregate.
129
+ import uvicorn
130
+
131
+ from .config import get_settings
132
+
133
+ settings = get_settings()
134
+ if args.library is not None:
135
+ print(f"library: {settings.library}", file=sys.stderr)
136
+ if not settings.serve_spa:
137
+ print("headless: serving /v1, /docs and /openapi.json; / is not served",
138
+ file=sys.stderr)
139
+ # The ``factory=True`` flag tells uvicorn that the target is a
140
+ # *callable* returning an app rather than an app instance --
141
+ # so we hand it ``aggregate_api.app:create_app`` and it calls
142
+ # the factory itself. This plays nicely with --reload: the
143
+ # factory re-runs on each worker restart, picking up code edits.
144
+ uvicorn.run(
145
+ "aggregate_api.app:create_app",
146
+ factory=True,
147
+ host=args.host or settings.host,
148
+ port=args.port or settings.port,
149
+ reload=args.reload,
150
+ )
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
aggregate_api/app.py ADDED
@@ -0,0 +1,206 @@
1
+ """FastAPI application factory.
2
+
3
+ ``create_app()`` is the single entry point: it builds a fresh
4
+ :class:`fastapi.FastAPI`, installs CORS if configured, mounts the
5
+ ``v1`` routers, and -- if the Plan D web bundle is present --
6
+ serves it as static files at ``/``.
7
+
8
+ Flask users
9
+ -----------
10
+
11
+ The factory pattern (``create_app``) lets uvicorn launch the app
12
+ lazily (one app per worker) and lets tests build a fresh instance
13
+ per test if they want. The Flask-equivalent is the standard
14
+ "application factory" used by larger Flask apps.
15
+
16
+ Routing model
17
+ -------------
18
+
19
+ Each ``routes/*.py`` module defines an :class:`APIRouter`
20
+ (``router`` global). The factory mounts all of them under
21
+ ``/v1``. The OpenAPI schema (``/openapi.json``) and the
22
+ Swagger UI (``/docs``) are added by FastAPI automatically.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from importlib.metadata import version as _pkg_version
29
+ from pathlib import Path
30
+
31
+ from aggregate import plugins as agg_plugins
32
+ from fastapi import FastAPI
33
+ from fastapi.middleware.gzip import GZipMiddleware
34
+ from fastapi.staticfiles import StaticFiles
35
+
36
+ from . import resources, status
37
+ from .config import Settings, get_settings
38
+ from .cors import install_cors
39
+ from .library import get_underwriter
40
+ from .routes import decl as decl_routes
41
+ from .routes import examples as examples_routes
42
+ from .routes import meta as meta_routes
43
+ from .routes import objects as objects_routes
44
+ from .routes import status as status_routes
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _load_plugins(settings: Settings) -> None:
50
+ """Run third-party ``aggregate.plugins`` registrations, once per process.
51
+
52
+ Parameters
53
+ ----------
54
+ settings : Settings
55
+ Read for ``plugins_enabled`` and ``plugins_allow``.
56
+
57
+ Notes
58
+ -----
59
+ The library does not auto-load on import, by design: ``build()`` has to be
60
+ reproducible, so a notebook's answers must not depend on what happens to be
61
+ installed. The host decides, and a server is a deployment that may declare
62
+ what it trusts, which is why this is here and behind a setting.
63
+
64
+ :func:`aggregate.plugins.load` is idempotent, so the many apps a test run
65
+ builds pay for discovery once. It never raises: a plugin whose import or
66
+ ``register()`` fails is recorded on its own :class:`~aggregate.plugins.LoadedPlugin`
67
+ and skipped, and one broken experiment must not take the server down. The
68
+ failure is logged here, at WARNING, **and** reported on ``GET /v1/meta``,
69
+ because a plugin that silently did not load is otherwise debugged by
70
+ wondering why a tab is empty.
71
+ """
72
+ if not settings.plugins_enabled:
73
+ return
74
+ loaded = agg_plugins.load(allow=settings.plugins_allow)
75
+ for plugin in loaded:
76
+ if plugin.ok:
77
+ continue
78
+ logger.warning("plugin %r failed to load and was skipped: %s",
79
+ plugin.name, plugin.error)
80
+
81
+
82
+ def create_app(settings: Settings | None = None) -> FastAPI:
83
+ """Build the FastAPI app.
84
+
85
+ Parameters
86
+ ----------
87
+ settings : Settings | None
88
+ Optional pre-built settings object. ``None`` means "read
89
+ from environment via :func:`get_settings`" -- the normal
90
+ path. Tests can inject a custom ``Settings`` to bypass
91
+ env-var setup.
92
+
93
+ Returns
94
+ -------
95
+ FastAPI
96
+ Configured app, routers mounted, ready for uvicorn.
97
+ """
98
+ if settings is None:
99
+ # Force a fresh read so config-affecting monkeypatches in
100
+ # tests are honored. Cache the result so subsequent
101
+ # ``Depends(get_settings)`` calls return the same object.
102
+ get_settings.cache_clear()
103
+ settings = get_settings()
104
+ # Drop cached cache+audit singletons -- the next request
105
+ # rebuilds them against the (possibly just-changed) config.
106
+ objects_routes.reset_singletons()
107
+ # And the underwriter, for the same reason: which library it reads is a
108
+ # setting, and a test that just changed that setting would otherwise get the
109
+ # base built for the previous case.
110
+ get_underwriter.cache_clear()
111
+ # Process-lifetime facts for GET /v1/status. Both are idempotent and both
112
+ # are here rather than at import time, because a module-level record would
113
+ # be made when the first import happens rather than when the process starts
114
+ # serving, and the second is the number an operator reads uptime against.
115
+ # ``status.mark_started`` records once per process even though tests build
116
+ # many apps: see its Notes for why an uptime that resets under a test client
117
+ # would make every "since process start" label on the page a lie.
118
+ status.mark_started()
119
+ resources.seed()
120
+ _load_plugins(settings)
121
+
122
+ app = FastAPI(
123
+ title="aggregate api",
124
+ description=(
125
+ "HTTP/JSON wrapper around the aggregate library. "
126
+ "Stand up DecL parsing, FFT-based compound distributions, "
127
+ "and risk-pricing as a web service."
128
+ ),
129
+ version=_pkg_version("aggregate_api"),
130
+ # Disable the default Pydantic-validation 422 schema in
131
+ # OpenAPI -- it's noisy and we override the parse path
132
+ # with our own ErrorReport response model.
133
+ )
134
+
135
+ # Compress responses. A density payload is the whole point: the exhibits ask
136
+ # for every grid point (2**16 rows), which is 3.6 MB of JSON for one
137
+ # aggregate and 7 MB for a portfolio's per-unit frame. Those are floats
138
+ # rendered as decimal text, so they compress about 10 to 1: 0.35 MB and
139
+ # 1.3 MB on the wire, which is what an image costs.
140
+ #
141
+ # 1 kB minimum, so a health check or a one-row pentagon is not worth the
142
+ # round trip through zlib. Installed before CORS so the middleware stack
143
+ # unwinds with CORS headers on the outside, where a browser needs them even
144
+ # on a compressed response.
145
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
146
+
147
+ install_cors(app, settings.cors_origins)
148
+
149
+ # Mount all routers under /v1. FastAPI's include_router accepts
150
+ # a prefix, similar to Flask's blueprint url_prefix kwarg.
151
+ app.include_router(meta_routes.router, prefix="/v1", tags=["meta"])
152
+ app.include_router(objects_routes.router, prefix="/v1", tags=["objects"])
153
+ app.include_router(decl_routes.router, prefix="/v1", tags=["decl"])
154
+ app.include_router(examples_routes.router, prefix="/v1", tags=["examples"])
155
+ # The operator's page. Mounted with the rest, and ahead of the static mount
156
+ # below, which is why it lives under /v1: an unmatched top-level path falls
157
+ # through to StaticFiles(html=True) and would be answered with index.html
158
+ # rather than a 404, so a top-level /status could be shadowed by a
159
+ # registration-order mistake and would fail by serving the SPA. Its own
160
+ # routes are include_in_schema=False and behind require_private; the public
161
+ # Caddy block 404s the whole /v1/status prefix before any of that is
162
+ # reached. See routes/status.py.
163
+ app.include_router(status_routes.router, prefix="/v1", tags=["status"])
164
+
165
+ # Static-file mount for the SPA (Plan D). Conditional because
166
+ # the api ships independently of the web build; if the web
167
+ # bundle isn't present, the api is api-only and ``/`` returns
168
+ # 404 (which is fine for backend-only deploys).
169
+ static_dir = _resolve_static_dir(settings)
170
+ if static_dir is not None and static_dir.exists():
171
+ # ``html=True`` tells StaticFiles to serve index.html for /
172
+ # *and* fall back to it for unknown paths -- the SPA-style
173
+ # client-side routing behavior the web app needs.
174
+ app.mount(
175
+ "/",
176
+ StaticFiles(directory=str(static_dir), html=True),
177
+ name="static",
178
+ )
179
+
180
+ return app
181
+
182
+
183
+ def _resolve_static_dir(settings: Settings) -> Path | None:
184
+ """Locate the SPA bundle directory.
185
+
186
+ Order of precedence:
187
+
188
+ 0. ``AGGAPI_SERVE_SPA=0`` (``settings.serve_spa``) refuses outright, which
189
+ is what ``aggregate-api --headless`` sets. Ahead of the other two so
190
+ this stays the single place that decides whether to mount, rather than
191
+ adding a second condition at the mount site.
192
+ 1. ``AGGAPI_STATIC_DIR`` env var (handed via ``settings.static_dir``).
193
+ Useful for developing the SPA out of a separate tree.
194
+ 2. ``src/aggregate_api/static`` inside the installed package.
195
+
196
+ Returns None if serving is off, or if neither location is set / exists.
197
+ """
198
+ if not settings.serve_spa:
199
+ return None
200
+ if settings.static_dir:
201
+ return Path(settings.static_dir)
202
+ # importlib.resources style: the static dir lives next to
203
+ # this file. We use Path rather than files() because StaticFiles
204
+ # needs a real filesystem path, not a Traversable.
205
+ pkg_root = Path(__file__).resolve().parent
206
+ return pkg_root / "static"