withcache 0.8.8__tar.gz → 0.8.10__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 (28) hide show
  1. {withcache-0.8.8 → withcache-0.8.10}/PKG-INFO +1 -1
  2. {withcache-0.8.8 → withcache-0.8.10}/deploy/Containerfile +3 -2
  3. withcache-0.8.10/deploy/compose.local-build.yml +11 -0
  4. {withcache-0.8.8 → withcache-0.8.10}/shim/build.zig.zon +1 -1
  5. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/__init__.py +1 -1
  6. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/server.py +8 -1
  7. {withcache-0.8.8 → withcache-0.8.10}/tests/test_withcache.py +205 -0
  8. {withcache-0.8.8 → withcache-0.8.10}/.gitignore +0 -0
  9. {withcache-0.8.8 → withcache-0.8.10}/LICENSE +0 -0
  10. {withcache-0.8.8 → withcache-0.8.10}/README.md +0 -0
  11. {withcache-0.8.8 → withcache-0.8.10}/deploy/compose.yml +0 -0
  12. {withcache-0.8.8 → withcache-0.8.10}/deploy/envvars.example +0 -0
  13. {withcache-0.8.8 → withcache-0.8.10}/hatch_build.py +0 -0
  14. {withcache-0.8.8 → withcache-0.8.10}/pyproject.toml +0 -0
  15. {withcache-0.8.8 → withcache-0.8.10}/shim/build.zig +0 -0
  16. {withcache-0.8.8 → withcache-0.8.10}/shim/shim.zig +0 -0
  17. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/_shim.py +0 -0
  18. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/client.py +0 -0
  19. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/curlwithcache.py +0 -0
  20. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/oras.py +0 -0
  21. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/bootstrap-icons.min.css +0 -0
  22. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/bootstrap.min.css +0 -0
  23. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  24. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  25. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/htmx.min.js +0 -0
  26. {withcache-0.8.8 → withcache-0.8.10}/src/withcache/wgetwithcache.py +0 -0
  27. {withcache-0.8.8 → withcache-0.8.10}/tests/test_differential.py +0 -0
  28. {withcache-0.8.8 → withcache-0.8.10}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.8
3
+ Version: 0.8.10
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
@@ -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.8",
5
+ .version = "0.8.10",
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.8"
20
+ __version__ = "0.8.10"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -1821,7 +1821,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
1821
1821
  '<progress id="spin" class="htmx-indicator ms-1" '
1822
1822
  'style="width:4rem;height:.4rem;"></progress></div>'
1823
1823
  )
1824
- 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
+ )
1825
1832
 
1826
1833
  # ---- Cached page -----------------------------------------------------
1827
1834
  def _cached_body_html(self) -> str:
@@ -1440,5 +1440,210 @@ class TestReadFormDuplicateKeys(unittest.TestCase):
1440
1440
  conn.close()
1441
1441
 
1442
1442
 
1443
+ class TestCatalogStateLoadPersisted(unittest.TestCase):
1444
+ """CatalogState.load_persisted is the restart-recovery path (the
1445
+ docstring's whole premise: 'a persisted result from the last
1446
+ successful fetch survives a restart'). Four branches: override
1447
+ present, override absent, TOML corrupt (silent last_error), TOML
1448
+ missing. None were exercised before."""
1449
+
1450
+ def setUp(self):
1451
+ self.tmpdir = tempfile.mkdtemp()
1452
+ self.persist = os.path.join(self.tmpdir, "catalog.toml")
1453
+ self.url_override = os.path.join(self.tmpdir, "catalog_url")
1454
+
1455
+ def _fresh_state(self, *, env_url: str = "") -> "server.CatalogState":
1456
+ return server.CatalogState(
1457
+ url="http://default/catalog.toml",
1458
+ persist_path=self.persist,
1459
+ url_override_path=self.url_override,
1460
+ env_url=env_url,
1461
+ )
1462
+
1463
+ def test_missing_files_leaves_state_empty(self):
1464
+ s = self._fresh_state()
1465
+ s.load_persisted()
1466
+ self.assertEqual(s.entries, [])
1467
+ self.assertEqual(s.fetched_at, "")
1468
+ self.assertEqual(s.last_error, "")
1469
+ # URL stays at default when override file absent.
1470
+ self.assertEqual(s.url, "http://default/catalog.toml")
1471
+
1472
+ def test_valid_toml_populates_entries(self):
1473
+ payload = (
1474
+ b'[[images]]\nname = "demo"\nsrc = "https://x/demo.img.zst"\n'
1475
+ b'[[images]]\nname = "other"\nsrc = "https://x/other.img"\n'
1476
+ )
1477
+ with open(self.persist, "wb") as f:
1478
+ f.write(payload)
1479
+ s = self._fresh_state()
1480
+ s.load_persisted()
1481
+ self.assertEqual([e["name"] for e in s.entries], ["demo", "other"])
1482
+ self.assertTrue(s.fetched_at) # non-empty ISO timestamp
1483
+ self.assertEqual(s.last_error, "")
1484
+
1485
+ def test_url_override_file_wins_when_env_unset(self):
1486
+ with open(self.url_override, "w", encoding="utf-8") as f:
1487
+ f.write("https://operator-set/catalog.toml\n")
1488
+ s = self._fresh_state()
1489
+ s.load_persisted()
1490
+ self.assertEqual(s.url, "https://operator-set/catalog.toml")
1491
+
1492
+ def test_env_url_pin_beats_url_override_file(self):
1493
+ # Env pinning: operator override MUST NOT sneak past.
1494
+ with open(self.url_override, "w", encoding="utf-8") as f:
1495
+ f.write("https://operator-set/catalog.toml\n")
1496
+ s = self._fresh_state(env_url="https://env-pinned/catalog.toml")
1497
+ s.load_persisted()
1498
+ # Env-url wins; the operator file is ignored.
1499
+ self.assertNotEqual(s.url, "https://operator-set/catalog.toml")
1500
+
1501
+ def test_corrupt_toml_records_last_error_without_raising(self):
1502
+ with open(self.persist, "wb") as f:
1503
+ f.write(b"this is not toml { at all")
1504
+ s = self._fresh_state()
1505
+ s.load_persisted() # must not raise
1506
+ self.assertEqual(s.entries, [])
1507
+ self.assertTrue(s.last_error)
1508
+ self.assertIn("failed to load", s.last_error)
1509
+
1510
+
1511
+ class TestAddOrasEntryDedupeByName(unittest.TestCase):
1512
+ """add_oras_entry replaces an existing entry by name rather than
1513
+ appending -- the whole reason for the list comprehension at
1514
+ line 356. A regression that dropped the filter would silently
1515
+ duplicate rows. TestAddOrasEntryFormatMapping (Pass 9+10)
1516
+ covered only the field-derivation branches."""
1517
+
1518
+ def setUp(self):
1519
+ self.tmpdir = tempfile.mkdtemp()
1520
+ self.catalog = server.CatalogState(
1521
+ url="http://localhost/catalog.toml",
1522
+ persist_path=os.path.join(self.tmpdir, "catalog.toml"),
1523
+ )
1524
+ self._original_resolve = server.oras.resolve_ref
1525
+ server.oras.resolve_ref = lambda ref: types.SimpleNamespace(
1526
+ title="demo.img.zst", size=1234, digest="sha256:abc"
1527
+ )
1528
+
1529
+ def tearDown(self):
1530
+ server.oras.resolve_ref = self._original_resolve
1531
+
1532
+ def test_second_add_of_same_name_replaces_not_appends(self):
1533
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1534
+ self.assertTrue(ok)
1535
+ # Second add with a URL that resolves to the same derived name
1536
+ # should replace the entry (dedupe on name).
1537
+ ok, _ = self.catalog.add_oras_entry("oras://ghcr.io/x/demo:v1")
1538
+ self.assertTrue(ok)
1539
+ names = [e["name"] for e in self.catalog.entries]
1540
+ # Exactly one row for the demo name -- not two duplicates.
1541
+ self.assertEqual(names.count("demo-v1"), 1)
1542
+
1543
+
1544
+ class TestHealthzEndpoint(unittest.TestCase):
1545
+ """/healthz is what the deploy/Containerfile HEALTHCHECK curl-
1546
+ probes (Pass 7). A regression to 500 / 404 would silently mark
1547
+ every container permanently unhealthy. Nbdmux tests /healthz
1548
+ explicitly; withcache diverged."""
1549
+
1550
+ def setUp(self):
1551
+ self.httpd, _ = _start_withcache()
1552
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
1553
+
1554
+ def tearDown(self):
1555
+ getattr(self.httpd, "mgr", None) and self.httpd.mgr.close()
1556
+ self.httpd.shutdown()
1557
+ self.httpd.server_close()
1558
+
1559
+ def test_healthz_returns_200_ok(self):
1560
+ r = urllib.request.urlopen(self.base + "/healthz")
1561
+ self.assertEqual(r.status, 200)
1562
+ body = r.read().decode("utf-8").strip()
1563
+ self.assertEqual(body, "ok")
1564
+
1565
+ def test_healthz_open_no_auth_required(self):
1566
+ """The read path must never require login -- shims + external
1567
+ health probes can't hold a session cookie."""
1568
+ # Set an admin password; withcache should STILL let /healthz
1569
+ # through without an auth check.
1570
+ self.httpd.auth = server.Auth(b"k", "letmein")
1571
+ r = urllib.request.urlopen(self.base + "/healthz")
1572
+ self.assertEqual(r.status, 200)
1573
+
1574
+
1575
+ class TestDownloadManagerClose(unittest.TestCase):
1576
+ """DownloadManager.close() (Pass 9+10) drains worker threads. A
1577
+ regression that failed to send enough STOP sentinels, or that
1578
+ left workers blocking on queue.get(), would leak threads across
1579
+ tests + leave a Ctrl-C'd server hanging."""
1580
+
1581
+ def setUp(self):
1582
+ self.tmpdir = tempfile.mkdtemp()
1583
+ self.store = server.Store(self.tmpdir, keep_query=False)
1584
+
1585
+ def test_close_terminates_workers_within_timeout(self):
1586
+ mgr = server.DownloadManager(self.store, workers=3)
1587
+ self.assertEqual(len(mgr._threads), 3)
1588
+ # Every worker was started and should terminate on close().
1589
+ for t in mgr._threads:
1590
+ self.assertTrue(t.is_alive())
1591
+ mgr.close(timeout=3.0)
1592
+ for t in mgr._threads:
1593
+ self.assertFalse(t.is_alive(), "worker still alive after close()")
1594
+
1595
+ def test_enqueue_dedupes_already_pending_url(self):
1596
+ """Same URL enqueued twice while the first is still pending
1597
+ should return the same Job id (line 842-843)."""
1598
+ mgr = server.DownloadManager(self.store, workers=0)
1599
+ try:
1600
+ j1 = mgr.enqueue("https://x/a")
1601
+ j2 = mgr.enqueue("https://x/a")
1602
+ self.assertEqual(j1.id, j2.id)
1603
+ finally:
1604
+ mgr.close(timeout=1.0)
1605
+
1606
+ def test_cancel_queued_job_marks_it_cancelled_synchronously(self):
1607
+ """A cancel on a still-queued job flips it to 'cancelled' at
1608
+ the request site rather than waiting for the worker."""
1609
+ mgr = server.DownloadManager(self.store, workers=0)
1610
+ try:
1611
+ job = mgr.enqueue("https://x/queued")
1612
+ self.assertEqual(job.status, "queued")
1613
+ got = mgr.cancel(job.id)
1614
+ self.assertIsNotNone(got)
1615
+ self.assertEqual(got.status, "cancelled")
1616
+ self.assertIsNotNone(got.finished_at)
1617
+ finally:
1618
+ mgr.close(timeout=1.0)
1619
+
1620
+ def test_cancel_unknown_id_returns_none(self):
1621
+ mgr = server.DownloadManager(self.store, workers=0)
1622
+ try:
1623
+ self.assertIsNone(mgr.cancel(12345))
1624
+ finally:
1625
+ mgr.close(timeout=1.0)
1626
+
1627
+
1628
+ class TestHumanSize(unittest.TestCase):
1629
+ """human_size feeds the operator UI's Cached / Streams tabs;
1630
+ an off-by-one in the ladder would silently mis-label sizes."""
1631
+
1632
+ def test_zero_is_bytes(self):
1633
+ self.assertIn("B", server.human_size(0))
1634
+
1635
+ def test_bytes_below_kib(self):
1636
+ self.assertIn("B", server.human_size(512))
1637
+
1638
+ def test_kib_range(self):
1639
+ self.assertIn("KiB", server.human_size(2048))
1640
+
1641
+ def test_mib_range(self):
1642
+ self.assertIn("MiB", server.human_size(5 * 1024 * 1024))
1643
+
1644
+ def test_gib_range(self):
1645
+ self.assertIn("GiB", server.human_size(3 * 1024 * 1024 * 1024))
1646
+
1647
+
1443
1648
  if __name__ == "__main__":
1444
1649
  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
File without changes