withcache 0.6.5__tar.gz → 0.7.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.
Files changed (26) hide show
  1. {withcache-0.6.5 → withcache-0.7.0}/PKG-INFO +2 -2
  2. {withcache-0.6.5 → withcache-0.7.0}/pyproject.toml +2 -2
  3. {withcache-0.6.5 → withcache-0.7.0}/shim/build.zig.zon +1 -1
  4. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/__init__.py +1 -1
  5. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/server.py +185 -3
  6. {withcache-0.6.5 → withcache-0.7.0}/tests/test_withcache.py +8 -0
  7. {withcache-0.6.5 → withcache-0.7.0}/.gitignore +0 -0
  8. {withcache-0.6.5 → withcache-0.7.0}/LICENSE +0 -0
  9. {withcache-0.6.5 → withcache-0.7.0}/README.md +0 -0
  10. {withcache-0.6.5 → withcache-0.7.0}/deploy/Containerfile +0 -0
  11. {withcache-0.6.5 → withcache-0.7.0}/deploy/compose.yml +0 -0
  12. {withcache-0.6.5 → withcache-0.7.0}/hatch_build.py +0 -0
  13. {withcache-0.6.5 → withcache-0.7.0}/shim/build.zig +0 -0
  14. {withcache-0.6.5 → withcache-0.7.0}/shim/shim.zig +0 -0
  15. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/_shim.py +0 -0
  16. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/client.py +0 -0
  17. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/oras.py +0 -0
  19. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  20. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/bootstrap.min.css +0 -0
  21. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  22. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  23. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.6.5 → withcache-0.7.0}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.6.5 → withcache-0.7.0}/tests/test_differential.py +0 -0
  26. {withcache-0.6.5 → withcache-0.7.0}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.6.5
3
+ Version: 0.7.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>
@@ -13,7 +13,7 @@ Classifier: Intended Audience :: System Administrators
13
13
  Classifier: License :: OSI Approved :: BSD License
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Topic :: System :: Archiving :: Mirroring
16
- Requires-Python: >=3.10
16
+ Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # withcache
@@ -7,7 +7,7 @@ name = "withcache"
7
7
  dynamic = ["version"] # single source of truth: src/withcache/__init__.py:__version__
8
8
  description = "Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = { text = "BSD-3-Clause" }
12
12
  authors = [{ name = "Simon A. F. Lund", email = "safl@safl.dk" }]
13
13
  keywords = ["cache", "artifacts", "cuda", "rocm", "doca", "firmware", "lab"]
@@ -45,7 +45,7 @@ include = ["src/withcache", "shim", "README.md", "LICENSE", "deploy", "tests"]
45
45
 
46
46
  [tool.ruff]
47
47
  line-length = 100
48
- target-version = "py310"
48
+ target-version = "py311"
49
49
 
50
50
  [tool.ruff.lint]
51
51
  select = ["E", "F", "W", "I", "UP", "B"]
@@ -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.6.5",
5
+ .version = "0.7.0",
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.6.5"
20
+ __version__ = "0.7.0"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -37,11 +37,13 @@ import socketserver
37
37
  import sqlite3
38
38
  import threading
39
39
  import time
40
+ import tomllib
41
+ import urllib.error
40
42
  import urllib.parse
41
43
  import urllib.request
42
44
  from dataclasses import dataclass, field
43
- from datetime import datetime, timezone
44
- from typing import ClassVar
45
+ from datetime import UTC, datetime
46
+ from typing import Any, ClassVar
45
47
 
46
48
  from . import __version__, oras
47
49
 
@@ -70,7 +72,7 @@ _DB_WRITE_LOCK = threading.Lock()
70
72
 
71
73
 
72
74
  def now_iso() -> str:
73
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
75
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
74
76
 
75
77
 
76
78
  def _age_human(started_at: float, *, now: float | None = None) -> str:
@@ -147,6 +149,84 @@ def resolve_secret(data_dir: str) -> bytes:
147
149
  return secret
