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,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"
|