withcache 0.8.7__tar.gz → 0.8.9__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 (29) hide show
  1. {withcache-0.8.7 → withcache-0.8.9}/PKG-INFO +1 -1
  2. {withcache-0.8.7 → withcache-0.8.9}/deploy/Containerfile +3 -2
  3. withcache-0.8.9/deploy/compose.local-build.yml +11 -0
  4. withcache-0.8.9/deploy/compose.yml +23 -0
  5. withcache-0.8.9/deploy/envvars.example +17 -0
  6. {withcache-0.8.7 → withcache-0.8.9}/shim/build.zig.zon +1 -1
  7. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/__init__.py +1 -1
  8. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/server.py +33 -2
  9. {withcache-0.8.7 → withcache-0.8.9}/tests/test_withcache.py +89 -0
  10. withcache-0.8.7/deploy/compose.yml +0 -28
  11. {withcache-0.8.7 → withcache-0.8.9}/.gitignore +0 -0
  12. {withcache-0.8.7 → withcache-0.8.9}/LICENSE +0 -0
  13. {withcache-0.8.7 → withcache-0.8.9}/README.md +0 -0
  14. {withcache-0.8.7 → withcache-0.8.9}/hatch_build.py +0 -0
  15. {withcache-0.8.7 → withcache-0.8.9}/pyproject.toml +0 -0
  16. {withcache-0.8.7 → withcache-0.8.9}/shim/build.zig +0 -0
  17. {withcache-0.8.7 → withcache-0.8.9}/shim/shim.zig +0 -0
  18. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/_shim.py +0 -0
  19. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/client.py +0 -0
  20. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/curlwithcache.py +0 -0
  21. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/oras.py +0 -0
  22. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/static/bootstrap-icons.min.css +0 -0
  23. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/static/bootstrap.min.css +0 -0
  24. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  25. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  26. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/static/htmx.min.js +0 -0
  27. {withcache-0.8.7 → withcache-0.8.9}/src/withcache/wgetwithcache.py +0 -0
  28. {withcache-0.8.7 → withcache-0.8.9}/tests/test_differential.py +0 -0
  29. {withcache-0.8.7 → withcache-0.8.9}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.7
3
+ Version: 0.8.9
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>
@@ -1,6 +1,7 @@
1
- # withcache cache-host. Build context is the repo root:
1
+ # withcache cache-host. From-source build (rare; the reference
2
+ # compose.yml uses the published ``ghcr.io/safl/withcache:latest``
3
+ # image instead). Build context is the repo root:
2
4
  # podman build -f deploy/Containerfile -t withcache .
3
- # (deploy/compose.yml sets the context for you.)
4
5
  FROM python:3.12-slim
5
6
 
6
7
  # Install the package (no third-party deps) to get the withcache-server command.
@@ -0,0 +1,11 @@
1
+ # Overlay compose file for from-source dev iteration:
2
+ # podman compose -f deploy/compose.yml -f deploy/compose.local-build.yml up -d --build
3
+ # The reference deploy/compose.yml uses the published image
4
+ # ``ghcr.io/safl/withcache:latest``; this overlay swaps in a local
5
+ # build from the repo root's Containerfile.
6
+ services:
7
+ withcache:
8
+ build:
9
+ context: ..
10
+ dockerfile: deploy/Containerfile
11
+ image: withcache:local
@@ -0,0 +1,23 @@
1
+ # Reference compose stack for withcache.
2
+ #
3
+ # Usage:
4
+ # cp envvars.example envvars && "${EDITOR:-vi}" envvars # set the knobs
5
+ # podman compose --env-file envvars up -d
6
+ # # http://<host>:8081/ (operator UI)
7
+ services:
8
+ withcache:
9
+ image: ghcr.io/safl/withcache:latest
10
+ restart: unless-stopped
11
+ container_name: withcache
12
+ environment:
13
+ WITHCACHE_ADMIN_PASSWORD: "${WITHCACHE_ADMIN_PASSWORD:-}"
14
+ WITHCACHE_SESSION_SECRET: "${WITHCACHE_SESSION_SECRET:-}"
15
+ WITHCACHE_CATALOG_URL: "${WITHCACHE_CATALOG_URL:-}"
16
+ ports:
17
+ - "${HOST_HTTP_PORT:-8081}:8081"
18
+ volumes:
19
+ # Persistent: blob store + cache.db + session-secret survive restarts.
20
+ - withcache-data:/data
21
+
22
+ volumes:
23
+ withcache-data:
@@ -0,0 +1,17 @@
1
+ # Copy to ``envvars`` and edit. The compose stack reads this via --env-file.
2
+
3
+ # Gate the operator UI + control-plane writes. Empty = open (logs a
4
+ # startup warning; only safe on a LAN-internal withcache).
5
+ WITHCACHE_ADMIN_PASSWORD=
6
+
7
+ # Optional: pin the signed-cookie HMAC key. Unset = auto-generate a
8
+ # fresh 64-hex key at first start and persist it under /data. Set
9
+ # explicitly to keep cookies valid across a container rebuild that
10
+ # wipes the data volume, or to rotate the secret on demand.
11
+ #WITHCACHE_SESSION_SECRET=
12
+
13
+ # Optional: pin the image-catalog URL. When set, the env value wins
14
+ # over any /admin/catalog_set_url operator override so a locked-down
15
+ # deploy stays locked. Unset uses the default (safl/nosi's
16
+ # ``catalog.toml`` on GitHub).
17
+ #WITHCACHE_CATALOG_URL=
@@ -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.7",
5
+ .version = "0.8.9",
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.7"
20
+ __version__ = "0.8.9"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -826,6 +826,8 @@ class DownloadManager:
826
826
  reports progress and honors a per-job cancel flag. Jobs are in-memory
827
827
  (completed artifacts persist as blobs); restarting drops in-flight jobs."""
828
828
 
829
+ _STOP = -1
830
+
829
831
  def __init__(self, store: Store, workers: int = 2):
830
832
  self.store = store
831
833
  self._jobs: dict[int, Job] = {}
@@ -833,8 +835,24 @@ class DownloadManager:
833
835
  self._lock = threading.Lock()
834
836
  self._q: queue.Queue[int] = queue.Queue()
835
837
  self._ids = itertools.count(1)
838
+ self._threads: list[threading.Thread] = []
836
839
  for _ in range(max(1, workers)):
837
- threading.Thread(target=self._worker, daemon=True).start()
840
+ t = threading.Thread(target=self._worker, daemon=True)
841
+ t.start()
842
+ self._threads.append(t)
843
+
844
+ def close(self, timeout: float = 3.0) -> None:
845
+ """Ask each worker to exit and wait for them. Pushes one
846
+ ``_STOP`` sentinel per worker so an idle ``queue.get()``
847
+ unblocks; running workers finish their current job first.
848
+ The daemon=True flag lets ``main()`` exit without calling
849
+ this, but test fixtures + explicit shutdowns should call
850
+ it to avoid the sqlite3 finalizer warnings that leaked
851
+ worker threads produce."""
852
+ for _ in self._threads:
853
+ self._q.put(self._STOP)
854
+ for t in self._threads:
855
+ t.join(timeout=timeout)
838
856
 
839
857
  def enqueue(self, url: str, headers: dict | None = None) -> Job:
840
858
  with self._lock:
@@ -872,6 +890,8 @@ class DownloadManager:
872
890
  def _worker(self):
873
891
  while True:
874
892
  jid = self._q.get()
893
+ if jid == self._STOP:
894
+ return
875
895
  with self._lock:
876
896
  job = self._jobs.get(jid)
877
897
  if job is None or job.status != "queued":
@@ -1801,7 +1821,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
1801
1821
  '<progress id="spin" class="htmx-indicator ms-1" '
1802
1822
  'style="width:4rem;height:.4rem;"></progress></div>'
1803
1823
  )
1804
- return f'<div class="subnav-strip"><div class="container">{left}{right}</div></div>'
1824
+ # <nav class="navbar subnav-strip"> matches bty's canonical
1825
+ # subnav shape (bty/src/bty/web/_templates/ui/_subnav.html);
1826
+ # ``.navbar`` gets the Bootstrap subnav-strip vertical rhythm,
1827
+ # aria-label + <nav> give screen readers a landmark.
1828
+ return (
1829
+ '<nav class="navbar subnav-strip" aria-label="Section sub-navigation">'
1830
+ f'<div class="container">{left}{right}</div></nav>'
1831
+ )
1805
1832
 
1806
1833
  # ---- Cached page -----------------------------------------------------
1807
1834
  def _cached_body_html(self) -> str:
@@ -2249,6 +2276,10 @@ def main():
2249
2276
  httpd.serve_forever()
2250
2277
  except KeyboardInterrupt:
2251
2278
  print("\nbye", flush=True)
2279
+ finally:
2280
+ # Drain workers on shutdown so the sqlite finalizer warnings
2281
+ # from leaked threads stop appearing on Ctrl-C.
2282
+ mgr.close()
2252
2283
 
2253
2284
 
2254
2285
  if __name__ == "__main__":
@@ -178,6 +178,7 @@ class TestStoreFromOrigin(unittest.TestCase):
178
178
  self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
179
179
 
180
180
  def tearDown(self):
181
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
181
182
  self.httpd.shutdown()
182
183
  self.httpd.server_close()
183
184
 
@@ -266,6 +267,7 @@ class TestTruncatedDownloadRejected(unittest.TestCase):
266
267
  self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
267
268
 
268
269
  def tearDown(self):
270
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
269
271
  self.httpd.shutdown()
270
272
  self.httpd.server_close()
271
273
 
@@ -384,6 +386,7 @@ class TestRangeResumeOnTruncation(unittest.TestCase):
384
386
  self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
385
387
 
386
388
  def tearDown(self):
389
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
387
390
  self.httpd.shutdown()
388
391
  self.httpd.server_close()
389
392
 
@@ -843,6 +846,7 @@ class TestFetchWithHeaders(unittest.TestCase):
843
846
  self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
844
847
 
845
848
  def tearDown(self):
849
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
846
850
  self.httpd.shutdown()
847
851
  self.httpd.server_close()
848
852
 
@@ -1095,6 +1099,7 @@ class TestMultiPageNavigation(unittest.TestCase):
1095
1099
  self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
1096
1100
 
1097
1101
  def tearDown(self):
1102
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
1098
1103
  self.httpd.shutdown()
1099
1104
  self.httpd.server_close()
1100
1105
 
@@ -1196,6 +1201,7 @@ class TestCatalogAdminEndpoints(unittest.TestCase):
1196
1201
  def tearDown(self):
1197
1202
  self.origin.shutdown()
1198
1203
  self.origin.server_close()
1204
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
1199
1205
  self.httpd.shutdown()
1200
1206
  self.httpd.server_close()
1201
1207
 
@@ -1316,6 +1322,88 @@ class TestCatalogAdminEndpoints(unittest.TestCase):
1316
1322
  httpd2.server_close()
1317
1323
 
1318
1324
 
1325
+ class TestAddOrasEntryFormatMapping(unittest.TestCase):
1326
+ """CatalogState.add_oras_entry derives ``format`` from the resolved
1327
+ layer title's suffix and ``arch`` from the tag suffix. The
1328
+ happy-path test in TestCatalogAdminEndpoints only covers the
1329
+ unreachable-registry branch (fmt stays ''); this class exercises
1330
+ the title -> format mapping (all five known suffixes + fallback)
1331
+ and the arch extraction."""
1332
+
1333
+ def setUp(self):
1334
+ self.tmpdir = tempfile.mkdtemp()
1335
+ self.catalog = server.CatalogState(
1336
+ url="http://localhost/catalog.toml",
1337
+ persist_path=os.path.join(self.tmpdir, "catalog.toml"),
1338
+ )
1339
+ # Patch oras.resolve_ref for the duration of the tests; every
1340
+ # test sets a fake_resolver with the shape it needs.
1341
+ self._original_resolve = server.oras.resolve_ref
1342
+
1343
+ def tearDown(self):
1344
+ server.oras.resolve_ref = self._original_resolve
1345
+
1346
+ def _stub_resolve(self, title, size=1234):
1347
+ def _r(ref):
1348
+ return types.SimpleNamespace(title=title, size=size, digest="sha256:abc")
1349
+
1350
+ server.oras.resolve_ref = _r
1351
+
1352
+ def test_title_ends_with_img_zst(self):
1353
+ self._stub_resolve("debian-13-headless.img.zst")
1354
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1355
+ self.assertTrue(ok)
1356
+ self.assertEqual(self.catalog.entries[-1]["format"], "img.zst")
1357
+
1358
+ def test_title_ends_with_img_gz(self):
1359
+ self._stub_resolve("release.img.gz")
1360
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1361
+ self.assertTrue(ok)
1362
+ self.assertEqual(self.catalog.entries[-1]["format"], "img.gz")
1363
+
1364
+ def test_title_ends_with_img_xz(self):
1365
+ self._stub_resolve("release.img.xz")
1366
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1367
+ self.assertTrue(ok)
1368
+ self.assertEqual(self.catalog.entries[-1]["format"], "img.xz")
1369
+
1370
+ def test_title_ends_with_bare_img(self):
1371
+ self._stub_resolve("raw.img")
1372
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1373
+ self.assertTrue(ok)
1374
+ self.assertEqual(self.catalog.entries[-1]["format"], "img")
1375
+
1376
+ def test_title_ends_with_iso(self):
1377
+ self._stub_resolve("bootable.iso")
1378
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1379
+ self.assertTrue(ok)
1380
+ self.assertEqual(self.catalog.entries[-1]["format"], "iso")
1381
+
1382
+ def test_title_no_known_suffix_leaves_format_unset(self):
1383
+ self._stub_resolve("something.tar")
1384
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1385
+ self.assertTrue(ok)
1386
+ self.assertNotIn("format", self.catalog.entries[-1])
1387
+
1388
+ def test_tag_suffix_extracts_arch_x86_64(self):
1389
+ self._stub_resolve("release.img.zst")
1390
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1-x86_64")
1391
+ self.assertTrue(ok)
1392
+ self.assertEqual(self.catalog.entries[-1]["arch"], "x86_64")
1393
+
1394
+ def test_tag_suffix_extracts_arch_arm64(self):
1395
+ self._stub_resolve("release.img.zst")
1396
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1-arm64")
1397
+ self.assertTrue(ok)
1398
+ self.assertEqual(self.catalog.entries[-1]["arch"], "arm64")
1399
+
1400
+ def test_unknown_arch_suffix_leaves_arch_unset(self):
1401
+ self._stub_resolve("release.img.zst")
1402
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1-mips")
1403
+ self.assertTrue(ok)
1404
+ self.assertNotIn("arch", self.catalog.entries[-1])
1405
+
1406
+
1319
1407
  class TestReadFormDuplicateKeys(unittest.TestCase):
1320
1408
  """read_form rejects duplicate form keys so a first-value-wins