148
150
 
149
151
 
152
+ DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
153
+
154
+
155
+ @dataclass
156
+ class CatalogState:
157
+ """Live state of the fetched image catalog.
158
+
159
+ withcache does not run a background poller. The catalog is
160
+ fetched at process start (best-effort, persisted result from
161
+ the last successful fetch survives a restart), refetched
162
+ lazily when the dashboard renders and the in-memory copy is
163
+ older than :data:`CATALOG_CACHE_TTL`, and force-refetched by
164
+ the operator via the Refresh button (POST /admin/catalog_refresh).
165
+
166
+ The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
167
+ so a restart doesn't wipe the last known good catalog and the
168
+ same file can be re-served verbatim to consumers on a future
169
+ ``GET /catalog.toml`` route.
170
+ """
171
+
172
+ url: str
173
+ persist_path: str
174
+ entries: list[dict[str, Any]] = field(default_factory=list)
175
+ fetched_at: str = ""
176
+ last_error: str = ""
177
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
178
+
179
+ def load_persisted(self) -> None:
180
+ """Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
181
+ ``catalog.toml`` if it exists. Never raises; a corrupt or missing
182
+ file leaves the state empty so the operator sees the "not
183
+ fetched yet" hint in the dashboard."""
184
+ if not os.path.isfile(self.persist_path):
185
+ return
186
+ try:
187
+ with open(self.persist_path, "rb") as f:
188
+ raw = f.read()
189
+ parsed = tomllib.loads(raw.decode("utf-8"))
190
+ mtime = os.path.getmtime(self.persist_path)
191
+ self.entries = list(parsed.get("images") or [])
192
+ self.fetched_at = datetime.fromtimestamp(mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
193
+ except (OSError, ValueError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
194
+ self.last_error = f"failed to load {self.persist_path}: {e}"
195
+
196
+ def fetch_now(self, *, timeout: float = 15.0) -> None:
197
+ """Fetch the catalog URL, parse, persist raw bytes, populate
198
+ ``entries``. On failure the previously-cached entries remain;
199
+ the error is recorded so the tab can surface it. Single-writer
200
+ via ``self._lock`` so a burst of clicks doesn't double-fetch."""
201
+ with self._lock:
202
+ try:
203
+ req = urllib.request.Request(self.url, headers={"User-Agent": USER_AGENT})
204
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
205
+ raw = resp.read()
206
+ # Validate before persisting -- a 500 from upstream that
207
+ # returns HTML must not clobber a previously-good
208
+ # catalog.toml on disk.
209
+ parsed = tomllib.loads(raw.decode("utf-8"))
210
+ entries = list(parsed.get("images") or [])
211
+ # Atomic write: tempfile + rename so a crash mid-write
212
+ # never leaves a half-written catalog.toml.
213
+ tmp = self.persist_path + ".tmp"
214
+ with open(tmp, "wb") as f:
215
+ f.write(raw)
216
+ os.replace(tmp, self.persist_path)
217
+ self.entries = entries
218
+ self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
219
+ self.last_error = ""
220
+ except (
221
+ urllib.error.URLError,
222
+ OSError,
223
+ ValueError,
224
+ UnicodeDecodeError,
225
+ tomllib.TOMLDecodeError,
226
+ ) as e:
227
+ self.last_error = str(e)
228
+
229
+
150
230
  class Auth:
151
231
  COOKIE = "withcache-token"
152
232
  MAX_AGE = 7 * 24 * 3600 # cookie lifetime, seconds
@@ -745,6 +825,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
745
825
  def streams(self) -> StreamRegistry:
746
826
  return self.server.streams # type: ignore[attr-defined]
747
827
 
828
+ @property
829
+ def catalog(self) -> CatalogState:
830
+ return self.server.catalog # type: ignore[attr-defined]
831
+
748
832
  def log_message(self, format, *args): # quieter, single-line
749
833
  print(f"{self.address_string()} - {format % args}", flush=True)
750
834
 
@@ -793,6 +877,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
793
877
  "/admin/delete",
794
878
  "/admin/cancel",
795
879
  "/admin/clear",
880
+ "/admin/catalog_refresh",
796
881
  )
797
882
 
798
883
  def do_POST(self):
@@ -820,6 +905,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
820
905
  self.mgr.cancel(int(jid))
821
906
  elif parsed.path == "/admin/clear":
822
907
  self.mgr.clear_finished()
908
+ elif parsed.path == "/admin/catalog_refresh":
909
+ self.catalog.fetch_now()
823
910
  self.respond_admin()
824
911
  else:
825
912
  self.send_text(404, "not found\n")
@@ -1354,6 +1441,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
1354
1441
  nstreams = len(streams)
1355
1442
  njobs = len(jobs)
1356
1443
 
1444
+ catalog_rows = self._catalog_rows()
1445
+
1357
1446
  return f"""
1358
1447
  <p class="text-muted small mb-2">{nblobs} cached ({used}){full}
1359
1448
  &middot; {nmisses} pending miss(es)</p>
@@ -1367,6 +1456,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
1367
1456
  href="#tab-downloads">Downloads ({njobs})</a></li>
1368
1457
  <li class="nav-item"><a class="nav-link {_active("tab-misses").lstrip()}"
1369
1458
  href="#tab-misses">Misses ({nmisses})</a></li>
1459
+ <li class="nav-item"><a class="nav-link {_active("tab-catalog").lstrip()}"
1460
+ href="#tab-catalog">Catalog ({len(self.catalog.entries)})</a></li>
1370
1461
  </ul>
1371
1462
  </nav>
1372
1463
 
@@ -1412,8 +1503,80 @@ class Handler(http.server.BaseHTTPRequestHandler):
1412
1503
  </tr></thead>
1413
1504
  <tbody>{miss_rows}</tbody>
1414
1505
  </table></div>
1506
+ </section>
1507
+
1508
+ <section id="tab-catalog" class="tab{_active("tab-catalog")}">
1509
+ <div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
1510
+ <div>
1511
+ <div><small class="text-muted">source</small>
1512
+ &nbsp;<code>{html.escape(self.catalog.url)}</code></div>
1513
+ <div><small class="text-muted">fetched</small>
1514
+ &nbsp;<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
1515
+ {
1516
+ (
1517
+ f'<span class="badge bg-danger-subtle text-danger ms-1">'
1518
+ f"{html.escape(self.catalog.last_error)}</span>"
1519
+ )
1520
+ if self.catalog.last_error
1521
+ else ""
1522
+ }</div>
1523
+ </div>
1524
+ <form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
1525
+ hx-indicator="#spin" class="m-0">
1526
+ <button class="btn btn-sm btn-primary" type="submit">Refresh</button>
1527
+ </form>
1528
+ </div>
1529
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1530
+ <thead class="table-light"><tr>
1531
+ <th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
1532
+ </tr></thead>
1533
+ <tbody>{catalog_rows}</tbody>
1534
+ </table></div>
1415
1535
  </section>"""
1416
1536
 
1537
+ def _catalog_rows(self) -> str:
1538
+ """One <tr> per catalog image. Cells: name, format, arch,
1539
+ human-readable size, and a Source cell whose URL is the
1540
+ withcache /b/ token (so a click through pulls via cache).
1541
+ Empty catalog rows to a "not fetched yet" placeholder + a
1542
+ hint about the Refresh button."""
1543
+ entries = self.catalog.entries
1544
+ if not entries:
1545
+ hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
1546
+ return (
1547
+ '<tr><td colspan="5" class="text-center text-muted">'
1548
+ f"<em>{html.escape(hint)}</em></td></tr>"
1549
+ )
1550
+ rows: list[str] = []
1551
+ for entry in entries:
1552
+ name = str(entry.get("name") or "")
1553
+ src = str(entry.get("src") or "")
1554
+ fmt = str(entry.get("format") or "")
1555
+ arch = str(entry.get("arch") or "")
1556
+ size_bytes = entry.get("size_bytes")
1557
+ size = human_size(int(size_bytes)) if isinstance(size_bytes, int) else ""
1558
+ # Route the Source column via /b/<token>/<name> so a
1559
+ # click serves through withcache -- first hit fills the
1560
+ # cache, subsequent hits serve from disk. Mirrors the
1561
+ # blob-row rendering above.
1562
+ if src.startswith(("http://", "https://", "oras://")):
1563
+ token = base64.urlsafe_b64encode(src.encode("utf-8")).decode("ascii").rstrip("=")
1564
+ filename = urllib.parse.urlsplit(src).path.rsplit("/", 1)[-1] or "download"
1565
+ link = f"/b/{token}/{urllib.parse.quote(filename)}"
1566
+ src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
1567
+ else:
1568
+ src_cell = html.escape(src)
1569
+ rows.append(
1570
+ f"<tr>"
1571
+ f"<td>{html.escape(name)}</td>"
1572
+ f'<td class="mono"><small>{html.escape(fmt)}</small></td>'
1573
+ f'<td class="mono"><small>{html.escape(arch)}</small></td>'
1574
+ f"<td>{html.escape(size)}</td>"
1575
+ f'<td class="url">{src_cell}</td>'
1576
+ "</tr>"
1577
+ )
1578
+ return "".join(rows)
1579
+
1417
1580
  def _stream_progress_cell(self, s: Stream) -> str:
1418
1581
  """One progress cell for an active stream: a <progress> bar when the
1419
1582
  total is known (always for a cached blob, since the size came off
@@ -1506,6 +1669,25 @@ def main():
1506
1669
  httpd.mgr = mgr # type: ignore[attr-defined]
1507
1670
  httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
1508
1671
  httpd.streams = StreamRegistry() # type: ignore[attr-defined]
1672
+ # Catalog state: env override, else the shipping default (nosi's
1673
+ # rolling catalog manifest). Seeded from the last persisted
1674
+ # catalog.toml on disk so a restart doesn't wipe the cache.
1675
+ catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
1676
+ catalog = CatalogState(
1677
+ url=catalog_url,
1678
+ persist_path=os.path.join(store.data_dir, "catalog.toml"),
1679
+ )
1680
+ catalog.load_persisted()
1681
+ httpd.catalog = catalog # type: ignore[attr-defined]
1682
+ # If no catalog is persisted yet, kick a single startup fetch
1683
+ # in a daemon thread so the operator has something to look at
1684
+ # without needing to click Refresh on their first visit.
1685
+ # Failures record ``last_error`` in memory; the dashboard row
1686
+ # shows it. Never blocks the serve loop.
1687
+ if not catalog.entries:
1688
+ threading.Thread(
1689
+ target=catalog.fetch_now, name="withcache-catalog-init", daemon=True
1690
+ ).start()
1509
1691
  print(
1510
1692
  f"withcache cache-host on http://{args.host}:{args.port} "
1511
1693
  f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
@@ -643,6 +643,14 @@ def _start_withcache(auto_fetch=False):
643
643
  httpd.mgr = server.DownloadManager(store, workers=1)
644
644
  httpd.auto_fetch = auto_fetch
645
645
  httpd.streams = server.StreamRegistry()
646
+ # Empty catalog with a bogus URL so the tab renders "click Refresh
647
+ # above to fetch the catalog manifest" instead of firing a real
648
+ # HTTP call. Tests that specifically exercise the catalog code
649
+ # can override this attribute after the helper returns.
650
+ httpd.catalog = server.CatalogState(
651
+ url="http://localhost/catalog.toml",
652
+ persist_path=os.path.join(store.data_dir, "catalog.toml"),
653
+ )
646
654
  threading.Thread(target=httpd.serve_forever, daemon=True).start()
647
655
  return httpd, store
648
656
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes