dirigent-server 0.9.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.
dirigent_server/ui.py ADDED
@@ -0,0 +1,237 @@
1
+ """The web UI: a built single-page bundle, served off the API server itself.
2
+
3
+ The bundle mounts in two pieces, and the order of the two matters. Starlette matches routes
4
+ in registration order, so ``/assets`` is registered with the fixed paths, ahead of every
5
+ router, and the shell is mounted at ``/`` after them, where it claims whatever is left.
6
+
7
+ The UI routes with clean paths, so ``/runs/<id>`` is a route inside the bundle rather than a
8
+ file on disk. Nothing serves it, and a deep link or a refresh would answer 404 -- so the 404
9
+ handler serves the shell for a navigation that no route and no file claimed. A navigation is
10
+ a GET or HEAD, outside the reserved prefixes, whose ``Accept`` names an HTML type: a browser
11
+ navigating always names one, and ``fetch`` defaults to ``*/*``, which is not one. Everything
12
+ else keeps the problem document. Precedence is unchanged by any of it: an API route wins,
13
+ then a real file, and the shell is the last resort.
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+ from fastapi import FastAPI, HTTPException, Request, status
19
+ from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
20
+ from starlette.responses import Response
21
+ from starlette.staticfiles import StaticFiles
22
+ from starlette.types import Scope
23
+
24
+ from dirigent_core import __version__
25
+ from dirigent_core.config import Settings
26
+ from dirigent_server.errors import http_error
27
+
28
+ #: The file whose presence means a bundle was built, rather than the directory existing.
29
+ INDEX_FILENAME = "index.html"
30
+
31
+ #: Where vite emits every content-hashed file, and the path they are served from.
32
+ ASSETS_MOUNT_PATH = "/assets"
33
+
34
+ CONFIG_PATH = "/config.json"
35
+ FAVICON_PATH = "/favicon.ico"
36
+
37
+ #: Icons the shell may reference, most specific first; the first one present answers
38
+ #: ``/favicon.ico``, which browsers ask for whatever the document declares.
39
+ FAVICON_FILENAMES = (("favicon.ico", "image/x-icon"), ("favicon.svg", "image/svg+xml"), ("favicon-32.png", "image/png"))
40
+
41
+ #: A content-hashed file's name changes whenever its bytes do, so it never goes stale.
42
+ IMMUTABLE = "public, max-age=31536000, immutable"
43
+
44
+ #: The shell names the current hashes, so a cached one loads files a rebuild deleted.
45
+ REVALIDATE = "no-cache"
46
+
47
+ #: The media types a navigating browser names, and a ``fetch`` default of ``*/*`` does not.
48
+ HTML_ACCEPT = ("text/html", "application/xhtml+xml")
49
+
50
+ #: Paths the shell must never answer for: a fetch that got HTML instead of JSON fails three
51
+ #: layers away, and a missing hashed asset answered with the page that asked for it loops.
52
+ RESERVED_PREFIXES = (
53
+ "/health",
54
+ "/hooks",
55
+ "/docs",
56
+ "/redoc",
57
+ "/openapi.json",
58
+ ASSETS_MOUNT_PATH,
59
+ CONFIG_PATH,
60
+ FAVICON_PATH,
61
+ )
62
+
63
+ BUNDLE_MISSING = (
64
+ "The dirigent web UI is enabled and no bundle is built. Build one with `make ui`, "
65
+ "or set DIRIGENT_UI_ENABLED=false to run this instance as an API only. "
66
+ "The API itself is unaffected and is serving normally."
67
+ )
68
+
69
+ #: Where the built bundle lives inside the installed package: vite's ``build.outDir``.
70
+ PACKAGED_STATIC = Path(__file__).resolve().parent / "static"
71
+
72
+ #: Where vite leaves it in a checkout, so the dev loop needs no install step.
73
+ CHECKOUT_STATIC = Path(__file__).resolve().parents[2] / "frontend" / "dist"
74
+
75
+ MOUNT_NAME = "ui"
76
+ ASSETS_MOUNT_NAME = "ui-assets"
77
+
78
+
79
+ class UiStaticFiles(StaticFiles):
80
+ """Static files with a cache policy stated rather than left to a browser's heuristic.
81
+
82
+ Starlette sends only ETag and Last-Modified, and a response with no ``Cache-Control`` is
83
+ heuristically cacheable, so the policy is written down: the hashed asset tree is
84
+ immutable, and everything else revalidates.
85
+ """
86
+
87
+ def __init__(self, *, directory: Path, html: bool = False, immutable: bool = False) -> None:
88
+ """Serve one directory, either as the hashed asset tree or as the shell's root.
89
+
90
+ The directory may not exist yet: a checkout serves the bundle a later ``make ui``
91
+ writes, so existence is a per-request question rather than a startup one.
92
+ """
93
+ super().__init__(directory=directory, html=html, check_dir=False)
94
+ self.immutable = immutable
95
+
96
+ async def check_config(self) -> None:
97
+ """Verify the directory only once it exists; before that, every path is a miss."""
98
+ if self.directory is not None and Path(self.directory).is_dir():
99
+ await super().check_config()
100
+
101
+ async def get_response(self, path: str, scope: Scope) -> Response:
102
+ """Answer one static file, stating how long it may be held."""
103
+ response = await super().get_response(path, scope)
104
+ served = 200 <= response.status_code < 400
105
+ response.headers["cache-control"] = IMMUTABLE if self.immutable and served else REVALIDATE
106
+ return response
107
+
108
+
109
+ def static_dir(settings: Settings) -> Path | None:
110
+ """The built bundle this instance serves, or None when it has none to serve.
111
+
112
+ The installed package's own ``static/`` wins over a checkout's ``frontend/dist``.
113
+ """
114
+ if not settings.ui_enabled:
115
+ return None
116
+ for candidate in (PACKAGED_STATIC, CHECKOUT_STATIC):
117
+ if (candidate / INDEX_FILENAME).is_file():
118
+ return candidate
119
+ return None
120
+
121
+
122
+ def serving_root(settings: Settings) -> Path | None:
123
+ """Where this instance's bundle lives, or would land if one were built.
124
+
125
+ A bundle built later must be served without a restart, so the answer does not require
126
+ ``index.html`` to exist yet: a directory a build writes into is enough. A built bundle
127
+ keeps the same precedence ``static_dir`` states; with none, a checkout's ``frontend/``
128
+ names where ``make ui`` will put one, and an installed package's own ``static/`` is where
129
+ a wheel would have carried one.
130
+ """
131
+ if not settings.ui_enabled:
132
+ return None
133
+ built = static_dir(settings)
134
+ if built is not None:
135
+ return built
136
+ if CHECKOUT_STATIC.parent.is_dir():
137
+ return CHECKOUT_STATIC
138
+ if PACKAGED_STATIC.is_dir():
139
+ return PACKAGED_STATIC
140
+ return None
141
+
142
+
143
+ def mount_ui_assets(app: FastAPI, settings: Settings) -> None:
144
+ """Register the UI's fixed paths, before every router: the asset tree and the config document."""
145
+ if not settings.ui_enabled:
146
+ return
147
+ _add_config_route(app, settings)
148
+ directory = serving_root(settings)
149
+ if directory is None:
150
+ return
151
+ app.mount(
152
+ ASSETS_MOUNT_PATH,
153
+ UiStaticFiles(directory=directory / ASSETS_MOUNT_PATH.lstrip("/"), immutable=True),
154
+ name=ASSETS_MOUNT_NAME,
155
+ )
156
+ _add_favicon_route(app, directory)
157
+
158
+
159
+ def mount_ui_shell(app: FastAPI, settings: Settings) -> None:
160
+ """Mount the shell at ``/``, after every router, and answer a client route with it."""
161
+ if not settings.ui_enabled:
162
+ return
163
+ directory = serving_root(settings)
164
+ if directory is None:
165
+ _add_refusal_route(app)
166
+ return
167
+ app.mount("/", UiStaticFiles(directory=directory, html=True), name=MOUNT_NAME)
168
+ _add_shell_fallback(app, settings, directory / INDEX_FILENAME)
169
+
170
+
171
+ def _add_config_route(app: FastAPI, settings: Settings) -> None:
172
+ """Answer what the bundle cannot hardcode: where this instance's API is, and its version."""
173
+
174
+ @app.get(CONFIG_PATH, include_in_schema=False)
175
+ async def config() -> JSONResponse: # pyright: ignore[reportUnusedFunction]
176
+ return JSONResponse(
177
+ {"api_prefix": settings.api_prefix, "version": __version__},
178
+ headers={"cache-control": REVALIDATE},
179
+ )
180
+
181
+
182
+ def _add_favicon_route(app: FastAPI, directory: Path) -> None:
183
+ """Serve the first icon the bundle carries at ``/favicon.ico``."""
184
+ icons = [(directory / name, media_type) for name, media_type in FAVICON_FILENAMES]
185
+
186
+ @app.get(FAVICON_PATH, include_in_schema=False)
187
+ async def favicon() -> FileResponse: # pyright: ignore[reportUnusedFunction]
188
+ for path, media_type in icons:
189
+ if path.is_file():
190
+ return FileResponse(path, media_type=media_type, headers={"cache-control": REVALIDATE})
191
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="this bundle carries no icon")
192
+
193
+
194
+ def _add_refusal_route(app: FastAPI) -> None:
195
+ """Answer ``/`` with one sentence naming the command that builds the bundle."""
196
+
197
+ @app.get("/", include_in_schema=False)
198
+ async def missing() -> PlainTextResponse: # pyright: ignore[reportUnusedFunction]
199
+ return PlainTextResponse(
200
+ BUNDLE_MISSING,
201
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
202
+ headers={"cache-control": REVALIDATE},
203
+ )
204
+
205
+
206
+ def _add_shell_fallback(app: FastAPI, settings: Settings, index: Path) -> None:
207
+ """Serve the shell for a navigation nothing else claimed, and leave every other 404 alone.
208
+
209
+ The index is read per request, so a bundle built after startup is served without a
210
+ restart, and one a rebuild has momentarily emptied refuses honestly instead of erroring.
211
+ ``/`` itself answers the refusal to any GET, navigation or not, because it is the one
212
+ path a person and a probe both try first.
213
+ """
214
+ reserved = (settings.api_prefix, *RESERVED_PREFIXES)
215
+
216
+ async def shell_or_404(request: Request, error: Exception) -> Response:
217
+ at_root = request.url.path == "/" and request.method in ("GET", "HEAD")
218
+ if (_is_navigation(request) or at_root) and not request.url.path.startswith(reserved):
219
+ if index.is_file():
220
+ return FileResponse(index, media_type="text/html", headers={"cache-control": REVALIDATE})
221
+ if at_root or _is_navigation(request):
222
+ return PlainTextResponse(
223
+ BUNDLE_MISSING,
224
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
225
+ headers={"cache-control": REVALIDATE},
226
+ )
227
+ return await http_error(request, error)
228
+
229
+ app.add_exception_handler(status.HTTP_404_NOT_FOUND, shell_or_404)
230
+
231
+
232
+ def _is_navigation(request: Request) -> bool:
233
+ """Whether this request is a browser asking for a page, rather than code asking for data."""
234
+ if request.method not in ("GET", "HEAD"):
235
+ return False
236
+ accepted = request.headers.get("accept", "")
237
+ return any(media_type in accepted for media_type in HTML_ACCEPT)
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-server
3
+ Version: 0.9.0
4
+ Summary: Dirigent FastAPI server: the REST API, its authentication, and the health probes.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ License-File: THIRD_PARTY_NOTICES.md
8
+ Requires-Dist: dirigent-client
9
+ Requires-Dist: dirigent-common
10
+ Requires-Dist: dirigent-core
11
+ Requires-Dist: fastapi>=0.141.1
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # dirigent-server
16
+
17
+ Dirigent FastAPI server: the REST API, its authentication, and the health probes.
@@ -0,0 +1,32 @@
1
+ dirigent_server/__init__.py,sha256=4cI1TvtXRBRtawFFgbZy1F5R1FhjZjDdPughsO2leFw,318
2
+ dirigent_server/app.py,sha256=I5lbeBZwjiV9oUcqIxnm5KpMlBtOML_IsKuNwE_GGuA,6660
3
+ dirigent_server/dependencies.py,sha256=Ue_rXXlCntkN-2V12OU8qu9KqKI0WsDR_yIVObMxgws,1957
4
+ dirigent_server/errors.py,sha256=mMCLbJ-gZxF0WYnEs_QgF1XtS8Uwx5m_KS4soONEH0Q,4692
5
+ dirigent_server/health.py,sha256=HnS_YCKI5C_366oQNRg36Uwo6UOODLhOhlLy0wOsW9Y,3478
6
+ dirigent_server/logging.py,sha256=HNa30tPmos7yE_twoozvtHvtJXI-QB_HlY8Rpo6o_A4,223
7
+ dirigent_server/pagination.py,sha256=f9ur1wlhGCQj3c46Ck_S5dmhsh8WZrs9blfR_Zxytys,2074
8
+ dirigent_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ dirigent_server/routes/__init__.py,sha256=XOhUeN8m-xakoeN7SxFS8wp7Q8cvfOOXT5RE-7Bfwrc,2852
10
+ dirigent_server/routes/alerts.py,sha256=gs_q4bsneq61IO4SieTfpv2od0CQyNVWMzzATBb3stw,10694
11
+ dirigent_server/routes/auth.py,sha256=ctpyXOHm1QUN7URGE2stZ7-nSysxeBqTVx4UoHv1cBw,8557
12
+ dirigent_server/routes/blocks.py,sha256=xKp4tj4HYkv9VKZSmX6qUhfDhnx8zvGWYTKuk0nQUks,1514
13
+ dirigent_server/routes/connections.py,sha256=OcfLhVh1hIQ9CE8DVR-gAEFnq-N8cHJU5AanfbpogoU,12375
14
+ dirigent_server/routes/hooks.py,sha256=ImSiuZ8uDaj7cOxWJzrQwLK-uMJa1c6lta1sUnPjM_w,5323
15
+ dirigent_server/routes/pipelines.py,sha256=-s98ksFjdAbPN47N51hk9ukEKaPMWoiQvmNZcOfXLG4,16387
16
+ dirigent_server/routes/runs.py,sha256=zdjuaPI7OeZKATUg-EQT6DhoBpHF5tE2in_Qbmpl9uM,34486
17
+ dirigent_server/routes/schema.py,sha256=V0a-amIAiyQQTuW_K6X4stLH_OBiHpMFlbxXdPXFKrQ,1114
18
+ dirigent_server/routes/schemas.py,sha256=U80QuZUczj03vfKKlTWtoJ5nZ9z0-xSpIb6ltri7EP0,5461
19
+ dirigent_server/routes/system.py,sha256=Ah9HHcw8ULl59SllfzmyTZIn9yZy4F43nuwSrAug4oo,2397
20
+ dirigent_server/routes/trigger_documents.py,sha256=DLSmPtJJaQ-QG2kzPq-ACR0hIc6GS9KqDt8VovRFqh4,4225
21
+ dirigent_server/routes/triggers.py,sha256=sXIf7O_M3hqE1Rv-jBPDmI3-fYVnlSxw7Va5lHRDbgk,20446
22
+ dirigent_server/routes/users.py,sha256=PiBR9gZi3mISGpGPxSgmNUVxSjGx2msNY82NdqFvCh4,7849
23
+ dirigent_server/routes/workers.py,sha256=SvQMW_4SK0GFJ1SYdV8sT--gU7OOJXdL8wP0_tPH-t8,2059
24
+ dirigent_server/security.py,sha256=ZhocAlbIQ7UUpX0emSPWzoZUgoQZEf_xaR_esuKAsP0,7775
25
+ dirigent_server/static/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ dirigent_server/transactions.py,sha256=45fO6r3HFgXDfs9Bs2BgvHC1JYQ2nLiy2J--UlEGF4U,1459
27
+ dirigent_server/ui.py,sha256=YzHIxEjEX0wGsmXl2ffDWyZi72rMvi0y-KBGD9Raf3o,10234
28
+ dirigent_server-0.9.0.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
29
+ dirigent_server-0.9.0.dist-info/licenses/THIRD_PARTY_NOTICES.md,sha256=a_fvGwkpViiMt7XYjuiAvjNxt-6rTyVC2FYhrt5jjQQ,33423
30
+ dirigent_server-0.9.0.dist-info/WHEEL,sha256=mru_b36sH6joUMnwf7IlFCun3RoDjrNg9RcfBmEcqsE,81
31
+ dirigent_server-0.9.0.dist-info/METADATA,sha256=7pqewZxwVb4bLlXw3QJJwL43xj5Dj8mv5mJmJ8Q1LSU,541
32
+ dirigent_server-0.9.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.