withcache 0.6.1__tar.gz → 0.6.3__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.1 → withcache-0.6.3}/PKG-INFO +15 -1
  2. {withcache-0.6.1 → withcache-0.6.3}/README.md +14 -0
  3. {withcache-0.6.1 → withcache-0.6.3}/shim/build.zig.zon +1 -1
  4. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/__init__.py +1 -1
  5. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/server.py +49 -18
  6. {withcache-0.6.1 → withcache-0.6.3}/tests/test_differential.py +1 -1
  7. {withcache-0.6.1 → withcache-0.6.3}/tests/test_oras.py +16 -10
  8. {withcache-0.6.1 → withcache-0.6.3}/tests/test_withcache.py +50 -12
  9. {withcache-0.6.1 → withcache-0.6.3}/.gitignore +0 -0
  10. {withcache-0.6.1 → withcache-0.6.3}/LICENSE +0 -0
  11. {withcache-0.6.1 → withcache-0.6.3}/deploy/Containerfile +0 -0
  12. {withcache-0.6.1 → withcache-0.6.3}/deploy/compose.yml +0 -0
  13. {withcache-0.6.1 → withcache-0.6.3}/hatch_build.py +0 -0
  14. {withcache-0.6.1 → withcache-0.6.3}/pyproject.toml +0 -0
  15. {withcache-0.6.1 → withcache-0.6.3}/shim/build.zig +0 -0
  16. {withcache-0.6.1 → withcache-0.6.3}/shim/shim.zig +0 -0
  17. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/_shim.py +0 -0
  18. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/client.py +0 -0
  19. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/curlwithcache.py +0 -0
  20. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/oras.py +0 -0
  21. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/static/htmx.min.js +0 -0
  22. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/static/pico.min.css +0 -0
  23. {withcache-0.6.1 → withcache-0.6.3}/src/withcache/wgetwithcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.6.1
3
+ Version: 0.6.3
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>
@@ -285,6 +285,20 @@ the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
285
285
  next call flips to the cache. The encoding is shared with the shims and server,
286
286
  so consumers stay in lockstep with the cache-host.
287
287
 
288
+ ### Pull an `oras://` artifact (oras + client together)
289
+
290
+ For a registry blob, pair `withcache.oras` (resolve the reference to a blob URL
291
+ + bearer) with `client.serve_url` (prefer the cache when warm). A runnable
292
+ example is in [`examples/pull_oras_blob.py`](examples/pull_oras_blob.py):
293
+
294
+ ```python
295
+ from withcache import client, oras
296
+
297
+ resolved = oras.resolve_ref("oras://ghcr.io/<owner>/<repo>@sha256:<digest>")
298
+ url, headers = resolved.blob_url, dict(resolved.headers)
299
+ url = client.serve_url(server, url, headers=headers) or url # cache when warm
300
+ ```
301
+
288
302
  ## Tests
289
303
 
290
304
  ```sh
@@ -267,6 +267,20 @@ the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
267
267
  next call flips to the cache. The encoding is shared with the shims and server,
268
268
  so consumers stay in lockstep with the cache-host.
269
269
 
270
+ ### Pull an `oras://` artifact (oras + client together)
271
+
272
+ For a registry blob, pair `withcache.oras` (resolve the reference to a blob URL
273
+ + bearer) with `client.serve_url` (prefer the cache when warm). A runnable
274
+ example is in [`examples/pull_oras_blob.py`](examples/pull_oras_blob.py):
275
+
276
+ ```python
277
+ from withcache import client, oras
278
+
279
+ resolved = oras.resolve_ref("oras://ghcr.io/<owner>/<repo>@sha256:<digest>")
280
+ url, headers = resolved.blob_url, dict(resolved.headers)
281
+ url = client.serve_url(server, url, headers=headers) or url # cache when warm
282
+ ```
283
+
270
284
  ## Tests
271
285
 
272
286
  ```sh
@@ -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.1",
5
+ .version = "0.6.3",
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.1"
20
+ __version__ = "0.6.3"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -22,6 +22,7 @@ admin password is set, the operator UI is left open (with a startup warning).
22
22
 
23
23
  import argparse
24
24
  import base64
25
+ import contextlib
25
26
  import hashlib
26
27
  import hmac
27
28
  import html
@@ -40,6 +41,7 @@ import urllib.parse
40
41
  import urllib.request
41
42
  from dataclasses import dataclass, field
42
43
  from datetime import datetime, timezone
44
+ from typing import ClassVar
43
45
 
44
46
  from . import __version__, oras
45
47
 
@@ -304,10 +306,8 @@ class Store:
304
306
  """Drop a cached artifact (row + bytes). The manual half of eviction."""
305
307
  with _DB_WRITE_LOCK, self.conn() as c:
306
308
  c.execute("DELETE FROM blobs WHERE key=?", (key,))
307
- try:
309
+ with contextlib.suppress(FileNotFoundError):
308
310
  os.remove(self.blob_path(key))
309
- except FileNotFoundError:
310
- pass
311
311
 
312
312
  def store_from_origin(
313
313
  self,
@@ -471,7 +471,6 @@ class Store:
471
471
  # --------------------------------------------------------------------------
472
472
  # Background download manager (thread pool; modelled on bty's job managers)
473
473
  # --------------------------------------------------------------------------
474
- JOB_STATES = ("queued", "running", "completed", "cancelled", "failed")
475
474
  PENDING_STATES = frozenset(("queued", "running"))
476
475
 
477
476
 
@@ -757,7 +756,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
757
756
  if not self.is_authed():
758
757
  self.send_text(401, "login required\n")
759
758
  else:
760
- self.send_html(200, self.render_dash())
759
+ # The browser sends the current URL hash (the active
760
+ # tab id) via the ``X-Active-Tab`` request header on
761
+ # every 1 Hz refresh. Server bakes the matching
762
+ # ``.active-tab`` class directly into the rendered
763
+ # HTML so the htmx innerHTML swap doesn't visibly
764
+ # blink while the post-swap JS would otherwise
765
+ # re-apply the class. See render_dash().
766
+ active = (self.headers.get("X-Active-Tab") or "").strip()
767
+ self.send_html(200, self.render_dash(active_tab=active))
761
768
  elif parsed.path == "/":
762
769
  if not self.is_authed():
763
770
  self.redirect("/ui/login")
@@ -1004,7 +1011,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1004
1011
  self.end_headers()
1005
1012
 
1006
1013
  # -- HTML --------------------------------------------------------------
1007
- STATUS_COLORS = {
1014
+ STATUS_COLORS: ClassVar[dict[str, str]] = {
1008
1015
  "queued": "#888",
1009
1016
  "running": "var(--pico-primary, #0172ad)",
1010
1017
  "completed": "#2e7d32",
@@ -1093,11 +1100,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
1093
1100
  isn't wiped by the 1 Hz refresh. ``isCollapsed`` is true when
1094
1101
  there's no selection or the caret is a zero-width point; once
1095
1102
  the operator releases / clears the selection polling resumes
1096
- on the next 1 s tick. -->
1103
+ on the next 1 s tick.
1104
+ ``hx-headers`` sends the current URL hash (the active tab id)
1105
+ as ``X-Active-Tab`` on every refresh so the server can bake
1106
+ ``.active-tab`` into the rendered HTML -- eliminating the
1107
+ visible flicker the post-swap JS-applied class would otherwise
1108
+ cause when the new innerHTML lands without the class. -->
1097
1109
  <div id="dash" hx-get="/admin/dash"
1098
1110
  hx-trigger="load, every 1s [document.getSelection().isCollapsed]"
1099
- hx-swap="innerHTML">
1100
- {self.render_dash()}
1111
+ hx-swap="innerHTML"
1112
+ hx-headers='js:{{"X-Active-Tab": (location.hash || "").replace(/^#/, "")}}'>
1113
+ {self.render_dash(active_tab=(self.headers.get("X-Active-Tab") or "").strip())}
1101
1114
  </div>
1102
1115
 
1103
1116
  <!-- Tab activation. Applies an ``active-tab`` class to the
@@ -1137,7 +1150,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
1137
1150
  </script>
1138
1151
  </main></body></html>"""
1139
1152
 
1140
- def render_dash(self) -> str:
1153
+ def render_dash(self, active_tab: str = "") -> str:
1154
+ # Tab activation is baked into the rendered HTML so the htmx
1155
+ # innerHTML swap doesn't strip `.active-tab` between the
1156
+ # swap and the post-settle JS re-apply. The client sends
1157
+ # the current hash via the ``X-Active-Tab`` header on each
1158
+ # 1 Hz refresh (and on every operator click via the same
1159
+ # script that watches hashchange). Unknown / empty value
1160
+ # defaults to the first tab.
1161
+ _TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses")
1162
+ if active_tab not in _TAB_IDS:
1163
+ active_tab = _TAB_IDS[0]
1164
+
1165
+ def _active(tab_id: str) -> str:
1166
+ return " active-tab" if tab_id == active_tab else ""
1167
+
1141
1168
  nblobs, nmisses = self.store.counts()
1142
1169
  jobs = self.mgr.list()
1143
1170
  misses = self.store.list_misses()
@@ -1271,13 +1298,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
1271
1298
  <p><small>{nblobs} cached ({used}){full} &middot; {nmisses} pending miss(es)</small></p>
1272
1299
  {tab_style}
1273
1300
  <nav class="tabs"><ul>
1274
- <li><a href="#tab-cached">Cached ({nblobs})</a></li>
1275
- <li><a href="#tab-streams">Streams ({nstreams})</a></li>
1276
- <li><a href="#tab-downloads">Downloads ({njobs})</a></li>
1277
- <li><a href="#tab-misses">Misses ({nmisses})</a></li>
1301
+ <li><a href="#tab-cached" class="{_active("tab-cached").lstrip()}"
1302
+ >Cached ({nblobs})</a></li>
1303
+ <li><a href="#tab-streams" class="{_active("tab-streams").lstrip()}"
1304
+ >Streams ({nstreams})</a></li>
1305
+ <li><a href="#tab-downloads" class="{_active("tab-downloads").lstrip()}"
1306
+ >Downloads ({njobs})</a></li>
1307
+ <li><a href="#tab-misses" class="{_active("tab-misses").lstrip()}"
1308
+ >Misses ({nmisses})</a></li>
1278
1309
  </ul></nav>
1279
1310
 
1280
- <section id="tab-cached" class="tab">
1311
+ <section id="tab-cached" class="tab{_active("tab-cached")}">
1281
1312
  <figure><table class="striped">
1282
1313
  <thead><tr>
1283
1314
  <th>URL</th><th>Size</th><th class="num">Hits</th><th class="num">Misses</th>
@@ -1287,7 +1318,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1287
1318
  </table></figure>
1288
1319
  </section>
1289
1320
 
1290
- <section id="tab-streams" class="tab">
1321
+ <section id="tab-streams" class="tab{_active("tab-streams")}">
1291
1322
  <figure><table class="striped">
1292
1323
  <thead><tr>
1293
1324
  <th>URL</th><th>Client</th><th>Progress</th><th>Age</th>
@@ -1296,7 +1327,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1296
1327
  </table></figure>
1297
1328
  </section>
1298
1329
 
1299
- <section id="tab-downloads" class="tab">
1330
+ <section id="tab-downloads" class="tab{_active("tab-downloads")}">
1300
1331
  <div class="row">
1301
1332
  <small>Auto-fetch workers feeding the cache.</small>
1302
1333
  <form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" style="margin:0">
@@ -1310,7 +1341,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1310
1341
  </table></figure>
1311
1342
  </section>
1312
1343
 
1313
- <section id="tab-misses" class="tab">
1344
+ <section id="tab-misses" class="tab{_active("tab-misses")}">
1314
1345
  <figure><table class="striped">
1315
1346
  <thead><tr>
1316
1347
  <th>URL</th><th class="num">Misses</th><th>Last seen</th><th>Action</th>
@@ -15,7 +15,7 @@ import unittest
15
15
  HERE = os.path.dirname(__file__)
16
16
  sys.path.insert(0, os.path.join(HERE, "..", "src"))
17
17
 
18
- from withcache import _shim # noqa: E402
18
+ from withcache import _shim
19
19
 
20
20
  SHIM = os.environ.get(
21
21
  "WITHCACHE_SHIM", os.path.join(HERE, "..", "shim", "zig-out", "bin", "withcache-shim")
@@ -21,7 +21,7 @@ from unittest.mock import patch
21
21
 
22
22
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
23
23
 
24
- from withcache import oras, server # noqa: E402
24
+ from withcache import oras, server
25
25
 
26
26
  # A trimmed-down version of a real nosi manifest -- two layers (the
27
27
  # .img.gz and a .sha256 sidecar), one annotation each, OCI media types.
@@ -304,9 +304,11 @@ class TestResolveRef(unittest.TestCase):
304
304
  def _failing(req, timeout=None):
305
305
  raise OSError("network unreachable")
306
306
 
307
- with patch("urllib.request.urlopen", _failing):
308
- with self.assertRaisesRegex(oras.OrasError, "token fetch failed"):
309
- oras.resolve_ref("oras://ghcr.io/safl/nosi/debian-sysdev:latest")
307
+ with (
308
+ patch("urllib.request.urlopen", _failing),
309
+ self.assertRaisesRegex(oras.OrasError, "token fetch failed"),
310
+ ):
311
+ oras.resolve_ref("oras://ghcr.io/safl/nosi/debian-sysdev:latest")
310
312
 
311
313
 
312
314
  class TestIsOrasUrl(unittest.TestCase):
@@ -341,9 +343,11 @@ class TestUrlopenRetry(unittest.TestCase):
341
343
  calls["n"] += 1
342
344
  raise _http_error(404)
343
345
 
344
- with patch("urllib.request.urlopen", _notfound):
345
- with self.assertRaises(urllib.error.HTTPError):
346
- oras._urlopen_retry("https://x/y", timeout=5)
346
+ with (
347
+ patch("urllib.request.urlopen", _notfound),
348
+ self.assertRaises(urllib.error.HTTPError),
349
+ ):
350
+ oras._urlopen_retry("https://x/y", timeout=5)
347
351
  self.assertEqual(calls["n"], 1)
348
352
 
349
353
  def test_exhausts_then_reraises(self):
@@ -354,9 +358,11 @@ class TestUrlopenRetry(unittest.TestCase):
354
358
  calls["n"] += 1
355
359
  raise _http_error(503)
356
360
 
357
- with patch("urllib.request.urlopen", _down):
358
- with self.assertRaises(urllib.error.HTTPError):
359
- oras._urlopen_retry("https://x/y", timeout=5)
361
+ with (
362
+ patch("urllib.request.urlopen", _down),
363
+ self.assertRaises(urllib.error.HTTPError),
364
+ ):
365
+ oras._urlopen_retry("https://x/y", timeout=5)
360
366
  self.assertEqual(calls["n"], oras._RETRY_ATTEMPTS)
361
367
 
362
368
 
@@ -4,7 +4,9 @@ No third-party test deps; src/ is put on the path so the package imports
4
4
  without an install.
5
5
  """
6
6
 
7
+ import contextlib
7
8
  import http.server
9
+ import itertools
8
10
  import os
9
11
  import shutil
10
12
  import socket
@@ -18,11 +20,11 @@ import unittest
18
20
 
19
21
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
20
22
 
21
- import base64 # noqa: E402
22
- import urllib.error # noqa: E402
23
- import urllib.request # noqa: E402
23
+ import base64
24
+ import urllib.error
25
+ import urllib.request
24
26
 
25
- from withcache import _shim, client, curlwithcache, server, wgetwithcache # noqa: E402
27
+ from withcache import _shim, client, curlwithcache, server, wgetwithcache
26
28
 
27
29
 
28
30
  # --------------------------------------------------------------------------
@@ -187,10 +189,8 @@ class _TruncatingOrigin(http.server.BaseHTTPRequestHandler):
187
189
  # close the underlying socket so urllib observes EOF before
188
190
  # Content-Length bytes arrive
189
191
  self.wfile.flush()
190
- try:
192
+ with contextlib.suppress(OSError):
191
193
  self.connection.shutdown(socket.SHUT_RDWR)
192
- except OSError:
193
- pass
194
194
 
195
195
  def log_message(self, format, *args):
196
196
  pass
@@ -307,10 +307,8 @@ class _ResumableTruncatingOrigin(http.server.BaseHTTPRequestHandler):
307
307
  half = full // 2
308
308
  self.wfile.write(self.PAYLOAD[:half])
309
309
  self.wfile.flush()
310
- try:
310
+ with contextlib.suppress(OSError):
311
311
  self.connection.shutdown(socket.SHUT_RDWR)
312
- except OSError:
313
- pass
314
312
  else:
315
313
  # any non-ranged retry serves the whole thing (covers the
316
314
  # 200-on-Range fallback path: origin ignored Range, we
@@ -362,7 +360,7 @@ class TestRangeResumeOnTruncation(unittest.TestCase):
362
360
  # final report should be the full payload
363
361
  self.assertEqual(observed[-1][0], len(_ResumableTruncatingOrigin.PAYLOAD))
364
362
  # at no point did the byte counter regress
365
- for prev, curr in zip(observed, observed[1:], strict=False):
363
+ for prev, curr in itertools.pairwise(observed):
366
364
  self.assertGreaterEqual(curr[0], prev[0])
367
365
  # the resume actually crossed the cut point: at least one
368
366
  # progress call lands above the half-mark (otherwise we
@@ -732,7 +730,7 @@ class TestAutoFetchOnMiss(unittest.TestCase):
732
730
  urllib.request.urlopen(_shim.blob_url(base, self.origin_url))
733
731
 
734
732
  def test_miss_schedules_pull_by_default(self):
735
- httpd, store = _start_withcache(auto_fetch=True)
733
+ httpd, _store = _start_withcache(auto_fetch=True)
736
734
  try:
737
735
  self._miss(httpd)
738
736
  # the miss enqueued a background pull, no operator needed
@@ -1023,5 +1021,45 @@ class TestOrasTagRevalidation(unittest.TestCase):
1023
1021
  self.assertFalse(server._oras_tag_moved(self.TAG, self.HEX_A, resolve=_boom))
1024
1022
 
1025
1023
 
1024
+ class TestDashActiveTabFromHeader(unittest.TestCase):
1025
+ """``GET /admin/dash`` bakes ``.active-tab`` into the rendered HTML
1026
+ based on the ``X-Active-Tab`` request header. The browser sends the
1027
+ current URL hash on every refresh so the htmx innerHTML swap doesn't
1028
+ visibly blink while a post-swap JS would otherwise re-apply the class.
1029
+ """
1030
+
1031
+ def setUp(self):
1032
+ self.httpd, self.store = _start_withcache()
1033
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
1034
+
1035
+ def tearDown(self):
1036
+ self.httpd.shutdown()
1037
+ self.httpd.server_close()
1038
+
1039
+ def _dash(self, active_tab=None):
1040
+ req = urllib.request.Request(self.base + "/admin/dash")
1041
+ if active_tab is not None:
1042
+ req.add_header("X-Active-Tab", active_tab)
1043
+ return urllib.request.urlopen(req).read().decode("utf-8")
1044
+
1045
+ def test_default_tab_when_header_missing(self):
1046
+ body = self._dash()
1047
+ # tab-cached is the first tab and the no-header default
1048
+ self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
1049
+ self.assertIn('<section id="tab-streams" class="tab">', body)
1050
+
1051
+ def test_header_picks_the_active_tab(self):
1052
+ body = self._dash("tab-misses")
1053
+ self.assertIn('<section id="tab-misses" class="tab active-tab">', body)
1054
+ self.assertIn('<section id="tab-cached" class="tab">', body)
1055
+
1056
+ def test_unknown_header_value_falls_back_to_first(self):
1057
+ """A hand-crafted X-Active-Tab with a bogus value must not echo
1058
+ into the HTML; the renderer falls back to the first tab."""
1059
+ body = self._dash("tab-totally-not-real")
1060
+ self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
1061
+ self.assertNotIn("tab-totally-not-real", body)
1062
+
1063
+
1026
1064
  if __name__ == "__main__":
1027
1065
  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