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.
- {withcache-0.8.8 → withcache-0.8.10}/PKG-INFO +1 -1
- {withcache-0.8.8 → withcache-0.8.10}/deploy/Containerfile +3 -2
- withcache-0.8.10/deploy/compose.local-build.yml +11 -0
- {withcache-0.8.8 → withcache-0.8.10}/shim/build.zig.zon +1 -1
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/__init__.py +1 -1
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/server.py +8 -1
- {withcache-0.8.8 → withcache-0.8.10}/tests/test_withcache.py +205 -0
- {withcache-0.8.8 → withcache-0.8.10}/.gitignore +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/LICENSE +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/README.md +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/deploy/compose.yml +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/deploy/envvars.example +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/hatch_build.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/pyproject.toml +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/shim/build.zig +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/shim/shim.zig +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/_shim.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/client.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/oras.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.8.8 → withcache-0.8.10}/tests/test_differential.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|