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.
Files changed (47) hide show
  1. {withcache-0.9.0 → withcache-0.10.0}/PKG-INFO +1 -1
  2. {withcache-0.9.0 → withcache-0.10.0}/shim/build.zig.zon +1 -1
  3. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/__init__.py +1 -1
  4. withcache-0.10.0/src/withcache/_api.py +386 -0
  5. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_app.py +144 -39
  6. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_settings_store.py +4 -4
  7. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/cached.html +2 -2
  8. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/catalog.html +115 -8
  9. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/misses.html +2 -2
  10. withcache-0.10.0/src/withcache/client.py +266 -0
  11. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/server.py +30 -24
  12. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_admin_forms.py +2 -2
  13. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_blob.py +18 -43
  14. withcache-0.10.0/tests/test_fastapi_catalog_api.py +316 -0
  15. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_scaffold.py +2 -6
  16. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_settings_persistence.py +2 -2
  17. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_ui_pages.py +2 -2
  18. {withcache-0.9.0 → withcache-0.10.0}/tests/test_withcache.py +9 -26
  19. withcache-0.9.0/src/withcache/_api.py +0 -182
  20. withcache-0.9.0/src/withcache/client.py +0 -89
  21. {withcache-0.9.0 → withcache-0.10.0}/.gitignore +0 -0
  22. {withcache-0.9.0 → withcache-0.10.0}/LICENSE +0 -0
  23. {withcache-0.9.0 → withcache-0.10.0}/README.md +0 -0
  24. {withcache-0.9.0 → withcache-0.10.0}/deploy/Containerfile +0 -0
  25. {withcache-0.9.0 → withcache-0.10.0}/deploy/compose.local-build.yml +0 -0
  26. {withcache-0.9.0 → withcache-0.10.0}/deploy/compose.yml +0 -0
  27. {withcache-0.9.0 → withcache-0.10.0}/deploy/envvars.example +0 -0
  28. {withcache-0.9.0 → withcache-0.10.0}/hatch_build.py +0 -0
  29. {withcache-0.9.0 → withcache-0.10.0}/pyproject.toml +0 -0
  30. {withcache-0.9.0 → withcache-0.10.0}/shim/build.zig +0 -0
  31. {withcache-0.9.0 → withcache-0.10.0}/shim/shim.zig +0 -0
  32. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_shim.py +0 -0
  33. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/_layout.html +0 -0
  34. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/downloads.html +0 -0
  35. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/login.html +0 -0
  36. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/_templates/ui/settings.html +0 -0
  37. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/curlwithcache.py +0 -0
  38. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/oras.py +0 -0
  39. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  40. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/bootstrap.min.css +0 -0
  41. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  42. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  43. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/static/htmx.min.js +0 -0
  44. {withcache-0.9.0 → withcache-0.10.0}/src/withcache/wgetwithcache.py +0 -0
  45. {withcache-0.9.0 → withcache-0.10.0}/tests/test_differential.py +0 -0
  46. {withcache-0.9.0 → withcache-0.10.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  47. {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.9.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.9.0",
5
+ .version = "0.10.0",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -21,6 +21,6 @@ inherit the framework floor.
21
21
  from . import oras
22
22
  from .client import blob_url, cache_base, is_cached, serve_url
23
23
 
24
- __version__ = "0.9.0"
24
+ __version__ = "0.10.0"
25
25
 
26
26
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -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))