1321
1409
  collapse can't hide a payload silently -- mirrors nbdmux's
@@ -1327,6 +1415,7 @@ class TestReadFormDuplicateKeys(unittest.TestCase):
1327
1415
  self.port = self.httpd.server_address[1]
1328
1416
 
1329
1417
  def tearDown(self):
1418
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
1330
1419
  self.httpd.shutdown()
1331
1420
  self.httpd.server_close()
1332
1421
 
@@ -1,28 +0,0 @@
1
- # Single-host deploy (run from the repo root):
2
- # WITHCACHE_ADMIN_PASSWORD=secret podman compose -f deploy/compose.yml up -d
3
- # (or: docker compose -f deploy/compose.yml up -d)
4
- services:
5
- cache-host:
6
- build:
7
- context: ..
8
- dockerfile: deploy/Containerfile
9
- image: withcache:latest
10
- container_name: withcache
11
- ports:
12
- - "8081:8081"
13
- environment:
14
- # Protect the operator UI. Unset => UI is open (a warning is logged).
15
- - WITHCACHE_ADMIN_PASSWORD=${WITHCACHE_ADMIN_PASSWORD:-}
16
- # Optional: pin the session-signing key (else persisted under /data).
17
- - WITHCACHE_SESSION_SECRET=${WITHCACHE_SESSION_SECRET:-}
18
- # Optional: pin the image-catalog URL. When set, the env value
19
- # wins over any /admin/catalog_set_url operator override so a
20
- # locked-down deploy stays locked. Unset uses the default
21
- # (safl/nosi's ``catalog.toml`` on GitHub).
22
- - WITHCACHE_CATALOG_URL=${WITHCACHE_CATALOG_URL:-}
23
- volumes:
24
- - withcache-data:/data
25
- restart: unless-stopped
26
-
27
- volumes:
28
- withcache-data:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes