withcache 0.8.10__tar.gz → 0.9.1__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.8.10 → withcache-0.9.1}/PKG-INFO +6 -1
  2. {withcache-0.8.10 → withcache-0.9.1}/pyproject.toml +24 -1
  3. {withcache-0.8.10 → withcache-0.9.1}/shim/build.zig.zon +1 -1
  4. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/__init__.py +7 -3
  5. withcache-0.9.1/src/withcache/_api.py +330 -0
  6. withcache-0.9.1/src/withcache/_app.py +504 -0
  7. withcache-0.9.1/src/withcache/_settings_store.py +100 -0
  8. withcache-0.9.1/src/withcache/_templates/ui/_layout.html +251 -0
  9. withcache-0.9.1/src/withcache/_templates/ui/cached.html +78 -0
  10. withcache-0.9.1/src/withcache/_templates/ui/catalog.html +162 -0
  11. withcache-0.9.1/src/withcache/_templates/ui/downloads.html +100 -0
  12. withcache-0.9.1/src/withcache/_templates/ui/login.html +30 -0
  13. withcache-0.9.1/src/withcache/_templates/ui/misses.html +67 -0
  14. withcache-0.9.1/src/withcache/_templates/ui/settings.html +277 -0
  15. withcache-0.9.1/src/withcache/client.py +237 -0
  16. withcache-0.9.1/src/withcache/server.py +1069 -0
  17. withcache-0.9.1/tests/test_fastapi_admin_forms.py +250 -0
  18. withcache-0.9.1/tests/test_fastapi_blob.py +225 -0
  19. withcache-0.9.1/tests/test_fastapi_catalog_api.py +249 -0
  20. withcache-0.9.1/tests/test_fastapi_scaffold.py +116 -0
  21. withcache-0.9.1/tests/test_fastapi_settings_persistence.py +250 -0
  22. withcache-0.9.1/tests/test_fastapi_ui_pages.py +181 -0
  23. withcache-0.9.1/tests/test_fastapi_uvicorn_smoke.py +126 -0
  24. {withcache-0.8.10 → withcache-0.9.1}/tests/test_withcache.py +10 -605
  25. withcache-0.8.10/src/withcache/client.py +0 -89
  26. withcache-0.8.10/src/withcache/server.py +0 -2286
  27. {withcache-0.8.10 → withcache-0.9.1}/.gitignore +0 -0
  28. {withcache-0.8.10 → withcache-0.9.1}/LICENSE +0 -0
  29. {withcache-0.8.10 → withcache-0.9.1}/README.md +0 -0
  30. {withcache-0.8.10 → withcache-0.9.1}/deploy/Containerfile +0 -0
  31. {withcache-0.8.10 → withcache-0.9.1}/deploy/compose.local-build.yml +0 -0
  32. {withcache-0.8.10 → withcache-0.9.1}/deploy/compose.yml +0 -0
  33. {withcache-0.8.10 → withcache-0.9.1}/deploy/envvars.example +0 -0
  34. {withcache-0.8.10 → withcache-0.9.1}/hatch_build.py +0 -0
  35. {withcache-0.8.10 → withcache-0.9.1}/shim/build.zig +0 -0
  36. {withcache-0.8.10 → withcache-0.9.1}/shim/shim.zig +0 -0
  37. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/_shim.py +0 -0
  38. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/curlwithcache.py +0 -0
  39. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/oras.py +0 -0
  40. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
  41. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/bootstrap.min.css +0 -0
  42. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  43. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  44. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/htmx.min.js +0 -0
  45. {withcache-0.8.10 → withcache-0.9.1}/src/withcache/wgetwithcache.py +0 -0
  46. {withcache-0.8.10 → withcache-0.9.1}/tests/test_differential.py +0 -0
  47. {withcache-0.8.10 → withcache-0.9.1}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.10
3
+ Version: 0.9.1
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>
@@ -14,6 +14,11 @@ Classifier: License :: OSI Approved :: BSD License
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Topic :: System :: Archiving :: Mirroring
16
16
  Requires-Python: >=3.11
17
+ Requires-Dist: fastapi>=0.115
18
+ Requires-Dist: itsdangerous>=2.1
19
+ Requires-Dist: jinja2>=3.1
20
+ Requires-Dist: python-multipart>=0.0.9
21
+ Requires-Dist: uvicorn[standard]>=0.30
17
22
  Description-Content-Type: text/markdown
18
23
 
19
24
  # withcache
@@ -19,7 +19,30 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3",
20
20
  "Topic :: System :: Archiving :: Mirroring",
21
21
  ]
22
- dependencies = [] # stdlib only, by design
22
+ dependencies = [
23
+ # v0.9.0 shipped the FastAPI port: the daemon UI + control plane
24
+ # moved off stdlib http.server onto the same stack bty-web and
25
+ # nbdmux use. Keeps the trio's testing shape uniform (FastAPI
26
+ # TestClient across all three) and makes the shared chrome +
27
+ # settings-store patterns extractable into a trio-common lib.
28
+ # ``withcache.client`` / ``withcache.oras`` stay stdlib-only so
29
+ # downstream consumers (bty) don't inherit the framework floor.
30
+ "fastapi>=0.115",
31
+ "uvicorn[standard]>=0.30",
32
+ "jinja2>=3.1",
33
+ # Form-encoded POST parsing behind ``Form()`` deps.
34
+ "python-multipart>=0.0.9",
35
+ # Session cookie signing (SessionMiddleware).
36
+ "itsdangerous>=2.1",
37
+ ]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest>=8",
42
+ "ruff>=0.6",
43
+ # FastAPI TestClient uses httpx as its transport.
44
+ "httpx>=0.27",
45
+ ]
23
46
 
24
47
  [project.urls]
25
48
  Homepage = "https://github.com/safl/withcache"
@@ -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.8.10",
5
+ .version = "0.9.1",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -1,4 +1,4 @@
1
- """withcache operator-curated, URL-keyed artifact cache for a small lab.
1
+ """withcache, operator-curated URL-keyed artifact cache for a small lab.
2
2
 
3
3
  - ``withcache-server`` (withcache.server:main): the cache-host.
4
4
  - ``curlwithcache`` / ``wgetwithcache``: transparent curl/wget shims, shipped
@@ -11,12 +11,16 @@
11
11
  uses it on a cold miss; library consumers (e.g. ``bty``) import it to
12
12
  validate catalog entries and pre-resolve content digests.
13
13
 
14
- All modules are stdlib-only and self-contained.
14
+ Since v0.9.0 the daemon runs on FastAPI + Jinja + Bootstrap 5 + htmx
15
+ (matching bty-web + nbdmux, the eventual ``trio-common`` extraction
16
+ target); ``withcache.client``, ``withcache.oras``, and the shim
17
+ launchers stay stdlib-only so downstream consumers (bty) don't
18
+ inherit the framework floor.
15
19
  """
16
20
 
17
21
  from . import oras
18
22
  from .client import blob_url, cache_base, is_cached, serve_url
19
23
 
20
- __version__ = "0.8.10"
24
+ __version__ = "0.9.1"
21
25
 
22
26
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -0,0 +1,330 @@
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 (with
69
+ miss recording + optional fetch enqueue) on cache miss, or 200
70
+ + a StreamingResponse on hit.
71
+ """
72
+ if not url:
73
+ return PlainTextResponse("missing url\n", status_code=400)
74
+
75
+ store = request.app.state.store
76
+ mgr = request.app.state.mgr
77
+ auto_fetch: bool = request.app.state.auto_fetch
78
+ streams: StreamRegistry = request.app.state.streams
79
+
80
+ row = store.get_blob(url)
81
+ if row is not None and _oras_tag_moved(url, row["sha256"]):
82
+ # Tag re-pushed since we cached it: drop the stale bytes so
83
+ # the miss branch below re-fetches the current content.
84
+ store.delete_blob(row["key"])
85
+ row = None
86
+
87
+ if row is None:
88
+ store.record_miss(url)
89
+ if auto_fetch and store.has_capacity():
90
+ # Forward the client's Authorization header onto the
91
+ # worker so a token-gated origin (fresh OCI bearer from
92
+ # bty at catalog import time) can be fetched. Narrow
93
+ # allowlist: Authorization only; /admin/fetch carries
94
+ # its own ``headers=`` payload for the curated path.
95
+ fwd_headers: dict[str, str] | None = None
96
+ auth_header = request.headers.get("Authorization")
97
+ if auth_header:
98
+ fwd_headers = {"Authorization": auth_header}
99
+ mgr.enqueue(url, headers=fwd_headers)
100
+ return PlainTextResponse("cache miss (recorded)\n", status_code=404)
101
+
102
+ path = store.blob_path(row["key"])
103
+ headers = {
104
+ "Content-Length": str(row["size"]),
105
+ "X-Withcache-Sha256": row["sha256"],
106
+ }
107
+ media_type = row["content_type"] or "application/octet-stream"
108
+
109
+ if head_only:
110
+ # No body; the shim's HEAD probe doesn't count as a served
111
+ # download so record_hit + stream registration are skipped.
112
+ return Response(status_code=200, media_type=media_type, headers=headers)
113
+
114
+ store.record_hit(row["key"])
115
+ client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
116
+ stream = streams.start(url=url, client=client, total=row["size"])
117
+
118
+ def _chunks() -> Iterator[bytes]:
119
+ """Read the blob in 64 KiB chunks. StreamingResponse iterates
120
+ this + writes each chunk to the client. Progress ticks fire
121
+ every :data:`StreamRegistry.PROGRESS_STRIDE` chunks so the
122
+ dashboard's 1 Hz refresh sees updates without lock-contention
123
+ on a busy box."""
124
+ sent = 0
125
+ ticks = 0
126
+ try:
127
+ with open(path, "rb") as f:
128
+ while True:
129
+ chunk = f.read(CHUNK)
130
+ if not chunk:
131
+ break
132
+ yield chunk
133
+ sent += len(chunk)
134
+ ticks += 1
135
+ if ticks % StreamRegistry.PROGRESS_STRIDE == 0:
136
+ streams.bump(stream.id, sent)
137
+ streams.bump(stream.id, sent)
138
+ finally:
139
+ streams.finish(stream.id)
140
+
141
+ return StreamingResponse(_chunks(), media_type=media_type, headers=headers)
142
+
143
+
144
+ def register_api_routes(app: FastAPI) -> None:
145
+ """Attach the byte-serving routes to ``app``.
146
+
147
+ Runtime objects (``store``, ``mgr``, ``streams``, ``auto_fetch``)
148
+ are read from ``app.state`` at request time so tests + the
149
+ lifespan hook can inject fresh instances without rebuilding
150
+ the app.
151
+ """
152
+
153
+ @app.get("/blob", response_class=Response)
154
+ def blob_get_legacy(request: Request) -> Response:
155
+ """Legacy ``?url=<origin>`` shape. Kept for downstream
156
+ consumers that pinned this before the ``/b/<b64>/<name>``
157
+ canonical URL landed."""
158
+ url = _decode_blob_origin("/blob", request.url.query)
159
+ return _serve_blob(request, url, head_only=False)
160
+
161
+ @app.head("/blob")
162
+ def blob_head_legacy(request: Request) -> Response:
163
+ url = _decode_blob_origin("/blob", request.url.query)
164
+ return _serve_blob(request, url, head_only=True)
165
+
166
+ @app.get("/b/{token}/{name:path}", response_class=Response)
167
+ def blob_get_b64(token: str, name: str, request: Request) -> Response:
168
+ """Canonical ``/b/<b64>/<name>`` shape. bty's flash chain
169
+ builds these URLs; the ``<name>`` segment is decorative so
170
+ the client derives extension for downstream format probes."""
171
+ del name # decorative segment; source of truth is the b64 token
172
+ url = _decode_blob_origin(f"/b/{token}/x", "")
173
+ return _serve_blob(request, url, head_only=False)
174
+
175
+ @app.head("/b/{token}/{name:path}")
176
+ def blob_head_b64(token: str, name: str, request: Request) -> Response:
177
+ del name
178
+ url = _decode_blob_origin(f"/b/{token}/x", "")
179
+ return _serve_blob(request, url, head_only=True)
180
+
181
+ # ---------- Catalog JSON API -----------------------------------------
182
+ #
183
+ # Bty consumes the catalog through these endpoints: withcache is the
184
+ # single source of truth for what images exist. Read is open (mirrors
185
+ # /blob's LAN-only trust model); writes gate on the same Bearer
186
+ # pattern nbdmux uses -- an ``Authorization: Bearer <pw>`` header
187
+ # whose value matches ``$WITHCACHE_ADMIN_PASSWORD``, or a session
188
+ # cookie for browser-based operators.
189
+
190
+ def _bearer_or_session_authed(request: Request) -> None:
191
+ """Auth dependency for catalog writes.
192
+
193
+ - No password configured -> open (single-tenant LAN deploy).
194
+ - Session cookie present -> OK (browser path).
195
+ - Matching Bearer token -> OK (service-to-service path;
196
+ bty reads ``$WITHCACHE_ADMIN_PASSWORD`` and posts it).
197
+ - Otherwise -> 401.
198
+ """
199
+ auth = request.app.state.auth
200
+ if not auth.enabled:
201
+ return
202
+ # Session cookie check -- Starlette's SessionMiddleware
203
+ # stores the flag under ``withcache_authed`` in
204
+ # ``request.session``. Mirrors the flag ``_app.py`` sets.
205
+ if request.session.get("withcache_authed"):
206
+ return
207
+ header = request.headers.get("Authorization") or ""
208
+ if header.startswith("Bearer ") and auth.check_bearer(header[len("Bearer ") :]):
209
+ return
210
+ raise HTTPException(status_code=401, detail="auth required")
211
+
212
+ @app.get("/catalog")
213
+ def list_catalog(request: Request) -> JSONResponse:
214
+ """Return the current catalog as JSON.
215
+
216
+ Open route: bty polls this from a sibling container without a
217
+ session (mirrors the ``/blob`` byte-serving surface's trust
218
+ model -- LAN-only, no auth on reads). Returns a snapshot of
219
+ the in-process ``CatalogState.entries`` plus provenance
220
+ metadata (URL, env pin, fetch timestamps) so clients can
221
+ detect staleness. No pagination -- the catalog is small
222
+ (dozens of entries at most).
223
+ """
224
+ cs: CatalogState = request.app.state.catalog
225
+ return JSONResponse(
226
+ {
227
+ "url": cs.url,
228
+ "env_url": cs.env_url,
229
+ "fetched_at": cs.fetched_at,
230
+ "last_error": cs.last_error,
231
+ "entries": [dict(e) for e in cs.entries],
232
+ }
233
+ )
234
+
235
+ @app.post("/catalog/entries", status_code=201)
236
+ def add_catalog_entry(
237
+ body: dict[str, Any],
238
+ request: Request,
239
+ _auth: None = Depends(_bearer_or_session_authed),
240
+ ) -> JSONResponse:
241
+ """Insert one catalog entry.
242
+
243
+ Body: ``{"name": "...", "src": "...", "format?", "arch?",
244
+ "sha256?", "size_bytes?", "resolved_src?", "description?"}``.
245
+ Only ``src`` is required; every other field is optional and
246
+ gets persisted through the ``catalog.toml`` round-trip.
247
+
248
+ 409 when an entry with the same ``name`` already exists;
249
+ rejecting on duplicate name (not src) because the display
250
+ surface keys on name and two rows with the same name would
251
+ collide on the /ui/catalog table.
252
+
253
+ Rejects any unknown top-level key -- the emitter allowlists
254
+ keys anyway, but failing loud at the API boundary saves the
255
+ operator from wondering why their ``notes`` field vanished.
256
+ """
257
+ allowed = {
258
+ "name",
259
+ "src",
260
+ "resolved_src",
261
+ "format",
262
+ "arch",
263
+ "sha256",
264
+ "size_bytes",
265
+ "description",
266
+ }
267
+ if not isinstance(body, dict):
268
+ raise HTTPException(status_code=400, detail="body must be a JSON object")
269
+ unknown = set(body.keys()) - allowed
270
+ if unknown:
271
+ raise HTTPException(
272
+ status_code=400,
273
+ detail=f"unknown key(s) {sorted(unknown)}; allowed: {sorted(allowed)}",
274
+ )
275
+ name = body.get("name")
276
+ src = body.get("src")
277
+ if not isinstance(name, str) or not name.strip():
278
+ raise HTTPException(status_code=400, detail="name: non-empty string required")
279
+ if not isinstance(src, str) or not src.strip():
280
+ raise HTTPException(status_code=400, detail="src: non-empty string required")
281
+
282
+ cs: CatalogState = request.app.state.catalog
283
+ if any(e.get("name") == name for e in cs.entries):
284
+ raise HTTPException(
285
+ status_code=409,
286
+ detail=f"catalog entry with name={name!r} already exists",
287
+ )
288
+ entry = {k: v for k, v in body.items() if v is not None and v != ""}
289
+ cs.entries.append(entry)
290
+ _persist_catalog(cs)
291
+ return JSONResponse(entry, status_code=201)
292
+
293
+ @app.delete("/catalog/entries", status_code=204)
294
+ def delete_catalog_entry(
295
+ request: Request,
296
+ name: str | None = None,
297
+ _auth: None = Depends(_bearer_or_session_authed),
298
+ ) -> Response:
299
+ """Delete via ``?name=<name>`` query param (URL-safe;
300
+ avoids path-encoding TOML entry names).
301
+
302
+ 204 on success. 404 when no entry with that name exists.
303
+ """
304
+ if not name or not name.strip():
305
+ raise HTTPException(status_code=400, detail="name: query param required")
306
+ cs: CatalogState = request.app.state.catalog
307
+ original = list(cs.entries)
308
+ cs.entries = [e for e in cs.entries if e.get("name") != name]
309
+ if len(cs.entries) == len(original):
310
+ raise HTTPException(
311
+ status_code=404,
312
+ detail=f"no catalog entry with name={name!r}",
313
+ )
314
+ _persist_catalog(cs)
315
+ return Response(status_code=204)
316
+
317
+
318
+ def _persist_catalog(cs: CatalogState) -> None:
319
+ """Write the current in-memory catalog back to
320
+ ``<data_dir>/catalog.toml`` (when ``persist_path`` is set).
321
+
322
+ Silent no-op when ``persist_path`` is ``None`` -- tests inject
323
+ a stub CatalogState without disk backing and still exercise
324
+ the in-memory mutation."""
325
+ if cs.persist_path is None:
326
+ return
327
+ from pathlib import Path
328
+
329
+ Path(cs.persist_path).parent.mkdir(parents=True, exist_ok=True)
330
+ Path(cs.persist_path).write_bytes(_serialise_catalog(cs.entries))