withcache 0.6.0__tar.gz → 0.6.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 (23) hide show
  1. {withcache-0.6.0 → withcache-0.6.2}/PKG-INFO +1 -1
  2. {withcache-0.6.0 → withcache-0.6.2}/shim/build.zig.zon +1 -1
  3. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/__init__.py +1 -1
  4. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/server.py +95 -13
  5. {withcache-0.6.0 → withcache-0.6.2}/tests/test_withcache.py +102 -0
  6. {withcache-0.6.0 → withcache-0.6.2}/.gitignore +0 -0
  7. {withcache-0.6.0 → withcache-0.6.2}/LICENSE +0 -0
  8. {withcache-0.6.0 → withcache-0.6.2}/README.md +0 -0
  9. {withcache-0.6.0 → withcache-0.6.2}/deploy/Containerfile +0 -0
  10. {withcache-0.6.0 → withcache-0.6.2}/deploy/compose.yml +0 -0
  11. {withcache-0.6.0 → withcache-0.6.2}/hatch_build.py +0 -0
  12. {withcache-0.6.0 → withcache-0.6.2}/pyproject.toml +0 -0
  13. {withcache-0.6.0 → withcache-0.6.2}/shim/build.zig +0 -0
  14. {withcache-0.6.0 → withcache-0.6.2}/shim/shim.zig +0 -0
  15. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/_shim.py +0 -0
  16. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/client.py +0 -0
  17. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/oras.py +0 -0
  19. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/static/htmx.min.js +0 -0
  20. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/static/pico.min.css +0 -0
  21. {withcache-0.6.0 → withcache-0.6.2}/src/withcache/wgetwithcache.py +0 -0
  22. {withcache-0.6.0 → withcache-0.6.2}/tests/test_differential.py +0 -0
  23. {withcache-0.6.0 → withcache-0.6.2}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.6.0
3
+ Version: 0.6.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>
@@ -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.0",
5
+ .version = "0.6.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.6.0"
20
+ __version__ = "0.6.2"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -669,6 +669,49 @@ def _set_progress(job: Job, done: int, total: int | None):
669
669
  job.bytes_total = total
670
670
 
671
671
 
672
+ def _oras_tag_moved(url: str, cached_sha: str | None, *, resolve=oras.resolve_ref) -> bool:
673
+ """True iff ``url`` is an ``oras://`` *tag* whose layer the registry now
674
+ resolves to something other than ``cached_sha``.
675
+
676
+ The store keys on the ref string, not the resolved digest, so a mutable
677
+ tag that is re-pushed (e.g. a rolling weekly image tag) would otherwise
678
+ serve the first-cached bytes forever. Re-resolve the tag and compare its
679
+ current layer digest against the cached bytes' ``sha256`` -- which IS that
680
+ layer's content digest, so the comparison is exact.
681
+
682
+ Returns False (keep the cached copy) for anything we cannot prove has
683
+ moved: a non-oras URL, a digest-pinned ref (content-addressed, immutable),
684
+ a missing ``cached_sha``, or a registry/resolve error -- availability
685
+ beats freshness when the origin is unreachable, and a transient failure
686
+ must never nuke a good entry. Returns True only on a demonstrated move, so
687
+ the caller invalidates and re-fetches. ``resolve`` is injectable for tests.
688
+ """
689
+ if not oras.is_oras_url(url):
690
+ return False
691
+ try:
692
+ ref = oras.parse_ref(url)
693
+ except Exception:
694
+ return False
695
+ if ref.digest is not None:
696
+ return False # digest-pinned: the ref already names the content
697
+ cached = (cached_sha or "").lower()
698
+ if not cached:
699
+ return False
700
+ try:
701
+ current = resolve(ref).digest.split(":", 1)[-1].lower()
702
+ except Exception as exc:
703
+ print(f"withcache: oras revalidate {url} failed: {exc}; serving cached copy", flush=True)
704
+ return False
705
+ if current == cached:
706
+ return False
707
+ print(
708
+ f"withcache: oras tag moved {url}: "
709
+ f"cached sha256:{cached[:12]} -> registry sha256:{current[:12]}; invalidating",
710
+ flush=True,
711
+ )
712
+ return True
713
+
714
+
672
715
  # --------------------------------------------------------------------------
673
716
  # HTTP handler
674
717
  # --------------------------------------------------------------------------
@@ -714,7 +757,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
714
757
  if not self.is_authed():
715
758
  self.send_text(401, "login required\n")
716
759
  else:
717
- self.send_html(200, self.render_dash())
760
+ # The browser sends the current URL hash (the active
761
+ # tab id) via the ``X-Active-Tab`` request header on
762
+ # every 1 Hz refresh. Server bakes the matching
763
+ # ``.active-tab`` class directly into the rendered
764
+ # HTML so the htmx innerHTML swap doesn't visibly
765
+ # blink while the post-swap JS would otherwise
766
+ # re-apply the class. See render_dash().
767
+ active = (self.headers.get("X-Active-Tab") or "").strip()
768
+ self.send_html(200, self.render_dash(active_tab=active))
718
769
  elif parsed.path == "/":
719
770
  if not self.is_authed():
720
771
  self.redirect("/ui/login")
@@ -856,6 +907,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
856
907
  self.send_text(400, "missing url\n")
857
908
  return
858
909
  row = self.store.get_blob(url)
910
+ if row is not None and _oras_tag_moved(url, row["sha256"]):
911
+ # The tag was re-pushed to a different layer since we cached it.
912
+ # Drop the stale bytes and fall through to the miss path so the
913
+ # current content is (auto-)fetched. Deleting first also frees the
914
+ # space the refill needs when the store is near --max-bytes.
915
+ self.store.delete_blob(row["key"])
916
+ row = None
859
917
  if row is None:
860
918
  self.store.record_miss(url)
861
919
  if self.auto_fetch and self.store.has_capacity():
@@ -1043,11 +1101,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
1043
1101
  isn't wiped by the 1 Hz refresh. ``isCollapsed`` is true when
1044
1102
  there's no selection or the caret is a zero-width point; once
1045
1103
  the operator releases / clears the selection polling resumes
1046
- on the next 1 s tick. -->
1104
+ on the next 1 s tick.
1105
+ ``hx-headers`` sends the current URL hash (the active tab id)
1106
+ as ``X-Active-Tab`` on every refresh so the server can bake
1107
+ ``.active-tab`` into the rendered HTML -- eliminating the
1108
+ visible flicker the post-swap JS-applied class would otherwise
1109
+ cause when the new innerHTML lands without the class. -->
1047
1110
  <div id="dash" hx-get="/admin/dash"
1048
1111
  hx-trigger="load, every 1s [document.getSelection().isCollapsed]"
1049
- hx-swap="innerHTML">
1050
- {self.render_dash()}
1112
+ hx-swap="innerHTML"
1113
+ hx-headers='js:{{"X-Active-Tab": (location.hash || "").replace(/^#/, "")}}'>
1114
+ {self.render_dash(active_tab=(self.headers.get("X-Active-Tab") or "").strip())}
1051
1115
  </div>
1052
1116
 
1053
1117
  <!-- Tab activation. Applies an ``active-tab`` class to the
@@ -1087,7 +1151,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
1087
1151
  </script>
1088
1152
  </main></body></html>"""
1089
1153
 
1090
- def render_dash(self) -> str:
1154
+ def render_dash(self, active_tab: str = "") -> str:
1155
+ # Tab activation is baked into the rendered HTML so the htmx
1156
+ # innerHTML swap doesn't strip `.active-tab` between the
1157
+ # swap and the post-settle JS re-apply. The client sends
1158
+ # the current hash via the ``X-Active-Tab`` header on each
1159
+ # 1 Hz refresh (and on every operator click via the same
1160
+ # script that watches hashchange). Unknown / empty value
1161
+ # defaults to the first tab.
1162
+ _TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses")
1163
+ if active_tab not in _TAB_IDS:
1164
+ active_tab = _TAB_IDS[0]
1165
+
1166
+ def _active(tab_id: str) -> str:
1167
+ return " active-tab" if tab_id == active_tab else ""
1168
+
1091
1169
  nblobs, nmisses = self.store.counts()
1092
1170
  jobs = self.mgr.list()
1093
1171
  misses = self.store.list_misses()
@@ -1221,13 +1299,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
1221
1299
  <p><small>{nblobs} cached ({used}){full} &middot; {nmisses} pending miss(es)</small></p>
1222
1300
  {tab_style}
1223
1301
  <nav class="tabs"><ul>
1224
- <li><a href="#tab-cached">Cached ({nblobs})</a></li>
1225
- <li><a href="#tab-streams">Streams ({nstreams})</a></li>
1226
- <li><a href="#tab-downloads">Downloads ({njobs})</a></li>
1227
- <li><a href="#tab-misses">Misses ({nmisses})</a></li>
1302
+ <li><a href="#tab-cached" class="{_active("tab-cached").lstrip()}"
1303
+ >Cached ({nblobs})</a></li>
1304
+ <li><a href="#tab-streams" class="{_active("tab-streams").lstrip()}"
1305
+ >Streams ({nstreams})</a></li>
1306
+ <li><a href="#tab-downloads" class="{_active("tab-downloads").lstrip()}"
1307
+ >Downloads ({njobs})</a></li>
1308
+ <li><a href="#tab-misses" class="{_active("tab-misses").lstrip()}"
1309
+ >Misses ({nmisses})</a></li>
1228
1310
  </ul></nav>
1229
1311
 
1230
- <section id="tab-cached" class="tab">
1312
+ <section id="tab-cached" class="tab{_active("tab-cached")}">
1231
1313
  <figure><table class="striped">
1232
1314
  <thead><tr>
1233
1315
  <th>URL</th><th>Size</th><th class="num">Hits</th><th class="num">Misses</th>
@@ -1237,7 +1319,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1237
1319
  </table></figure>
1238
1320
  </section>
1239
1321
 
1240
- <section id="tab-streams" class="tab">
1322
+ <section id="tab-streams" class="tab{_active("tab-streams")}">
1241
1323
  <figure><table class="striped">
1242
1324
  <thead><tr>
1243
1325
  <th>URL</th><th>Client</th><th>Progress</th><th>Age</th>
@@ -1246,7 +1328,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1246
1328
  </table></figure>
1247
1329
  </section>
1248
1330
 
1249
- <section id="tab-downloads" class="tab">
1331
+ <section id="tab-downloads" class="tab{_active("tab-downloads")}">
1250
1332
  <div class="row">
1251
1333
  <small>Auto-fetch workers feeding the cache.</small>
1252
1334
  <form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" style="margin:0">
@@ -1260,7 +1342,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1260
1342
  </table></figure>
1261
1343
  </section>
1262
1344
 
1263
- <section id="tab-misses" class="tab">
1345
+ <section id="tab-misses" class="tab{_active("tab-misses")}">
1264
1346
  <figure><table class="striped">
1265
1347
  <thead><tr>
1266
1348
  <th>URL</th><th class="num">Misses</th><th>Last seen</th><th>Action</th>
@@ -13,6 +13,7 @@ import sys
13
13
  import tempfile
14
14
  import threading
15
15
  import time
16
+ import types
16
17
  import unittest
17
18
 
18
19
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
@@ -961,5 +962,106 @@ class TestClientLibraryAuthForwarding(unittest.TestCase):
961
962
  )
962
963
 
963
964
 
965
+ class TestOrasTagRevalidation(unittest.TestCase):
966
+ """server._oras_tag_moved: the store keys on the ref string, so a
967
+ re-pushed *mutable* tag must be detected and invalidated, while
968
+ digest-pinned refs and unreachable registries leave the cache intact."""
969
+
970
+ TAG = "oras://ghcr.io/safl/nosi/ubuntu-2604-headless:2026.W26"
971
+ HEX_A = "a" * 64
972
+ HEX_B = "b" * 64
973
+
974
+ @staticmethod
975
+ def _resolver(digest_hex):
976
+ def _r(_ref):
977
+ return types.SimpleNamespace(digest="sha256:" + digest_hex)
978
+
979
+ return _r
980
+
981
+ def test_moved_when_registry_digest_differs(self):
982
+ self.assertTrue(
983
+ server._oras_tag_moved(self.TAG, self.HEX_A, resolve=self._resolver(self.HEX_B))
984
+ )
985
+
986
+ def test_not_moved_when_digest_matches(self):
987
+ self.assertFalse(
988
+ server._oras_tag_moved(self.TAG, self.HEX_A, resolve=self._resolver(self.HEX_A))
989
+ )
990
+
991
+ def test_digest_match_is_case_insensitive(self):
992
+ self.assertFalse(
993
+ server._oras_tag_moved(self.TAG, self.HEX_A.upper(), resolve=self._resolver(self.HEX_A))
994
+ )
995
+
996
+ def test_digest_pinned_ref_never_revalidates(self):
997
+ pinned = "oras://ghcr.io/safl/nosi/x@sha256:" + self.HEX_A
998
+ calls = []
999
+
1000
+ def _r(ref):
1001
+ calls.append(ref)
1002
+ return types.SimpleNamespace(digest="sha256:" + self.HEX_B)
1003
+
1004
+ self.assertFalse(server._oras_tag_moved(pinned, self.HEX_A, resolve=_r))
1005
+ self.assertEqual(calls, [], "a digest-pinned ref must not hit the registry")
1006
+
1007
+ def test_non_oras_url_is_never_moved(self):
1008
+ self.assertFalse(
1009
+ server._oras_tag_moved(
1010
+ "https://h/x.img.gz", self.HEX_A, resolve=self._resolver(self.HEX_B)
1011
+ )
1012
+ )
1013
+
1014
+ def test_missing_cached_sha_keeps_entry(self):
1015
+ r = self._resolver(self.HEX_B)
1016
+ self.assertFalse(server._oras_tag_moved(self.TAG, "", resolve=r))
1017
+ self.assertFalse(server._oras_tag_moved(self.TAG, None, resolve=r))
1018
+
1019
+ def test_resolve_error_serves_cached(self):
1020
+ def _boom(_ref):
1021
+ raise OSError("registry unreachable")
1022
+
1023
+ self.assertFalse(server._oras_tag_moved(self.TAG, self.HEX_A, resolve=_boom))
1024
+
1025
+
1026
+ class TestDashActiveTabFromHeader(unittest.TestCase):
1027
+ """``GET /admin/dash`` bakes ``.active-tab`` into the rendered HTML
1028
+ based on the ``X-Active-Tab`` request header. The browser sends the
1029
+ current URL hash on every refresh so the htmx innerHTML swap doesn't
1030
+ visibly blink while a post-swap JS would otherwise re-apply the class.
1031
+ """
1032
+
1033
+ def setUp(self):
1034
+ self.httpd, self.store = _start_withcache()
1035
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
1036
+
1037
+ def tearDown(self):
1038
+ self.httpd.shutdown()
1039
+ self.httpd.server_close()
1040
+
1041
+ def _dash(self, active_tab=None):
1042
+ req = urllib.request.Request(self.base + "/admin/dash")
1043
+ if active_tab is not None:
1044
+ req.add_header("X-Active-Tab", active_tab)
1045
+ return urllib.request.urlopen(req).read().decode("utf-8")
1046
+
1047
+ def test_default_tab_when_header_missing(self):
1048
+ body = self._dash()
1049
+ # tab-cached is the first tab and the no-header default
1050
+ self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
1051
+ self.assertIn('<section id="tab-streams" class="tab">', body)
1052
+
1053
+ def test_header_picks_the_active_tab(self):
1054
+ body = self._dash("tab-misses")
1055
+ self.assertIn('<section id="tab-misses" class="tab active-tab">', body)
1056
+ self.assertIn('<section id="tab-cached" class="tab">', body)
1057
+
1058
+ def test_unknown_header_value_falls_back_to_first(self):
1059
+ """A hand-crafted X-Active-Tab with a bogus value must not echo
1060
+ into the HTML; the renderer falls back to the first tab."""
1061
+ body = self._dash("tab-totally-not-real")
1062
+ self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
1063
+ self.assertNotIn("tab-totally-not-real", body)
1064
+
1065
+
964
1066
  if __name__ == "__main__":
965
1067
  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