withcache 0.8.9__tar.gz → 0.9.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 (45) hide show
  1. {withcache-0.8.9 → withcache-0.9.0}/PKG-INFO +6 -1
  2. {withcache-0.8.9 → withcache-0.9.0}/pyproject.toml +24 -1
  3. {withcache-0.8.9 → withcache-0.9.0}/shim/build.zig.zon +1 -1
  4. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/__init__.py +7 -3
  5. withcache-0.9.0/src/withcache/_api.py +182 -0
  6. withcache-0.9.0/src/withcache/_app.py +503 -0
  7. withcache-0.9.0/src/withcache/_settings_store.py +100 -0
  8. withcache-0.9.0/src/withcache/_templates/ui/_layout.html +251 -0
  9. withcache-0.9.0/src/withcache/_templates/ui/cached.html +78 -0
  10. withcache-0.9.0/src/withcache/_templates/ui/catalog.html +162 -0
  11. withcache-0.9.0/src/withcache/_templates/ui/downloads.html +100 -0
  12. withcache-0.9.0/src/withcache/_templates/ui/login.html +30 -0
  13. withcache-0.9.0/src/withcache/_templates/ui/misses.html +67 -0
  14. withcache-0.9.0/src/withcache/_templates/ui/settings.html +277 -0
  15. withcache-0.9.0/src/withcache/server.py +1058 -0
  16. withcache-0.9.0/tests/test_fastapi_admin_forms.py +250 -0
  17. withcache-0.9.0/tests/test_fastapi_blob.py +225 -0
  18. withcache-0.9.0/tests/test_fastapi_scaffold.py +120 -0
  19. withcache-0.9.0/tests/test_fastapi_settings_persistence.py +250 -0
  20. withcache-0.9.0/tests/test_fastapi_ui_pages.py +181 -0
  21. withcache-0.9.0/tests/test_fastapi_uvicorn_smoke.py +126 -0
  22. {withcache-0.8.9 → withcache-0.9.0}/tests/test_withcache.py +165 -538
  23. withcache-0.8.9/src/withcache/server.py +0 -2286
  24. {withcache-0.8.9 → withcache-0.9.0}/.gitignore +0 -0
  25. {withcache-0.8.9 → withcache-0.9.0}/LICENSE +0 -0
  26. {withcache-0.8.9 → withcache-0.9.0}/README.md +0 -0
  27. {withcache-0.8.9 → withcache-0.9.0}/deploy/Containerfile +0 -0
  28. {withcache-0.8.9 → withcache-0.9.0}/deploy/compose.local-build.yml +0 -0
  29. {withcache-0.8.9 → withcache-0.9.0}/deploy/compose.yml +0 -0
  30. {withcache-0.8.9 → withcache-0.9.0}/deploy/envvars.example +0 -0
  31. {withcache-0.8.9 → withcache-0.9.0}/hatch_build.py +0 -0
  32. {withcache-0.8.9 → withcache-0.9.0}/shim/build.zig +0 -0
  33. {withcache-0.8.9 → withcache-0.9.0}/shim/shim.zig +0 -0
  34. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/_shim.py +0 -0
  35. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/client.py +0 -0
  36. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/curlwithcache.py +0 -0
  37. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/oras.py +0 -0
  38. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  39. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/static/bootstrap.min.css +0 -0
  40. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  41. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  42. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/static/htmx.min.js +0 -0
  43. {withcache-0.8.9 → withcache-0.9.0}/src/withcache/wgetwithcache.py +0 -0
  44. {withcache-0.8.9 → withcache-0.9.0}/tests/test_differential.py +0 -0
  45. {withcache-0.8.9 → withcache-0.9.0}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.9
3
+ Version: 0.9.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>
@@ -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.9",
5
+ .version = "0.9.0",
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.9"
24
+ __version__ = "0.9.0"
21
25
 
22
26
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -0,0 +1,182 @@
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. The pre-port stdlib
26
+ handler chunked via ``self.wfile.write`` in a loop; the port
27
+ preserves the same 64 KiB read size + the
28
+ :class:`StreamRegistry` progress ticks so the operator dashboard
29
+ shows the same numbers.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import urllib.parse
36
+ from collections.abc import Iterator
37
+
38
+ from fastapi import FastAPI, Request
39
+ from fastapi.responses import PlainTextResponse, Response, StreamingResponse
40
+
41
+ from .server import CHUNK, StreamRegistry, _oras_tag_moved
42
+
43
+
44
+ def _decode_blob_origin(path: str, query: str) -> str:
45
+ """Extract the source URL from either shape.
46
+
47
+ ``/blob?url=<origin>``: read ``url`` query param.
48
+ ``/b/<b64>/<name>``: base64-decode the first path segment. The
49
+ trailing ``<name>`` is decorative -- a client that derives
50
+ format from URL filename (e.g. bty's flash chain) gets the
51
+ right extension without the b64 token having to include it.
52
+ """
53
+ if path.startswith("/b/"):
54
+ token = path[len("/b/") :].split("/", 1)[0]
55
+ try:
56
+ return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode("utf-8")
57
+ except (ValueError, UnicodeDecodeError):
58
+ return ""
59
+ return (urllib.parse.parse_qs(query).get("url") or [""])[0]
60
+
61
+
62
+ def _serve_blob(
63
+ request: Request,
64
+ url: str,
65
+ head_only: bool,
66
+ ) -> Response:
67
+ """Shared implementation for GET and HEAD blob requests.
68
+
69
+ Returns 400 when the origin URL couldn't be decoded, 404 (with
70
+ miss recording + optional fetch enqueue) on cache miss, or 200
71
+ + a StreamingResponse on hit. Same status codes and body shapes
72
+ the pre-port stdlib handler emitted.
73
+ """
74
+ if not url:
75
+ return PlainTextResponse("missing url\n", status_code=400)
76
+
77
+ store = request.app.state.store
78
+ mgr = request.app.state.mgr
79
+ auto_fetch: bool = request.app.state.auto_fetch
80
+ streams: StreamRegistry = request.app.state.streams
81
+
82
+ row = store.get_blob(url)
83
+ if row is not None and _oras_tag_moved(url, row["sha256"]):
84
+ # Tag re-pushed since we cached it: drop the stale bytes so
85
+ # the miss branch below re-fetches the current content.
86
+ # Same shape as the pre-port handler.
87
+ store.delete_blob(row["key"])
88
+ row = None
89
+
90
+ if row is None:
91
+ store.record_miss(url)
92
+ if auto_fetch and store.has_capacity():
93
+ # Forward the client's Authorization header onto the
94
+ # worker so a token-gated origin (fresh OCI bearer from
95
+ # bty at catalog import time) can be fetched. Narrow
96
+ # allowlist: Authorization only; /admin/fetch carries
97
+ # its own ``headers=`` payload for the curated path.
98
+ fwd_headers: dict[str, str] | None = None
99
+ auth_header = request.headers.get("Authorization")
100
+ if auth_header:
101
+ fwd_headers = {"Authorization": auth_header}
102
+ mgr.enqueue(url, headers=fwd_headers)
103
+ return PlainTextResponse("cache miss (recorded)\n", status_code=404)
104
+
105
+ path = store.blob_path(row["key"])
106
+ headers = {
107
+ "Content-Length": str(row["size"]),
108
+ "X-Withcache-Sha256": row["sha256"],
109
+ }
110
+ media_type = row["content_type"] or "application/octet-stream"
111
+
112
+ if head_only:
113
+ # No body; the shim's HEAD probe doesn't count as a served
114
+ # download so record_hit + stream registration are skipped.
115
+ return Response(status_code=200, media_type=media_type, headers=headers)
116
+
117
+ store.record_hit(row["key"])
118
+ client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
119
+ stream = streams.start(url=url, client=client, total=row["size"])
120
+
121
+ def _chunks() -> Iterator[bytes]:
122
+ """Read the blob in 64 KiB chunks. StreamingResponse iterates
123
+ this + writes each chunk to the client. Progress ticks fire
124
+ every :data:`StreamRegistry.PROGRESS_STRIDE` chunks so the
125
+ dashboard's 1 Hz refresh sees updates without lock-contention
126
+ on a busy box."""
127
+ sent = 0
128
+ ticks = 0
129
+ try:
130
+ with open(path, "rb") as f:
131
+ while True:
132
+ chunk = f.read(CHUNK)
133
+ if not chunk:
134
+ break
135
+ yield chunk
136
+ sent += len(chunk)
137
+ ticks += 1
138
+ if ticks % StreamRegistry.PROGRESS_STRIDE == 0:
139
+ streams.bump(stream.id, sent)
140
+ streams.bump(stream.id, sent)
141
+ finally:
142
+ streams.finish(stream.id)
143
+
144
+ return StreamingResponse(_chunks(), media_type=media_type, headers=headers)
145
+
146
+
147
+ def register_api_routes(app: FastAPI) -> None:
148
+ """Attach the byte-serving routes to ``app``.
149
+
150
+ Runtime objects (``store``, ``mgr``, ``streams``, ``auto_fetch``)
151
+ are read from ``app.state`` at request time so tests + the
152
+ lifespan hook can inject fresh instances without rebuilding
153
+ the app.
154
+ """
155
+
156
+ @app.get("/blob", response_class=Response)
157
+ def blob_get_legacy(request: Request) -> Response:
158
+ """Legacy ``?url=<origin>`` shape. Kept for downstream
159
+ consumers that pinned this before the ``/b/<b64>/<name>``
160
+ canonical URL landed."""
161
+ url = _decode_blob_origin("/blob", request.url.query)
162
+ return _serve_blob(request, url, head_only=False)
163
+
164
+ @app.head("/blob")
165
+ def blob_head_legacy(request: Request) -> Response:
166
+ url = _decode_blob_origin("/blob", request.url.query)
167
+ return _serve_blob(request, url, head_only=True)
168
+
169
+ @app.get("/b/{token}/{name:path}", response_class=Response)
170
+ def blob_get_b64(token: str, name: str, request: Request) -> Response:
171
+ """Canonical ``/b/<b64>/<name>`` shape. bty's flash chain
172
+ builds these URLs; the ``<name>`` segment is decorative so
173
+ the client derives extension for downstream format probes."""
174
+ del name # decorative segment; source of truth is the b64 token
175
+ url = _decode_blob_origin(f"/b/{token}/x", "")
176
+ return _serve_blob(request, url, head_only=False)
177
+
178
+ @app.head("/b/{token}/{name:path}")
179
+ def blob_head_b64(token: str, name: str, request: Request) -> Response:
180
+ del name
181
+ url = _decode_blob_origin(f"/b/{token}/x", "")
182
+ return _serve_blob(request, url, head_only=True)