withcache 0.9.0__tar.gz → 0.10.0__tar.gz
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.
- {withcache-0.9.0 → withcache-0.10.0}/PKG-INFO +1 -1
- {withcache-0.9.0 → withcache-0.10.0}/shim/build.zig.zon +1 -1
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/__init__.py +1 -1
- withcache-0.10.0/src/withcache/_api.py +386 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_app.py +144 -39
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_settings_store.py +4 -4
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/cached.html +2 -2
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/catalog.html +115 -8
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/misses.html +2 -2
- withcache-0.10.0/src/withcache/client.py +266 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/server.py +30 -24
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_admin_forms.py +2 -2
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_blob.py +18 -43
- withcache-0.10.0/tests/test_fastapi_catalog_api.py +316 -0
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_scaffold.py +2 -6
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_settings_persistence.py +2 -2
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_ui_pages.py +2 -2
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_withcache.py +9 -26
- withcache-0.9.0/src/withcache/_api.py +0 -182
- withcache-0.9.0/src/withcache/client.py +0 -89
- {withcache-0.9.0 → withcache-0.10.0}/.gitignore +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/LICENSE +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/README.md +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/deploy/Containerfile +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/deploy/compose.local-build.yml +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/deploy/compose.yml +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/deploy/envvars.example +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/hatch_build.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/pyproject.toml +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/shim/build.zig +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/shim/shim.zig +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_shim.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/_layout.html +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/downloads.html +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/login.html +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/settings.html +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/oras.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_differential.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
- {withcache-0.9.0 → withcache-0.10.0}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)
|
|
5
5
|
Project-URL: Homepage, https://github.com/safl/withcache
|
|
6
6
|
Author-email: "Simon A. F. Lund" <safl@safl.dk>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.name = .withcache_shim,
|
|
3
3
|
// Zig requires a literal here; keep it in lockstep with the project's
|
|
4
4
|
// single source (src/withcache/__init__.py) via `make bump` / `make version-check`.
|
|
5
|
-
.version = "0.
|
|
5
|
+
.version = "0.10.0",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Byte-serving routes for the withcache FastAPI app.
|
|
2
|
+
|
|
3
|
+
Ports ``GET /blob?url=<origin>`` and ``GET /b/<b64>/<name>`` (+
|
|
4
|
+
their HEAD counterparts) from the stdlib ``server.py`` handler.
|
|
5
|
+
This is the bty-critical surface: bty at
|
|
6
|
+
``bty.web._withcache.blob_url`` builds these URLs and the live
|
|
7
|
+
env hands them to a ``wget|dd`` chain, so the wire contract must
|
|
8
|
+
stay byte-identical:
|
|
9
|
+
|
|
10
|
+
- URL shape: ``/b/<urlsafe_b64(src_url)>/<name>``. Name segment is
|
|
11
|
+
decorative; the b64 token is the source of truth.
|
|
12
|
+
- Cache hit -> 200 + ``Content-Type`` + ``Content-Length`` +
|
|
13
|
+
``X-Withcache-Sha256`` + streamed body (64 KiB chunks).
|
|
14
|
+
- Cache miss -> 404 with ``cache miss (recorded)`` body,
|
|
15
|
+
side-effect of enqueueing a background fetch when
|
|
16
|
+
``auto_fetch`` is on and the store has capacity.
|
|
17
|
+
- HEAD support: same headers, no body (probes from
|
|
18
|
+
bty.web._withcache.is_cached use HEAD).
|
|
19
|
+
- ``Authorization`` request header forwarded onto the background
|
|
20
|
+
fetch worker so a token-gated origin (typical use case: an OCI
|
|
21
|
+
bearer minted by bty at catalog import time) can be pulled by
|
|
22
|
+
the worker with the same credential.
|
|
23
|
+
|
|
24
|
+
Streaming shape: FastAPI ``StreamingResponse`` with a generator
|
|
25
|
+
that reads chunks from the on-disk blob at 64 KiB reads;
|
|
26
|
+
:class:`StreamRegistry` ticks progress per chunk so the operator
|
|
27
|
+
Downloads page shows in-flight transfers as they run.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import urllib.parse
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
38
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
|
39
|
+
|
|
40
|
+
from .server import CHUNK, CatalogState, StreamRegistry, _oras_tag_moved, _serialise_catalog
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode_blob_origin(path: str, query: str) -> str:
|
|
44
|
+
"""Extract the source URL from either shape.
|
|
45
|
+
|
|
46
|
+
``/blob?url=<origin>``: read ``url`` query param.
|
|
47
|
+
``/b/<b64>/<name>``: base64-decode the first path segment. The
|
|
48
|
+
trailing ``<name>`` is decorative -- a client that derives
|
|
49
|
+
format from URL filename (e.g. bty's flash chain) gets the
|
|
50
|
+
right extension without the b64 token having to include it.
|
|
51
|
+
"""
|
|
52
|
+
if path.startswith("/b/"):
|
|
53
|
+
token = path[len("/b/") :].split("/", 1)[0]
|
|
54
|
+
try:
|
|
55
|
+
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode("utf-8")
|
|
56
|
+
except (ValueError, UnicodeDecodeError):
|
|
57
|
+
return ""
|
|
58
|
+
return (urllib.parse.parse_qs(query).get("url") or [""])[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _serve_blob(
|
|
62
|
+
request: Request,
|
|
63
|
+
url: str,
|
|
64
|
+
head_only: bool,
|
|
65
|
+
) -> Response:
|
|
66
|
+
"""Shared implementation for GET and HEAD blob requests.
|
|
67
|
+
|
|
68
|
+
Returns 400 when the origin URL couldn't be decoded, 404 on
|
|
69
|
+
cache miss (with a hint pointing at ``POST /catalog/entries/
|
|
70
|
+
{name}/download``), or 200 + a StreamingResponse on hit.
|
|
71
|
+
|
|
72
|
+
Since v0.10.0 there is no auto-fetch on miss: the operator
|
|
73
|
+
picks explicitly what to download on withcache's /ui/catalog
|
|
74
|
+
page. A miss here is either "not downloaded yet" (operator
|
|
75
|
+
action needed) or "hit an unlisted URL" (bug in the caller).
|
|
76
|
+
"""
|
|
77
|
+
if not url:
|
|
78
|
+
return PlainTextResponse("missing url\n", status_code=400)
|
|
79
|
+
|
|
80
|
+
store = request.app.state.store
|
|
81
|
+
streams: StreamRegistry = request.app.state.streams
|
|
82
|
+
|
|
83
|
+
row = store.get_blob(url)
|
|
84
|
+
if row is not None and _oras_tag_moved(url, row["sha256"]):
|
|
85
|
+
# Tag re-pushed since we cached it: drop the stale bytes.
|
|
86
|
+
# The miss branch below turns this into a 404 the operator
|
|
87
|
+
# resolves by hitting Download / Redownload on /ui/catalog.
|
|
88
|
+
store.delete_blob(row["key"])
|
|
89
|
+
row = None
|
|
90
|
+
|
|
91
|
+
if row is None:
|
|
92
|
+
store.record_miss(url)
|
|
93
|
+
return PlainTextResponse(
|
|
94
|
+
"cache miss: this URL hasn't been downloaded yet. "
|
|
95
|
+
"Pick the matching catalog entry on /ui/catalog and hit Download.\n",
|
|
96
|
+
status_code=404,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
path = store.blob_path(row["key"])
|
|
100
|
+
headers = {
|
|
101
|
+
"Content-Length": str(row["size"]),
|
|
102
|
+
"X-Withcache-Sha256": row["sha256"],
|
|
103
|
+
}
|
|
104
|
+
media_type = row["content_type"] or "application/octet-stream"
|
|
105
|
+
|
|
106
|
+
if head_only:
|
|
107
|
+
# No body; the shim's HEAD probe doesn't count as a served
|
|
108
|
+
# download so record_hit + stream registration are skipped.
|
|
109
|
+
return Response(status_code=200, media_type=media_type, headers=headers)
|
|
110
|
+
|
|
111
|
+
store.record_hit(row["key"])
|
|
112
|
+
client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
|
|
113
|
+
stream = streams.start(url=url, client=client, total=row["size"])
|
|
114
|
+
|
|
115
|
+
def _chunks() -> Iterator[bytes]:
|
|
116
|
+
"""Read the blob in 64 KiB chunks. StreamingResponse iterates
|
|
117
|
+
this + writes each chunk to the client. Progress ticks fire
|
|
118
|
+
every :data:`StreamRegistry.PROGRESS_STRIDE` chunks so the
|
|
119
|
+
dashboard's 1 Hz refresh sees updates without lock-contention
|
|
120
|
+
on a busy box."""
|
|
121
|
+
sent = 0
|
|
122
|
+
ticks = 0
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "rb") as f:
|
|
125
|
+
while True:
|
|
126
|
+
chunk = f.read(CHUNK)
|
|
127
|
+
if not chunk:
|
|
128
|
+
break
|
|
129
|
+
yield chunk
|
|
130
|
+
sent += len(chunk)
|
|
131
|
+
ticks += 1
|
|
132
|
+
if ticks % StreamRegistry.PROGRESS_STRIDE == 0:
|
|
133
|
+
streams.bump(stream.id, sent)
|
|
134
|
+
streams.bump(stream.id, sent)
|
|
135
|
+
finally:
|
|
136
|
+
streams.finish(stream.id)
|
|
137
|
+
|
|
138
|
+
return StreamingResponse(_chunks(), media_type=media_type, headers=headers)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def register_api_routes(app: FastAPI) -> None:
|
|
142
|
+
"""Attach the byte-serving routes to ``app``.
|
|
143
|
+
|
|
144
|
+
Runtime objects (``store``, ``mgr``, ``streams``, ``auto_fetch``)
|
|
145
|
+
are read from ``app.state`` at request time so tests + the
|
|
146
|
+
lifespan hook can inject fresh instances without rebuilding
|
|
147
|
+
the app.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
@app.get("/blob", response_class=Response)
|
|
151
|
+
def blob_get_legacy(request: Request) -> Response:
|
|
152
|
+
"""Legacy ``?url=<origin>`` shape. Kept for downstream
|
|
153
|
+
consumers that pinned this before the ``/b/<b64>/<name>``
|
|
154
|
+
canonical URL landed."""
|
|
155
|
+
url = _decode_blob_origin("/blob", request.url.query)
|
|
156
|
+
return _serve_blob(request, url, head_only=False)
|
|
157
|
+
|
|
158
|
+
@app.head("/blob")
|
|
159
|
+
def blob_head_legacy(request: Request) -> Response:
|
|
160
|
+
url = _decode_blob_origin("/blob", request.url.query)
|
|
161
|
+
return _serve_blob(request, url, head_only=True)
|
|
162
|
+
|
|
163
|
+
@app.get("/b/{token}/{name:path}", response_class=Response)
|
|
164
|
+
def blob_get_b64(token: str, name: str, request: Request) -> Response:
|
|
165
|
+
"""Canonical ``/b/<b64>/<name>`` shape. bty's flash chain
|
|
166
|
+
builds these URLs; the ``<name>`` segment is decorative so
|
|
167
|
+
the client derives extension for downstream format probes."""
|
|
168
|
+
del name # decorative segment; source of truth is the b64 token
|
|
169
|
+
url = _decode_blob_origin(f"/b/{token}/x", "")
|
|
170
|
+
return _serve_blob(request, url, head_only=False)
|
|
171
|
+
|
|
172
|
+
@app.head("/b/{token}/{name:path}")
|
|
173
|
+
def blob_head_b64(token: str, name: str, request: Request) -> Response:
|
|
174
|
+
del name
|
|
175
|
+
url = _decode_blob_origin(f"/b/{token}/x", "")
|
|
176
|
+
return _serve_blob(request, url, head_only=True)
|
|
177
|
+
|
|
178
|
+
# ---------- Catalog JSON API -----------------------------------------
|
|
179
|
+
#
|
|
180
|
+
# Bty consumes the catalog through these endpoints: withcache is the
|
|
181
|
+
# single source of truth for what images exist. Read is open (mirrors
|
|
182
|
+
# /blob's LAN-only trust model); writes gate on the same Bearer
|
|
183
|
+
# pattern nbdmux uses -- an ``Authorization: Bearer <pw>`` header
|
|
184
|
+
# whose value matches ``$WITHCACHE_ADMIN_PASSWORD``, or a session
|
|
185
|
+
# cookie for browser-based operators.
|
|
186
|
+
|
|
187
|
+
def _bearer_or_session_authed(request: Request) -> None:
|
|
188
|
+
"""Auth dependency for catalog writes.
|
|
189
|
+
|
|
190
|
+
- No password configured -> open (single-tenant LAN deploy).
|
|
191
|
+
- Session cookie present -> OK (browser path).
|
|
192
|
+
- Matching Bearer token -> OK (service-to-service path;
|
|
193
|
+
bty reads ``$WITHCACHE_ADMIN_PASSWORD`` and posts it).
|
|
194
|
+
- Otherwise -> 401.
|
|
195
|
+
"""
|
|
196
|
+
auth = request.app.state.auth
|
|
197
|
+
if not auth.enabled:
|
|
198
|
+
return
|
|
199
|
+
# Session cookie check -- Starlette's SessionMiddleware
|
|
200
|
+
# stores the flag under ``withcache_authed`` in
|
|
201
|
+
# ``request.session``. Mirrors the flag ``_app.py`` sets.
|
|
202
|
+
if request.session.get("withcache_authed"):
|
|
203
|
+
return
|
|
204
|
+
header = request.headers.get("Authorization") or ""
|
|
205
|
+
if header.startswith("Bearer ") and auth.check_bearer(header[len("Bearer ") :]):
|
|
206
|
+
return
|
|
207
|
+
raise HTTPException(status_code=401, detail="auth required")
|
|
208
|
+
|
|
209
|
+
@app.get("/catalog")
|
|
210
|
+
def list_catalog(request: Request) -> JSONResponse:
|
|
211
|
+
"""Return the current catalog as JSON.
|
|
212
|
+
|
|
213
|
+
Open route: bty polls this from a sibling container without a
|
|
214
|
+
session (mirrors the ``/blob`` byte-serving surface's trust
|
|
215
|
+
model -- LAN-only, no auth on reads). Returns a snapshot of
|
|
216
|
+
the in-process ``CatalogState.entries`` plus provenance
|
|
217
|
+
metadata (URL, env pin, fetch timestamps) so clients can
|
|
218
|
+
detect staleness. Every entry is enriched with a
|
|
219
|
+
``downloaded_at`` field (or ``None``) so nbdmux + bty can
|
|
220
|
+
filter to only-downloaded entries without a second call. No
|
|
221
|
+
pagination -- the catalog is small (dozens of entries at
|
|
222
|
+
most).
|
|
223
|
+
"""
|
|
224
|
+
cs: CatalogState = request.app.state.catalog
|
|
225
|
+
store = request.app.state.store
|
|
226
|
+
entries_out: list[dict[str, Any]] = []
|
|
227
|
+
for e in cs.entries:
|
|
228
|
+
enriched = dict(e)
|
|
229
|
+
fetch_url = e.get("resolved_src") or e.get("src") or ""
|
|
230
|
+
row = store.get_blob(fetch_url) if fetch_url else None
|
|
231
|
+
enriched["downloaded_at"] = row["fetched_at"] if row else None
|
|
232
|
+
enriched["downloaded_size"] = int(row["size"]) if row else None
|
|
233
|
+
entries_out.append(enriched)
|
|
234
|
+
return JSONResponse(
|
|
235
|
+
{
|
|
236
|
+
"url": cs.url,
|
|
237
|
+
"env_url": cs.env_url,
|
|
238
|
+
"fetched_at": cs.fetched_at,
|
|
239
|
+
"last_error": cs.last_error,
|
|
240
|
+
"entries": entries_out,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@app.post("/catalog/entries", status_code=201)
|
|
245
|
+
def add_catalog_entry(
|
|
246
|
+
body: dict[str, Any],
|
|
247
|
+
request: Request,
|
|
248
|
+
_auth: None = Depends(_bearer_or_session_authed),
|
|
249
|
+
) -> JSONResponse:
|
|
250
|
+
"""Insert one catalog entry.
|
|
251
|
+
|
|
252
|
+
Body: ``{"name": "...", "src": "...", "format?", "arch?",
|
|
253
|
+
"sha256?", "size_bytes?", "resolved_src?", "description?"}``.
|
|
254
|
+
Only ``src`` is required; every other field is optional and
|
|
255
|
+
gets persisted through the ``catalog.toml`` round-trip.
|
|
256
|
+
|
|
257
|
+
409 when an entry with the same ``name`` already exists;
|
|
258
|
+
rejecting on duplicate name (not src) because the display
|
|
259
|
+
surface keys on name and two rows with the same name would
|
|
260
|
+
collide on the /ui/catalog table.
|
|
261
|
+
|
|
262
|
+
Rejects any unknown top-level key -- the emitter allowlists
|
|
263
|
+
keys anyway, but failing loud at the API boundary saves the
|
|
264
|
+
operator from wondering why their ``notes`` field vanished.
|
|
265
|
+
"""
|
|
266
|
+
allowed = {
|
|
267
|
+
"name",
|
|
268
|
+
"src",
|
|
269
|
+
"resolved_src",
|
|
270
|
+
"format",
|
|
271
|
+
"arch",
|
|
272
|
+
"sha256",
|
|
273
|
+
"size_bytes",
|
|
274
|
+
"description",
|
|
275
|
+
}
|
|
276
|
+
if not isinstance(body, dict):
|
|
277
|
+
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
|
278
|
+
unknown = set(body.keys()) - allowed
|
|
279
|
+
if unknown:
|
|
280
|
+
raise HTTPException(
|
|
281
|
+
status_code=400,
|
|
282
|
+
detail=f"unknown key(s) {sorted(unknown)}; allowed: {sorted(allowed)}",
|
|
283
|
+
)
|
|
284
|
+
name = body.get("name")
|
|
285
|
+
src = body.get("src")
|
|
286
|
+
if not isinstance(name, str) or not name.strip():
|
|
287
|
+
raise HTTPException(status_code=400, detail="name: non-empty string required")
|
|
288
|
+
if not isinstance(src, str) or not src.strip():
|
|
289
|
+
raise HTTPException(status_code=400, detail="src: non-empty string required")
|
|
290
|
+
|
|
291
|
+
cs: CatalogState = request.app.state.catalog
|
|
292
|
+
if any(e.get("name") == name for e in cs.entries):
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
status_code=409,
|
|
295
|
+
detail=f"catalog entry with name={name!r} already exists",
|
|
296
|
+
)
|
|
297
|
+
entry = {k: v for k, v in body.items() if v is not None and v != ""}
|
|
298
|
+
cs.entries.append(entry)
|
|
299
|
+
_persist_catalog(cs)
|
|
300
|
+
return JSONResponse(entry, status_code=201)
|
|
301
|
+
|
|
302
|
+
@app.post("/catalog/entries/{name}/download", status_code=202)
|
|
303
|
+
def download_catalog_entry(
|
|
304
|
+
name: str,
|
|
305
|
+
request: Request,
|
|
306
|
+
_auth: None = Depends(_bearer_or_session_authed),
|
|
307
|
+
) -> JSONResponse:
|
|
308
|
+
"""Fetch the entry's bytes into the local cache.
|
|
309
|
+
|
|
310
|
+
Since v0.10.0 auto-fetch on cache miss is gone: the operator
|
|
311
|
+
picks explicitly what to download. Nbdmux + bty's flash
|
|
312
|
+
chain both refuse to hand out /b/<url> URLs for entries
|
|
313
|
+
that haven't been downloaded, so this is the one-place
|
|
314
|
+
control for "make it servable."
|
|
315
|
+
|
|
316
|
+
Idempotent: enqueuing an entry that already has a live
|
|
317
|
+
download returns the existing job. Force-refetch is
|
|
318
|
+
``POST /catalog/entries/{name}/download?force=1``.
|
|
319
|
+
"""
|
|
320
|
+
cs: CatalogState = request.app.state.catalog
|
|
321
|
+
mgr = request.app.state.mgr
|
|
322
|
+
store = request.app.state.store
|
|
323
|
+
|
|
324
|
+
entry = next((e for e in cs.entries if e.get("name") == name), None)
|
|
325
|
+
if entry is None:
|
|
326
|
+
raise HTTPException(status_code=404, detail=f"no catalog entry with name={name!r}")
|
|
327
|
+
fetch_url = entry.get("resolved_src") or entry.get("src") or ""
|
|
328
|
+
if not fetch_url:
|
|
329
|
+
raise HTTPException(
|
|
330
|
+
status_code=400,
|
|
331
|
+
detail=f"catalog entry {name!r} has no ``src`` / ``resolved_src`` to fetch",
|
|
332
|
+
)
|
|
333
|
+
force = request.query_params.get("force") in ("1", "true", "yes")
|
|
334
|
+
if force:
|
|
335
|
+
existing = store.get_blob(fetch_url)
|
|
336
|
+
if existing is not None:
|
|
337
|
+
store.delete_blob(existing["key"])
|
|
338
|
+
job = mgr.enqueue(fetch_url)
|
|
339
|
+
return JSONResponse(
|
|
340
|
+
{
|
|
341
|
+
"name": name,
|
|
342
|
+
"src": fetch_url,
|
|
343
|
+
"job_id": job.id,
|
|
344
|
+
"status": job.status,
|
|
345
|
+
},
|
|
346
|
+
status_code=202,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
@app.delete("/catalog/entries", status_code=204)
|
|
350
|
+
def delete_catalog_entry(
|
|
351
|
+
request: Request,
|
|
352
|
+
name: str | None = None,
|
|
353
|
+
_auth: None = Depends(_bearer_or_session_authed),
|
|
354
|
+
) -> Response:
|
|
355
|
+
"""Delete via ``?name=<name>`` query param (URL-safe;
|
|
356
|
+
avoids path-encoding TOML entry names).
|
|
357
|
+
|
|
358
|
+
204 on success. 404 when no entry with that name exists.
|
|
359
|
+
"""
|
|
360
|
+
if not name or not name.strip():
|
|
361
|
+
raise HTTPException(status_code=400, detail="name: query param required")
|
|
362
|
+
cs: CatalogState = request.app.state.catalog
|
|
363
|
+
original = list(cs.entries)
|
|
364
|
+
cs.entries = [e for e in cs.entries if e.get("name") != name]
|
|
365
|
+
if len(cs.entries) == len(original):
|
|
366
|
+
raise HTTPException(
|
|
367
|
+
status_code=404,
|
|
368
|
+
detail=f"no catalog entry with name={name!r}",
|
|
369
|
+
)
|
|
370
|
+
_persist_catalog(cs)
|
|
371
|
+
return Response(status_code=204)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _persist_catalog(cs: CatalogState) -> None:
|
|
375
|
+
"""Write the current in-memory catalog back to
|
|
376
|
+
``<data_dir>/catalog.toml`` (when ``persist_path`` is set).
|
|
377
|
+
|
|
378
|
+
Silent no-op when ``persist_path`` is ``None`` -- tests inject
|
|
379
|
+
a stub CatalogState without disk backing and still exercise
|
|
380
|
+
the in-memory mutation."""
|
|
381
|
+
if cs.persist_path is None:
|
|
382
|
+
return
|
|
383
|
+
from pathlib import Path
|
|
384
|
+
|
|
385
|
+
Path(cs.persist_path).parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
Path(cs.persist_path).write_bytes(_serialise_catalog(cs.entries))
|