withcache 0.8.10__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.
- {withcache-0.8.10 → withcache-0.9.0}/PKG-INFO +6 -1
- {withcache-0.8.10 → withcache-0.9.0}/pyproject.toml +24 -1
- {withcache-0.8.10 → withcache-0.9.0}/shim/build.zig.zon +1 -1
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/__init__.py +7 -3
- withcache-0.9.0/src/withcache/_api.py +182 -0
- withcache-0.9.0/src/withcache/_app.py +503 -0
- withcache-0.9.0/src/withcache/_settings_store.py +100 -0
- withcache-0.9.0/src/withcache/_templates/ui/_layout.html +251 -0
- withcache-0.9.0/src/withcache/_templates/ui/cached.html +78 -0
- withcache-0.9.0/src/withcache/_templates/ui/catalog.html +162 -0
- withcache-0.9.0/src/withcache/_templates/ui/downloads.html +100 -0
- withcache-0.9.0/src/withcache/_templates/ui/login.html +30 -0
- withcache-0.9.0/src/withcache/_templates/ui/misses.html +67 -0
- withcache-0.9.0/src/withcache/_templates/ui/settings.html +277 -0
- withcache-0.9.0/src/withcache/server.py +1058 -0
- withcache-0.9.0/tests/test_fastapi_admin_forms.py +250 -0
- withcache-0.9.0/tests/test_fastapi_blob.py +225 -0
- withcache-0.9.0/tests/test_fastapi_scaffold.py +120 -0
- withcache-0.9.0/tests/test_fastapi_settings_persistence.py +250 -0
- withcache-0.9.0/tests/test_fastapi_ui_pages.py +181 -0
- withcache-0.9.0/tests/test_fastapi_uvicorn_smoke.py +126 -0
- {withcache-0.8.10 → withcache-0.9.0}/tests/test_withcache.py +1 -579
- withcache-0.8.10/src/withcache/server.py +0 -2286
- {withcache-0.8.10 → withcache-0.9.0}/.gitignore +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/LICENSE +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/README.md +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/deploy/Containerfile +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/deploy/compose.local-build.yml +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/deploy/compose.yml +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/deploy/envvars.example +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/hatch_build.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/shim/build.zig +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/shim/shim.zig +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/_shim.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/client.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/oras.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.8.10 → withcache-0.9.0}/tests/test_differential.py +0 -0
- {withcache-0.8.10 → 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.
|
|
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 = [
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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)
|