withcache 0.8.0__tar.gz → 0.8.2__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 (26) hide show
  1. {withcache-0.8.0 → withcache-0.8.2}/PKG-INFO +8 -6
  2. {withcache-0.8.0 → withcache-0.8.2}/README.md +7 -5
  3. {withcache-0.8.0 → withcache-0.8.2}/shim/build.zig.zon +1 -1
  4. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/__init__.py +1 -1
  5. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/server.py +16 -12
  6. {withcache-0.8.0 → withcache-0.8.2}/tests/test_withcache.py +176 -2
  7. {withcache-0.8.0 → withcache-0.8.2}/.gitignore +0 -0
  8. {withcache-0.8.0 → withcache-0.8.2}/LICENSE +0 -0
  9. {withcache-0.8.0 → withcache-0.8.2}/deploy/Containerfile +0 -0
  10. {withcache-0.8.0 → withcache-0.8.2}/deploy/compose.yml +0 -0
  11. {withcache-0.8.0 → withcache-0.8.2}/hatch_build.py +0 -0
  12. {withcache-0.8.0 → withcache-0.8.2}/pyproject.toml +0 -0
  13. {withcache-0.8.0 → withcache-0.8.2}/shim/build.zig +0 -0
  14. {withcache-0.8.0 → withcache-0.8.2}/shim/shim.zig +0 -0
  15. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/_shim.py +0 -0
  16. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/client.py +0 -0
  17. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/oras.py +0 -0
  19. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
  20. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/static/bootstrap.min.css +0 -0
  21. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  22. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  23. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.8.0 → withcache-0.8.2}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.8.0 → withcache-0.8.2}/tests/test_differential.py +0 -0
  26. {withcache-0.8.0 → withcache-0.8.2}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.0
3
+ Version: 0.8.2
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>
@@ -61,7 +61,7 @@ optional **operator-curated** model (`--curate`: a miss queue a human approves).
61
61
 
62
62
  | Path | What it is |
63
63
  |-------------------------------|-------------------------------------------------------------|
64
- | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Pico.css + HTMX) |
64
+ | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Bootstrap 5 + Bootstrap Icons + HTMX) |
65
65
  | `src/withcache/_shim.py` | Shared shim core (find URL → probe → rewrite → exec) |
66
66
  | `src/withcache/curlwithcache.py` / `wgetwithcache.py` | The Python `curl` / `wget` shims |
67
67
  | `shim/shim.zig` | The native shim: one static binary, both tools via `argv[0]` |
@@ -237,11 +237,13 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
237
237
 
238
238
  ## Operator UI
239
239
 
240
- `http://withcache-server:8081/` (Pico.css + HTMX, bundled offline) shows:
240
+ `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
241
+ offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
242
+ - **Cached** (landing): URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
243
+ - **Streams**: in-flight stream-through-and-store fetches serving bytes to a client while writing to disk.
244
+ - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers. The subnav Fetch form pre-seeds an artifact before anyone misses it.
241
245
  - **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
242
- - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
243
- - **Cached artifacts**: URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
244
- - **Add from URI**: pre-seed an artifact before anyone misses it.
246
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" input.
245
247
 
246
248
  ## Auth
247
249
 
@@ -43,7 +43,7 @@ optional **operator-curated** model (`--curate`: a miss queue a human approves).
43
43
 
44
44
  | Path | What it is |
45
45
  |-------------------------------|-------------------------------------------------------------|
46
- | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Pico.css + HTMX) |
46
+ | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Bootstrap 5 + Bootstrap Icons + HTMX) |
47
47
  | `src/withcache/_shim.py` | Shared shim core (find URL → probe → rewrite → exec) |
48
48
  | `src/withcache/curlwithcache.py` / `wgetwithcache.py` | The Python `curl` / `wget` shims |
49
49
  | `shim/shim.zig` | The native shim: one static binary, both tools via `argv[0]` |
@@ -219,11 +219,13 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
219
219
 
220
220
  ## Operator UI
221
221
 
222
- `http://withcache-server:8081/` (Pico.css + HTMX, bundled offline) shows:
222
+ `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
223
+ offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
224
+ - **Cached** (landing): URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
225
+ - **Streams**: in-flight stream-through-and-store fetches serving bytes to a client while writing to disk.
226
+ - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers. The subnav Fetch form pre-seeds an artifact before anyone misses it.
223
227
  - **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
224
- - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
225
- - **Cached artifacts**: URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
226
- - **Add from URI**: pre-seed an artifact before anyone misses it.
228
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" input.
227
229
 
228
230
  ## Auth
229
231
 
@@ -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.0",
5
+ .version = "0.8.2",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -17,6 +17,6 @@ All modules are stdlib-only and self-contained.
17
17
  from . import oras
18
18
  from .client import blob_url, cache_base, is_cached, serve_url
19
19
 
20
- __version__ = "0.8.0"
20
+ __version__ = "0.8.2"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -6,7 +6,7 @@ their origin URL. By default a cache miss is auto-fetched: it is recorded in the
6
6
  miss table and pulled from origin in the background, so the next request hits
7
7
  (the client falls through to origin on the first miss). Run with `--curate` to
8
8
  require an operator to approve each pull via a small web UI instead; either way
9
- you can pre-seed an artifact with the "Add from URI" form.
9
+ you can pre-seed an artifact with the Downloads-page Fetch form.
10
10
 
11
11
  This is the only component that needs internet egress (and any vendor creds).
12
12
  Clients never write to it.
@@ -180,17 +180,18 @@ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
180
180
  class CatalogState:
181
181
  """Live state of the fetched image catalog.
182
182
 
183
- withcache does not run a background poller. The catalog is
184
- fetched at process start (best-effort, persisted result from
185
- the last successful fetch survives a restart), refetched
186
- lazily when the dashboard renders and the in-memory copy is
187
- older than :data:`CATALOG_CACHE_TTL`, and force-refetched by
188
- the operator via the Refresh button (POST /admin/catalog_refresh).
183
+ withcache does not run a background poller and does not refetch
184
+ on a TTL. The catalog is fetched at process start (best-effort;
185
+ a persisted result from the last successful fetch survives a
186
+ restart) and force-refetched by the operator via the Refresh
187
+ button on the Catalog page (POST /admin/catalog_refresh); every
188
+ other render reads whatever is currently in memory.
189
189
 
190
190
  The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
191
- so a restart doesn't wipe the last known good catalog and the
192
- same file can be re-served verbatim to consumers on a future
193
- ``GET /catalog.toml`` route.
191
+ so a restart doesn't wipe the last known good catalog. There is
192
+ no HTTP route that serves the file back verbatim; consumers
193
+ resolve entries by name through the dashboard or by reading
194
+ ``entries`` in-process.
194
195
 
195
196
  ``env_url`` records the value pinned via ``$WITHCACHE_CATALOG_URL``
196
197
  (empty if unset). The operator can override the effective URL at
@@ -1690,13 +1691,16 @@ class Handler(http.server.BaseHTTPRequestHandler):
1690
1691
  subnav_html: str,
1691
1692
  body_html: str,
1692
1693
  ) -> str:
1694
+ brand_cls = "navbar-brand fw-semibold"
1695
+ if nav_active == "cached":
1696
+ brand_cls += " brand-active"
1693
1697
  return f"""{self._head(title)}
1694
1698
  <body class="bg-light">
1695
1699
  <div class="sticky-header">
1696
1700
  <div class="brand-accent"></div>
1697
1701
  <nav class="navbar navbar-expand-md bg-dark navbar-dark py-2">
1698
1702
  <div class="container">
1699
- <a class="navbar-brand fw-semibold" href="/ui/cached">
1703
+ <a class="{brand_cls}" href="/ui/cached">
1700
1704
  <i class="bi bi-database brand-icon me-1"></i>WITHCACHE
1701
1705
  </a>
1702
1706
  <div class="d-flex flex-grow-1 align-items-center flex-wrap">
@@ -1884,7 +1888,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1884
1888
  '<input class="form-control form-control-sm" type="url" name="url" '
1885
1889
  'style="width: 22rem;" '
1886
1890
  'placeholder="https://origin/path/artifact.tar.gz" required>'
1887
- '<button class="btn btn-sm btn-primary" type="submit" title="Add from URI"'
1891
+ '<button class="btn btn-sm btn-primary" type="submit" title="Fetch URI"'
1888
1892
  ">Fetch</button>"
1889
1893
  "</form>"
1890
1894
  )
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
22
22
 
23
23
  import base64
24
24
  import urllib.error
25
+ import urllib.parse
25
26
  import urllib.request
26
27
 
27
28
  from withcache import _shim, client, curlwithcache, server, wgetwithcache
@@ -1062,10 +1063,10 @@ class TestMultiPageNavigation(unittest.TestCase):
1062
1063
  self.assertIn(f'id="{key}-fragment"', body)
1063
1064
  self.assertIn('class="nav-btn active"', body)
1064
1065
 
1065
- def test_downloads_subnav_has_add_from_uri(self):
1066
+ def test_downloads_subnav_has_fetch_form(self):
1066
1067
  body = self._get("/ui/downloads")
1067
1068
  self.assertIn('hx-post="/admin/fetch"', body)
1068
- self.assertIn("Add from URI", body)
1069
+ self.assertIn(">Fetch</button>", body)
1069
1070
 
1070
1071
  def test_catalog_subnav_has_url_and_oras_forms(self):
1071
1072
  body = self._get("/ui/catalog")
@@ -1088,5 +1089,178 @@ class TestMultiPageNavigation(unittest.TestCase):
1088
1089
  self.assertNotIn("navbar-brand", body)
1089
1090
 
1090
1091
 
1092
+ # --------------------------------------------------------------------------
1093
+ # /admin/catalog_* -- the four operator-facing catalog control endpoints
1094
+ # --------------------------------------------------------------------------
1095
+ class _CatalogOrigin(http.server.BaseHTTPRequestHandler):
1096
+ """Serves a fixed valid catalog.toml to catalog_refresh tests."""
1097
+
1098
+ PAYLOAD = (
1099
+ b'[[images]]\nname = "demo"\nsrc = "https://example/demo.img.zst"\n'
1100
+ b'format = "img.zst"\n\n'
1101
+ b'[[images]]\nname = "other"\nsrc = "https://example/other.img.zst"\n'
1102
+ )
1103
+
1104
+ def do_GET(self):
1105
+ if self.path.endswith("/broken.toml"):
1106
+ self.send_response(200)
1107
+ self.send_header("Content-Length", "10")
1108
+ self.end_headers()
1109
+ self.wfile.write(b"not a toml")
1110
+ return
1111
+ self.send_response(200)
1112
+ self.send_header("Content-Type", "application/toml")
1113
+ self.send_header("Content-Length", str(len(self.PAYLOAD)))
1114
+ self.end_headers()
1115
+ self.wfile.write(self.PAYLOAD)
1116
+
1117
+ def log_message(self, format, *args):
1118
+ pass
1119
+
1120
+
1121
+ class TestCatalogAdminEndpoints(unittest.TestCase):
1122
+ """Cover the four catalog-admin endpoints
1123
+ (/admin/catalog_refresh, /admin/catalog_set_url,
1124
+ /admin/catalog_add_oras, /admin/catalog_delete_entry).
1125
+
1126
+ Only the shape assertions (form present + subnav layout) were
1127
+ covered before; the wire behaviour -- POST validates, updates
1128
+ state, and 303s back to the Catalog page -- had zero coverage.
1129
+ """
1130
+
1131
+ def setUp(self):
1132
+ self.origin = socketserver.TCPServer(("127.0.0.1", 0), _CatalogOrigin)
1133
+ threading.Thread(target=self.origin.serve_forever, daemon=True).start()
1134
+ origin_port = self.origin.server_address[1]
1135
+ self.catalog_url = f"http://127.0.0.1:{origin_port}/catalog.toml"
1136
+ self.httpd, self.store = _start_withcache()
1137
+ # Re-point the catalog at the local origin so /admin/catalog_refresh
1138
+ # hits something predictable.
1139
+ self.httpd.catalog.url = self.catalog_url
1140
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
1141
+
1142
+ def tearDown(self):
1143
+ self.origin.shutdown()
1144
+ self.origin.server_close()
1145
+ self.httpd.shutdown()
1146
+ self.httpd.server_close()
1147
+
1148
+ def _post(self, path, form):
1149
+ body = urllib.parse.urlencode(form).encode()
1150
+ req = urllib.request.Request(
1151
+ self.base + path,
1152
+ data=body,
1153
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
1154
+ method="POST",
1155
+ )
1156
+ return urllib.request.urlopen(req)
1157
+
1158
+ def test_catalog_refresh_populates_entries(self):
1159
+ self.assertEqual(self.httpd.catalog.entries, [])
1160
+ with self._post("/admin/catalog_refresh", {}) as r:
1161
+ self.assertEqual(r.status, 200)
1162
+ names = [e["name"] for e in self.httpd.catalog.entries]
1163
+ self.assertEqual(names, ["demo", "other"])
1164
+ self.assertEqual(self.httpd.catalog.last_error, "")
1165
+
1166
+ def test_catalog_refresh_bad_toml_keeps_previous_entries(self):
1167
+ # Seed the catalog with a known good entry, then re-point at
1168
+ # a broken TOML endpoint and refresh; entries must survive.
1169
+ self.httpd.catalog.entries = [{"name": "keeper", "src": "https://x/y"}]
1170
+ origin_port = self.origin.server_address[1]
1171
+ self.httpd.catalog.url = f"http://127.0.0.1:{origin_port}/broken.toml"
1172
+ with self._post("/admin/catalog_refresh", {}) as r:
1173
+ self.assertEqual(r.status, 200)
1174
+ names = [e["name"] for e in self.httpd.catalog.entries]
1175
+ self.assertEqual(names, ["keeper"])
1176
+ self.assertTrue(self.httpd.catalog.last_error)
1177
+
1178
+ def test_catalog_set_url_persists_override_and_fetches(self):
1179
+ # No env pin, no persist path -> the URL update takes effect
1180
+ # in-process and an immediate fetch fires.
1181
+ with self._post("/admin/catalog_set_url", {"url": self.catalog_url}) as r:
1182
+ self.assertEqual(r.status, 200)
1183
+ self.assertEqual(self.httpd.catalog.url, self.catalog_url)
1184
+ # fetch_now already ran -> entries populated.
1185
+ self.assertEqual([e["name"] for e in self.httpd.catalog.entries], ["demo", "other"])
1186
+
1187
+ def test_catalog_set_url_rejects_empty(self):
1188
+ with self._post("/admin/catalog_set_url", {"url": " "}) as r:
1189
+ self.assertEqual(r.status, 200)
1190
+ self.assertIn("empty", self.httpd.catalog.last_error)
1191
+
1192
+ def test_catalog_set_url_rejects_non_http_scheme(self):
1193
+ with self._post("/admin/catalog_set_url", {"url": "ftp://x/y"}) as r:
1194
+ self.assertEqual(r.status, 200)
1195
+ self.assertIn("http", self.httpd.catalog.last_error)
1196
+
1197
+ def test_catalog_set_url_rejects_when_env_pinned(self):
1198
+ self.httpd.catalog.env_url = "https://env-pinned/catalog.toml"
1199
+ pinned_url = self.httpd.catalog.url
1200
+ override_attempt = "https://different/catalog.toml"
1201
+ with self._post("/admin/catalog_set_url", {"url": override_attempt}) as r:
1202
+ self.assertEqual(r.status, 200)
1203
+ self.assertIn("pinned", self.httpd.catalog.last_error)
1204
+ # URL was not overridden -- stays at whatever it was before.
1205
+ self.assertEqual(self.httpd.catalog.url, pinned_url)
1206
+
1207
+ def test_catalog_add_oras_rejects_non_oras_url(self):
1208
+ with self._post("/admin/catalog_add_oras", {"url": "https://example/x"}) as r:
1209
+ self.assertEqual(r.status, 200)
1210
+ self.assertIn("oras://", self.httpd.catalog.last_error)
1211
+ self.assertEqual(self.httpd.catalog.entries, [])
1212
+
1213
+ def test_catalog_add_oras_registers_entry_even_when_layer_probe_fails(self):
1214
+ # A well-formed oras URL that will fail the layer-probe fetch
1215
+ # (unreachable registry). The entry still lands per the
1216
+ # add_oras_entry contract; last_info records the reason.
1217
+ oras_url = "oras://127.0.0.1:1/safl/nosi/demo:v1"
1218
+ with self._post("/admin/catalog_add_oras", {"url": oras_url}) as r:
1219
+ self.assertEqual(r.status, 200)
1220
+ entries = self.httpd.catalog.entries
1221
+ self.assertEqual(len(entries), 1, f"expected 1 entry, got {entries!r}")
1222
+ self.assertEqual(entries[0]["src"], oras_url)
1223
+ self.assertEqual(self.httpd.catalog.last_error, "")
1224
+
1225
+ def test_catalog_delete_entry_removes_matching_row(self):
1226
+ self.httpd.catalog.entries = [
1227
+ {"name": "a", "src": "https://x/a"},
1228
+ {"name": "b", "src": "https://x/b"},
1229
+ ]
1230
+ with self._post("/admin/catalog_delete_entry", {"name": "a"}) as r:
1231
+ self.assertEqual(r.status, 200)
1232
+ self.assertEqual([e["name"] for e in self.httpd.catalog.entries], ["b"])
1233
+
1234
+ def test_catalog_delete_entry_unknown_name_is_reported(self):
1235
+ self.httpd.catalog.entries = [{"name": "a", "src": "https://x/a"}]
1236
+ with self._post("/admin/catalog_delete_entry", {"name": "ghost"}) as r:
1237
+ self.assertEqual(r.status, 200)
1238
+ self.assertIn("ghost", self.httpd.catalog.last_error)
1239
+
1240
+ def test_admin_endpoints_require_auth_when_password_set(self):
1241
+ # Fresh httpd with auth enabled.
1242
+ httpd2, _ = _start_withcache()
1243
+ httpd2.auth = server.Auth(b"k", "letmein")
1244
+ base = f"http://127.0.0.1:{httpd2.server_address[1]}"
1245
+ for path in (
1246
+ "/admin/catalog_refresh",
1247
+ "/admin/catalog_set_url",
1248
+ "/admin/catalog_add_oras",
1249
+ "/admin/catalog_delete_entry",
1250
+ ):
1251
+ with self.subTest(path=path):
1252
+ req = urllib.request.Request(
1253
+ base + path,
1254
+ data=b"",
1255
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
1256
+ method="POST",
1257
+ )
1258
+ with self.assertRaises(urllib.error.HTTPError) as cm:
1259
+ urllib.request.urlopen(req)
1260
+ self.assertEqual(cm.exception.code, 401)
1261
+ httpd2.shutdown()
1262
+ httpd2.server_close()
1263
+
1264
+
1091
1265
  if __name__ == "__main__":
1092
1266
  unittest.main(verbosity=2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes