withcache 0.10.0__tar.gz → 0.12.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 (49) hide show
  1. {withcache-0.10.0 → withcache-0.12.0}/PKG-INFO +22 -21
  2. {withcache-0.10.0 → withcache-0.12.0}/README.md +21 -20
  3. {withcache-0.10.0 → withcache-0.12.0}/shim/build.zig.zon +1 -1
  4. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/__init__.py +5 -4
  5. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_api.py +33 -50
  6. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_app.py +349 -145
  7. withcache-0.12.0/src/withcache/_table_state.py +152 -0
  8. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_templates/ui/_layout.html +12 -20
  9. withcache-0.12.0/src/withcache/_templates/ui/_table_macros.html +126 -0
  10. withcache-0.12.0/src/withcache/_templates/ui/catalog.html +195 -0
  11. withcache-0.12.0/src/withcache/_templates/ui/dashboard.html +142 -0
  12. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_templates/ui/misses.html +32 -15
  13. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_templates/ui/settings.html +84 -47
  14. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/client.py +11 -15
  15. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/server.py +3 -63
  16. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_admin_forms.py +145 -53
  17. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_catalog_api.py +40 -8
  18. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_scaffold.py +11 -11
  19. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_settings_persistence.py +16 -16
  20. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_ui_pages.py +48 -28
  21. {withcache-0.10.0 → withcache-0.12.0}/tests/test_withcache.py +0 -83
  22. withcache-0.10.0/src/withcache/_templates/ui/cached.html +0 -78
  23. withcache-0.10.0/src/withcache/_templates/ui/catalog.html +0 -269
  24. withcache-0.10.0/src/withcache/_templates/ui/downloads.html +0 -100
  25. {withcache-0.10.0 → withcache-0.12.0}/.gitignore +0 -0
  26. {withcache-0.10.0 → withcache-0.12.0}/LICENSE +0 -0
  27. {withcache-0.10.0 → withcache-0.12.0}/deploy/Containerfile +0 -0
  28. {withcache-0.10.0 → withcache-0.12.0}/deploy/compose.local-build.yml +0 -0
  29. {withcache-0.10.0 → withcache-0.12.0}/deploy/compose.yml +0 -0
  30. {withcache-0.10.0 → withcache-0.12.0}/deploy/envvars.example +0 -0
  31. {withcache-0.10.0 → withcache-0.12.0}/hatch_build.py +0 -0
  32. {withcache-0.10.0 → withcache-0.12.0}/pyproject.toml +0 -0
  33. {withcache-0.10.0 → withcache-0.12.0}/shim/build.zig +0 -0
  34. {withcache-0.10.0 → withcache-0.12.0}/shim/shim.zig +0 -0
  35. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_settings_store.py +0 -0
  36. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_shim.py +0 -0
  37. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/_templates/ui/login.html +0 -0
  38. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/curlwithcache.py +0 -0
  39. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/oras.py +0 -0
  40. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  41. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/static/bootstrap.min.css +0 -0
  42. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  43. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  44. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/static/htmx.min.js +0 -0
  45. {withcache-0.10.0 → withcache-0.12.0}/src/withcache/wgetwithcache.py +0 -0
  46. {withcache-0.10.0 → withcache-0.12.0}/tests/test_differential.py +0 -0
  47. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_blob.py +0 -0
  48. {withcache-0.10.0 → withcache-0.12.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  49. {withcache-0.10.0 → withcache-0.12.0}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.10.0
3
+ Version: 0.12.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>
@@ -46,21 +46,23 @@ Artifacts are cached **by their origin URL as a key**; the shim opts in by
46
46
  re-pointing the URL at the cache. No transparent proxy, no TLS interception, no
47
47
  client CA. The URL is a lookup key, not a connection target.
48
48
 
49
- By default a miss is **auto-fetched**: the request falls through to origin (so
50
- the caller gets its file straight away), and the cache-host pulls the same
51
- artifact in the background, so the next request hits. Run with **`--curate`** to
52
- require a human instead, who reviews the miss list in a small web UI and presses
53
- **Download** (or pre-seeds via the Downloads-page **Fetch** form). Either way the cache-host is the
54
- only box that needs internet egress (and any vendor credentials), and clients
55
- never write to it.
49
+ A miss falls through to origin (the caller gets its file straight away) and
50
+ withcache records the miss on the operator's **`/ui/misses`** page. The operator
51
+ reviews the miss list, picks what's worth caching, and one click **Fetch**s a URL
52
+ into the catalog + downloads it. Every entry in the catalog is a byte-perfect
53
+ copy of the origin at the time the operator hit Download; subsequent requests
54
+ hit the local cache. The cache-host is the only box that needs internet egress
55
+ (and any vendor credentials); clients never write to it.
56
56
 
57
57
  ## Why not just curl + a caching proxy?
58
58
 
59
59
  For `https://` (i.e. every vendor download) a forward proxy can't cache without
60
60
  **SSL-bump / MITM**: curl tunnels TLS end-to-end via `CONNECT`, so the proxy
61
61
  only sees ciphertext. The shim sidesteps that entirely by *re-pointing the URL*
62
- to the cache instead of intercepting the connection. And no proxy offers the
63
- optional **operator-curated** model (`--curate`: a miss queue a human approves).
62
+ to the cache instead of intercepting the connection. And a proxy that
63
+ auto-fetches everything a client asks for isn't what you want in a lab; the
64
+ **operator-curated** model here means only bytes the operator chose live on
65
+ disk.
64
66
 
65
67
  ## Components
66
68
 
@@ -117,9 +119,8 @@ WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port 808
117
119
  Data (blobs + `cache.db` + `session-secret`) lives in the `/data` volume (or
118
120
  `--data-dir`). Artifacts are immutable per version, so there's no cache
119
121
  invalidation. `--workers N` sets the number of concurrent download workers,
120
- `--curate` switches from auto-fetch to operator-approved pulls, and `--max-bytes`
121
- (e.g. `50G`) caps the cache: when full it refuses new fills (no auto-eviction),
122
- and you free space by deleting artifacts in the UI.
122
+ `--max-bytes` (e.g. `50G`) caps the cache: when full it refuses new fills (no
123
+ auto-eviction), and you free space by deleting artifacts in the UI.
123
124
 
124
125
  ## Use the shims (transparent `curl` / `wget`)
125
126
 
@@ -245,10 +246,10 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
245
246
  `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
246
247
  offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
247
248
  - **Cached** (landing): URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
248
- - **Streams**: in-flight stream-through-and-store fetches serving bytes to a client while writing to disk.
249
- - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers. The subnav Fetch form pre-seeds an artifact before anyone misses it.
250
- - **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
251
- - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" input.
249
+ - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
250
+ - **Misses**: URLs clients asked for that aren't downloaded yet. Each with **Fetch** (promotes to a catalog entry AND downloads it) and **Dismiss** (forget it).
251
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" or "Add HTTPS entry" forms, then **Download** each entry.
252
+ - **Settings**: identity + storage paths + catalog source + logging (uvicorn level) + auth.
252
253
 
253
254
  ## Auth
254
255
 
@@ -288,10 +289,10 @@ url = client.serve_url("http://cache:8081", origin) or origin
288
289
  ```
289
290
 
290
291
  `is_cached()` is a graceful `HEAD` (a miss, timeout, or unreachable cache all
291
- return `False`, so you fall back to the origin), and it doubles as a warm-up:
292
- the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
293
- next call flips to the cache. The encoding is shared with the shims and server,
294
- so consumers stay in lockstep with the cache-host.
292
+ return `False`, so you fall back to the origin). Since v0.10.0 a miss is
293
+ recorded on the cache-host's `/ui/misses` page but no background fetch fires
294
+ -- the operator explicitly chooses what to Download. The encoding is shared
295
+ with the shims and server, so consumers stay in lockstep with the cache-host.
295
296
 
296
297
  ### Pull an `oras://` artifact (oras + client together)
297
298
 
@@ -23,21 +23,23 @@ Artifacts are cached **by their origin URL as a key**; the shim opts in by
23
23
  re-pointing the URL at the cache. No transparent proxy, no TLS interception, no
24
24
  client CA. The URL is a lookup key, not a connection target.
25
25
 
26
- By default a miss is **auto-fetched**: the request falls through to origin (so
27
- the caller gets its file straight away), and the cache-host pulls the same
28
- artifact in the background, so the next request hits. Run with **`--curate`** to
29
- require a human instead, who reviews the miss list in a small web UI and presses
30
- **Download** (or pre-seeds via the Downloads-page **Fetch** form). Either way the cache-host is the
31
- only box that needs internet egress (and any vendor credentials), and clients
32
- never write to it.
26
+ A miss falls through to origin (the caller gets its file straight away) and
27
+ withcache records the miss on the operator's **`/ui/misses`** page. The operator
28
+ reviews the miss list, picks what's worth caching, and one click **Fetch**s a URL
29
+ into the catalog + downloads it. Every entry in the catalog is a byte-perfect
30
+ copy of the origin at the time the operator hit Download; subsequent requests
31
+ hit the local cache. The cache-host is the only box that needs internet egress
32
+ (and any vendor credentials); clients never write to it.
33
33
 
34
34
  ## Why not just curl + a caching proxy?
35
35
 
36
36
  For `https://` (i.e. every vendor download) a forward proxy can't cache without
37
37
  **SSL-bump / MITM**: curl tunnels TLS end-to-end via `CONNECT`, so the proxy
38
38
  only sees ciphertext. The shim sidesteps that entirely by *re-pointing the URL*
39
- to the cache instead of intercepting the connection. And no proxy offers the
40
- optional **operator-curated** model (`--curate`: a miss queue a human approves).
39
+ to the cache instead of intercepting the connection. And a proxy that
40
+ auto-fetches everything a client asks for isn't what you want in a lab; the
41
+ **operator-curated** model here means only bytes the operator chose live on
42
+ disk.
41
43
 
42
44
  ## Components
43
45
 
@@ -94,9 +96,8 @@ WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port 808
94
96
  Data (blobs + `cache.db` + `session-secret`) lives in the `/data` volume (or
95
97
  `--data-dir`). Artifacts are immutable per version, so there's no cache
96
98
  invalidation. `--workers N` sets the number of concurrent download workers,
97
- `--curate` switches from auto-fetch to operator-approved pulls, and `--max-bytes`
98
- (e.g. `50G`) caps the cache: when full it refuses new fills (no auto-eviction),
99
- and you free space by deleting artifacts in the UI.
99
+ `--max-bytes` (e.g. `50G`) caps the cache: when full it refuses new fills (no
100
+ auto-eviction), and you free space by deleting artifacts in the UI.
100
101
 
101
102
  ## Use the shims (transparent `curl` / `wget`)
102
103
 
@@ -222,10 +223,10 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
222
223
  `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
223
224
  offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
224
225
  - **Cached** (landing): URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
225
- - **Streams**: in-flight stream-through-and-store fetches serving bytes to a client while writing to disk.
226
- - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers. The subnav Fetch form pre-seeds an artifact before anyone misses it.
227
- - **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
228
- - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" input.
226
+ - **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
227
+ - **Misses**: URLs clients asked for that aren't downloaded yet. Each with **Fetch** (promotes to a catalog entry AND downloads it) and **Dismiss** (forget it).
228
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configurable via `$WITHCACHE_CATALOG_URL` or the subnav Set&fetch input); pre-seed by URL via the "Add image from oras" or "Add HTTPS entry" forms, then **Download** each entry.
229
+ - **Settings**: identity + storage paths + catalog source + logging (uvicorn level) + auth.
229
230
 
230
231
  ## Auth
231
232
 
@@ -265,10 +266,10 @@ url = client.serve_url("http://cache:8081", origin) or origin
265
266
  ```
266
267
 
267
268
  `is_cached()` is a graceful `HEAD` (a miss, timeout, or unreachable cache all
268
- return `False`, so you fall back to the origin), and it doubles as a warm-up:
269
- the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
270
- next call flips to the cache. The encoding is shared with the shims and server,
271
- so consumers stay in lockstep with the cache-host.
269
+ return `False`, so you fall back to the origin). Since v0.10.0 a miss is
270
+ recorded on the cache-host's `/ui/misses` page but no background fetch fires
271
+ -- the operator explicitly chooses what to Download. The encoding is shared
272
+ with the shims and server, so consumers stay in lockstep with the cache-host.
272
273
 
273
274
  ### Pull an `oras://` artifact (oras + client together)
274
275
 
@@ -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.10.0",
5
+ .version = "0.12.0",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -7,9 +7,10 @@
7
7
  a cache-host (build serve URLs, probe what's cached) without re-implementing
8
8
  the ``/b/`` URL scheme.
9
9
  - ``withcache.oras``: OCI registry adapter. Parses ``oras://...`` references
10
- and resolves them to a plain HTTPS blob URL + bearer headers. The cache-host
11
- uses it on a cold miss; library consumers (e.g. ``bty``) import it to
12
- validate catalog entries and pre-resolve content digests.
10
+ and resolves them to a plain HTTPS blob URL + bearer headers. The
11
+ cache-host's DownloadManager invokes it when an operator hits Download
12
+ on an ``oras://`` catalog entry; library consumers (e.g. ``bty``) import
13
+ it to validate catalog entries and pre-resolve content digests.
13
14
 
14
15
  Since v0.9.0 the daemon runs on FastAPI + Jinja + Bootstrap 5 + htmx
15
16
  (matching bty-web + nbdmux, the eventual ``trio-common`` extraction
@@ -21,6 +22,6 @@ inherit the framework floor.
21
22
  from . import oras
22
23
  from .client import blob_url, cache_base, is_cached, serve_url
23
24
 
24
- __version__ = "0.10.0"
25
+ __version__ = "0.12.0"
25
26
 
26
27
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -11,15 +11,15 @@ stay byte-identical:
11
11
  decorative; the b64 token is the source of truth.
12
12
  - Cache hit -> 200 + ``Content-Type`` + ``Content-Length`` +
13
13
  ``X-Withcache-Sha256`` + streamed body (64 KiB chunks).
14
- - Cache miss -> 404 with ``cache miss (recorded)`` body,
15
- side-effect of enqueueing a background fetch when
16
- ``auto_fetch`` is on and the store has capacity.
17
- - HEAD support: same headers, no body (probes from
18
- bty.web._withcache.is_cached use HEAD).
19
- - ``Authorization`` request header forwarded onto the background
20
- fetch worker so a token-gated origin (typical use case: an OCI
21
- bearer minted by bty at catalog import time) can be pulled by
22
- the worker with the same credential.
14
+ - Cache miss -> 404 with a message pointing at ``/ui/catalog``'s
15
+ Download button, plus a side-effect ``record_miss`` write so the
16
+ ``/ui/misses`` page shows the operator what clients tried to
17
+ fetch that isn't in the catalog yet.
18
+
19
+ Since v0.10.0 there is no auto-fetch on miss: the operator
20
+ explicitly Downloads. The Miss page's Fetch button (retargeted in
21
+ v0.11.0) promotes a missed URL to a catalog entry + enqueues the
22
+ Download in one click.
23
23
 
24
24
  Streaming shape: FastAPI ``StreamingResponse`` with a generator
25
25
  that reads chunks from the on-disk blob at 64 KiB reads;
@@ -37,7 +37,7 @@ from typing import Any
37
37
  from fastapi import Depends, FastAPI, HTTPException, Request
38
38
  from fastapi.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
39
39
 
40
- from .server import CHUNK, CatalogState, StreamRegistry, _oras_tag_moved, _serialise_catalog
40
+ from .server import CHUNK, CatalogState, _oras_tag_moved, _serialise_catalog
41
41
 
42
42
 
43
43
  def _decode_blob_origin(path: str, query: str) -> str:
@@ -78,7 +78,6 @@ def _serve_blob(
78
78
  return PlainTextResponse("missing url\n", status_code=400)
79
79
 
80
80
  store = request.app.state.store
81
- streams: StreamRegistry = request.app.state.streams
82
81
 
83
82
  row = store.get_blob(url)
84
83
  if row is not None and _oras_tag_moved(url, row["sha256"]):
@@ -105,35 +104,20 @@ def _serve_blob(
105
104
 
106
105
  if head_only:
107
106
  # No body; the shim's HEAD probe doesn't count as a served
108
- # download so record_hit + stream registration are skipped.
107
+ # download so record_hit is skipped.
109
108
  return Response(status_code=200, media_type=media_type, headers=headers)
110
109
 
111
110
  store.record_hit(row["key"])
112
- client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
113
- stream = streams.start(url=url, client=client, total=row["size"])
114
111
 
115
112
  def _chunks() -> Iterator[bytes]:
116
113
  """Read the blob in 64 KiB chunks. StreamingResponse iterates
117
- this + writes each chunk to the client. Progress ticks fire
118
- every :data:`StreamRegistry.PROGRESS_STRIDE` chunks so the
119
- dashboard's 1 Hz refresh sees updates without lock-contention
120
- on a busy box."""
121
- sent = 0
122
- ticks = 0
123
- try:
124
- with open(path, "rb") as f:
125
- while True:
126
- chunk = f.read(CHUNK)
127
- if not chunk:
128
- break
129
- yield chunk
130
- sent += len(chunk)
131
- ticks += 1
132
- if ticks % StreamRegistry.PROGRESS_STRIDE == 0:
133
- streams.bump(stream.id, sent)
134
- streams.bump(stream.id, sent)
135
- finally:
136
- streams.finish(stream.id)
114
+ this + writes each chunk to the client."""
115
+ with open(path, "rb") as f:
116
+ while True:
117
+ chunk = f.read(CHUNK)
118
+ if not chunk:
119
+ break
120
+ yield chunk
137
121
 
138
122
  return StreamingResponse(_chunks(), media_type=media_type, headers=headers)
139
123
 
@@ -141,10 +125,9 @@ def _serve_blob(
141
125
  def register_api_routes(app: FastAPI) -> None:
142
126
  """Attach the byte-serving routes to ``app``.
143
127
 
144
- Runtime objects (``store``, ``mgr``, ``streams``, ``auto_fetch``)
145
- are read from ``app.state`` at request time so tests + the
146
- lifespan hook can inject fresh instances without rebuilding
147
- the app.
128
+ Runtime objects (``store``, ``mgr``, ``streams``) are read from
129
+ ``app.state`` at request time so tests + the lifespan hook can
130
+ inject fresh instances without rebuilding the app.
148
131
  """
149
132
 
150
133
  @app.get("/blob", response_class=Response)
@@ -212,25 +195,25 @@ def register_api_routes(app: FastAPI) -> None:
212
195
 
213
196
  Open route: bty polls this from a sibling container without a
214
197
  session (mirrors the ``/blob`` byte-serving surface's trust
215
- model -- LAN-only, no auth on reads). Returns a snapshot of
216
- the in-process ``CatalogState.entries`` plus provenance
217
- metadata (URL, env pin, fetch timestamps) so clients can
218
- detect staleness. Every entry is enriched with a
219
- ``downloaded_at`` field (or ``None``) so nbdmux + bty can
220
- filter to only-downloaded entries without a second call. No
221
- pagination -- the catalog is small (dozens of entries at
198
+ model -- LAN-only, no auth on reads). Returns only entries
199
+ whose bytes are on disk in withcache's cache. Since v0.11.0
200
+ "presence in this list" IS the readiness signal: staged
201
+ entries (added but not yet Downloaded) are invisible to trio
202
+ consumers so bty + nbdmux can trust every entry they see is
203
+ flashable / exportable. Operators see the staged view on
204
+ withcache's own ``/ui/catalog``.
205
+
206
+ No pagination -- the catalog is small (dozens of entries at
222
207
  most).
223
208
  """
224
209
  cs: CatalogState = request.app.state.catalog
225
210
  store = request.app.state.store
226
211
  entries_out: list[dict[str, Any]] = []
227
212
  for e in cs.entries:
228
- enriched = dict(e)
229
213
  fetch_url = e.get("resolved_src") or e.get("src") or ""
230
- row = store.get_blob(fetch_url) if fetch_url else None
231
- enriched["downloaded_at"] = row["fetched_at"] if row else None
232
- enriched["downloaded_size"] = int(row["size"]) if row else None
233
- entries_out.append(enriched)
214
+ if not fetch_url or store.get_blob(fetch_url) is None:
215
+ continue
216
+ entries_out.append(dict(e))
234
217
  return JSONResponse(
235
218
  {
236
219
  "url": cs.url,