withcache 0.13.0__tar.gz → 0.13.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 (49) hide show
  1. {withcache-0.13.0 → withcache-0.13.2}/PKG-INFO +10 -8
  2. {withcache-0.13.0 → withcache-0.13.2}/README.md +9 -7
  3. {withcache-0.13.0 → withcache-0.13.2}/shim/build.zig.zon +1 -1
  4. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/__init__.py +1 -1
  5. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_api.py +19 -6
  6. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_app.py +2 -2
  7. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/client.py +26 -1
  8. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/server.py +25 -11
  9. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_catalog_api.py +16 -0
  10. {withcache-0.13.0 → withcache-0.13.2}/tests/test_withcache.py +66 -0
  11. {withcache-0.13.0 → withcache-0.13.2}/.gitignore +0 -0
  12. {withcache-0.13.0 → withcache-0.13.2}/LICENSE +0 -0
  13. {withcache-0.13.0 → withcache-0.13.2}/deploy/Containerfile +0 -0
  14. {withcache-0.13.0 → withcache-0.13.2}/deploy/compose.local-build.yml +0 -0
  15. {withcache-0.13.0 → withcache-0.13.2}/deploy/compose.yml +0 -0
  16. {withcache-0.13.0 → withcache-0.13.2}/deploy/envvars.example +0 -0
  17. {withcache-0.13.0 → withcache-0.13.2}/hatch_build.py +0 -0
  18. {withcache-0.13.0 → withcache-0.13.2}/pyproject.toml +0 -0
  19. {withcache-0.13.0 → withcache-0.13.2}/shim/build.zig +0 -0
  20. {withcache-0.13.0 → withcache-0.13.2}/shim/shim.zig +0 -0
  21. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_events_log.py +0 -0
  22. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_settings_store.py +0 -0
  23. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_shim.py +0 -0
  24. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_table_state.py +0 -0
  25. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/_layout.html +0 -0
  26. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/_table_macros.html +0 -0
  27. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/catalog.html +0 -0
  28. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/dashboard.html +0 -0
  29. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/events.html +0 -0
  30. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/login.html +0 -0
  31. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/misses.html +0 -0
  32. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/_templates/ui/settings.html +0 -0
  33. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/curlwithcache.py +0 -0
  34. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/oras.py +0 -0
  35. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
  36. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/static/bootstrap.min.css +0 -0
  37. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  38. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  39. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/static/htmx.min.js +0 -0
  40. {withcache-0.13.0 → withcache-0.13.2}/src/withcache/wgetwithcache.py +0 -0
  41. {withcache-0.13.0 → withcache-0.13.2}/tests/test_differential.py +0 -0
  42. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_admin_forms.py +0 -0
  43. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_blob.py +0 -0
  44. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_events.py +0 -0
  45. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_scaffold.py +0 -0
  46. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_settings_persistence.py +0 -0
  47. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_ui_pages.py +0 -0
  48. {withcache-0.13.0 → withcache-0.13.2}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  49. {withcache-0.13.0 → withcache-0.13.2}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.13.0
3
+ Version: 0.13.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>
@@ -68,14 +68,16 @@ disk.
68
68
 
69
69
  | Path | What it is |
70
70
  |-------------------------------|-------------------------------------------------------------|
71
- | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Bootstrap 5 + Bootstrap Icons + HTMX) |
71
+ | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + events log |
72
+ | `src/withcache/_app.py` | FastAPI app factory + operator UI wiring (Bootstrap 5 + Bootstrap Icons + HTMX) |
72
73
  | `src/withcache/_shim.py` | Shared shim core (find URL → probe → rewrite → exec) |
73
74
  | `src/withcache/curlwithcache.py` / `wgetwithcache.py` | The Python `curl` / `wget` shims |
74
75
  | `shim/shim.zig` | The native shim: one static binary, both tools via `argv[0]` |
75
76
  | `deploy/Containerfile`, `deploy/compose.yml` | Single Podman/Docker host deploy |
76
77
 
77
- The cache-host and the Python shims are **stdlib-only** (no third-party runtime
78
- deps); the native shim is a dependency-free static binary.
78
+ The cache-host runs on FastAPI + Jinja + Bootstrap (matching bty +
79
+ nbdmux for one trio-consistent chrome); the shims (Python and
80
+ native) are dependency-free so they drop into any environment.
79
81
 
80
82
  ## Install
81
83
 
@@ -245,11 +247,11 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
245
247
 
246
248
  `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
247
249
  offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
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.
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
+ - **Dashboard** (landing): catalog + cache + activity summary, health checklist, and the last N audit events.
251
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configured on Settings > Catalog source). Add entries via the subnav's inline "Add ORAS" / "Add HTTPS" forms or "Fetch default" button, then **Download** each entry. Rows carry hits, size, download progress (live), and cached/failed pills.
250
252
  - **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.
253
+ - **Events**: append-only audit log with a free-text filter and per-page pagination. Failure rows carry an ack button; the dashboard's Health tripwire flags unacknowledged failures.
254
+ - **Settings**: identity + storage paths + catalog source (editable) + logging (uvicorn level) + auth.
253
255
 
254
256
  ## Auth
255
257
 
@@ -45,14 +45,16 @@ disk.
45
45
 
46
46
  | Path | What it is |
47
47
  |-------------------------------|-------------------------------------------------------------|
48
- | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + operator UI (Bootstrap 5 + Bootstrap Icons + HTMX) |
48
+ | `src/withcache/server.py` | The cache-host: blob store + miss table + **background download manager** + events log |
49
+ | `src/withcache/_app.py` | FastAPI app factory + operator UI wiring (Bootstrap 5 + Bootstrap Icons + HTMX) |
49
50
  | `src/withcache/_shim.py` | Shared shim core (find URL → probe → rewrite → exec) |
50
51
  | `src/withcache/curlwithcache.py` / `wgetwithcache.py` | The Python `curl` / `wget` shims |
51
52
  | `shim/shim.zig` | The native shim: one static binary, both tools via `argv[0]` |
52
53
  | `deploy/Containerfile`, `deploy/compose.yml` | Single Podman/Docker host deploy |
53
54
 
54
- The cache-host and the Python shims are **stdlib-only** (no third-party runtime
55
- deps); the native shim is a dependency-free static binary.
55
+ The cache-host runs on FastAPI + Jinja + Bootstrap (matching bty +
56
+ nbdmux for one trio-consistent chrome); the shims (Python and
57
+ native) are dependency-free so they drop into any environment.
56
58
 
57
59
  ## Install
58
60
 
@@ -222,11 +224,11 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
222
224
 
223
225
  `http://withcache-server:8081/` (Bootstrap 5 + Bootstrap Icons + HTMX, bundled
224
226
  offline; matches bty's chrome for a consistent trio) is a five-page dashboard:
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.
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
+ - **Dashboard** (landing): catalog + cache + activity summary, health checklist, and the last N audit events.
228
+ - **Catalog**: image catalog fetched from a nosi-style `catalog.toml` (URL configured on Settings > Catalog source). Add entries via the subnav's inline "Add ORAS" / "Add HTTPS" forms or "Fetch default" button, then **Download** each entry. Rows carry hits, size, download progress (live), and cached/failed pills.
227
229
  - **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.
230
+ - **Events**: append-only audit log with a free-text filter and per-page pagination. Failure rows carry an ack button; the dashboard's Health tripwire flags unacknowledged failures.
231
+ - **Settings**: identity + storage paths + catalog source (editable) + logging (uvicorn level) + auth.
230
232
 
231
233
  ## Auth
232
234
 
@@ -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.13.0",
5
+ .version = "0.13.2",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -22,6 +22,6 @@ inherit the framework floor.
22
22
  from . import oras
23
23
  from .client import blob_url, cache_base, is_cached, serve_url
24
24
 
25
- __version__ = "0.13.0"
25
+ __version__ = "0.13.2"
26
26
 
27
27
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -22,9 +22,11 @@ v0.11.0) promotes a missed URL to a catalog entry + enqueues the
22
22
  Download in one click.
23
23
 
24
24
  Streaming shape: FastAPI ``StreamingResponse`` with a generator
25
- that reads chunks from the on-disk blob at 64 KiB reads;
26
- :class:`StreamRegistry` ticks progress per chunk so the operator
27
- Downloads page shows in-flight transfers as they run.
25
+ that reads chunks from the on-disk blob at 64 KiB reads. The
26
+ Downloads-page real-time progress tracker was retired in v0.12.0
27
+ along with that page; per-entry download progress now folds into
28
+ the Catalog page's Downloaded column, populated from the
29
+ DownloadManager's job list directly.
28
30
  """
29
31
 
30
32
  from __future__ import annotations
@@ -254,9 +256,19 @@ def register_api_routes(app: FastAPI) -> None:
254
256
  """Insert one catalog entry.
255
257
 
256
258
  Body: ``{"name": "...", "src": "...", "format?", "arch?",
257
- "sha256?", "size_bytes?", "resolved_src?", "description?"}``.
258
- Only ``src`` is required; every other field is optional and
259
- gets persisted through the ``catalog.toml`` round-trip.
259
+ "sha256?", "size_bytes?", "resolved_src?", "description?",
260
+ "netboot_ref?"}``. Only ``src`` is required; every other
261
+ field is optional and gets persisted through the
262
+ ``catalog.toml`` round-trip.
263
+
264
+ ``netboot_ref`` names another catalog entry (by ``name``)
265
+ whose bytes are a nosi netboot bundle (vmlinuz + initrd +
266
+ manifest) that nbdmux extracts at warm time so bty can
267
+ ramboot the image using its own kernel. Present iff the
268
+ entry is a disk image that has a matching netboot bundle;
269
+ the bundle entry itself never carries a ``netboot_ref``.
270
+ Withcache does not validate that the referenced entry
271
+ exists -- the sibling may be added in either order.
260
272
 
261
273
  409 when an entry with the same ``name`` already exists;
262
274
  rejecting on duplicate name (not src) because the display
@@ -276,6 +288,7 @@ def register_api_routes(app: FastAPI) -> None:
276
288
  "sha256",
277
289
  "size_bytes",
278
290
  "description",
291
+ "netboot_ref",
279
292
  }
280
293
  if not isinstance(body, dict):
281
294
  raise HTTPException(status_code=400, detail="body must be a JSON object")
@@ -20,7 +20,7 @@ import hashlib
20
20
  import os
21
21
  import sys
22
22
  import threading
23
- from collections.abc import AsyncIterator
23
+ from collections.abc import AsyncGenerator
24
24
  from pathlib import Path
25
25
  from typing import Any
26
26
 
@@ -108,7 +108,7 @@ def create_app(
108
108
  jinja = _build_jinja(_TEMPLATES_DIR)
109
109
 
110
110
  @contextlib.asynccontextmanager
111
- async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
111
+ async def _lifespan(_app: FastAPI) -> AsyncGenerator[None]:
112
112
  """Start-of-request-cycle wiring for the daemon path.
113
113
 
114
114
  Fires only when ``run_lifecycle=True`` (``server.main`` boots
@@ -45,6 +45,7 @@ __all__ = [
45
45
  "cache_base",
46
46
  "delete_catalog_entry",
47
47
  "is_cached",
48
+ "is_healthy",
48
49
  "list_catalog",
49
50
  "serve_url",
50
51
  ]
@@ -121,6 +122,27 @@ def serve_url(
121
122
  return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
122
123
 
123
124
 
125
+ def is_healthy(
126
+ server: str = "http://localhost:8081",
127
+ timeout: float = PROBE_TIMEOUT,
128
+ ) -> bool:
129
+ """True iff ``GET /healthz`` returns 200. Suitable for a startup
130
+ probe / reachability pill on a consumer's Settings page.
131
+
132
+ Bypasses :func:`_catalog_request` deliberately: the healthz body
133
+ is small JSON but we don't inspect it -- the status code is the
134
+ whole signal. Any transport / HTTP / OS failure returns
135
+ ``False`` so callers can treat a False as "not reachable" without
136
+ having to catch a family of exceptions.
137
+ """
138
+ url = f"{cache_base(server)}/healthz"
139
+ try:
140
+ with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310
141
+ return bool(resp.status == 200)
142
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
143
+ return False
144
+
145
+
124
146
  def _resolve_password(password: str | None) -> str | None:
125
147
  """``password`` if given, else ``$WITHCACHE_ADMIN_PASSWORD``, else ``None``.
126
148
  Empty / whitespace values are treated as unset."""
@@ -199,7 +221,10 @@ def add_catalog_entry(
199
221
 
200
222
  ``entry`` must carry at least ``name`` + ``src``. Optional
201
223
  fields: ``format``, ``arch``, ``sha256``, ``size_bytes``,
202
- ``resolved_src``, ``description``.
224
+ ``resolved_src``, ``description``, ``netboot_ref`` (name of
225
+ a sibling entry carrying the matching nosi netboot bundle;
226
+ consumed by nbdmux to serve vmlinuz + initrd alongside the
227
+ disk-image export).
203
228
 
204
229
  Raises :class:`WithcacheError` on any HTTP failure (409 if the
205
230
  entry name already exists; 401 if auth is enabled and no valid
@@ -147,18 +147,31 @@ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
147
147
  row can't smuggle arbitrary TOML through.
148
148
 
149
149
  Scalar keys emitted: ``name``, ``src``, ``resolved_src``,
150
- ``format``, ``arch``, ``sha256``, ``description``. Integer:
151
- ``size_bytes``. ``resolved_src`` is the canonical fetch URL
152
- (for ``oras://`` refs; equal to ``src`` for plain HTTPS);
153
- bty needs it to render iPXE flash chains against the byte
154
- URL directly. ``description`` is operator-supplied prose so
155
- the /ui/images page has something better than the URL to
156
- display.
150
+ ``format``, ``arch``, ``sha256``, ``description``,
151
+ ``netboot_ref``. Integer: ``size_bytes``. ``resolved_src`` is
152
+ the canonical fetch URL (for ``oras://`` refs; equal to
153
+ ``src`` for plain HTTPS); bty needs it to render iPXE flash
154
+ chains against the byte URL directly. ``description`` is
155
+ operator-supplied prose so the /ui/images page has something
156
+ better than the URL to display. ``netboot_ref`` names a
157
+ sibling catalog entry carrying the matching nosi netboot
158
+ bundle (kernel + initrd) so nbdmux can pair the disk image
159
+ with its own kernel at warm time; see the ``add_catalog_entry``
160
+ docstring in ``_api.py`` for the pairing story.
157
161
  """
158
162
  out: list[str] = ["version = 1", ""]
159
163
  for e in entries:
160
164
  out.append("[[images]]")
161
- for key in ("name", "src", "resolved_src", "format", "arch", "sha256", "description"):
165
+ for key in (
166
+ "name",
167
+ "src",
168
+ "resolved_src",
169
+ "format",
170
+ "arch",
171
+ "sha256",
172
+ "description",
173
+ "netboot_ref",
174
+ ):
162
175
  val = e.get(key)
163
176
  if val is None or val == "":
164
177
  continue
@@ -1053,9 +1066,10 @@ def main():
1053
1066
  mgr = DownloadManager(store, workers=args.workers)
1054
1067
  auth_password = os.environ.get("WITHCACHE_ADMIN_PASSWORD")
1055
1068
 
1056
- # Since v0.10.0 there is no auto-fetch on miss: the operator
1057
- # hits Download on /ui/catalog to enqueue a fetch. The daemon
1058
- # is always in "curate" mode; the --curate flag went with it.
1069
+ # Withcache is operator-populated: cache misses return 404 (v0.10.0+)
1070
+ # and the operator hits Download on /ui/catalog to enqueue a fetch.
1071
+ # The v0.12.0 UI consolidation folded Cached + Downloads into the
1072
+ # Catalog page's per-row status column and progress bar.
1059
1073
  print(
1060
1074
  f"withcache cache-host on http://{args.host}:{args.port} "
1061
1075
  f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
@@ -167,12 +167,28 @@ class AddCatalogEntryTests(_CatalogApiBase):
167
167
  "sha256": "b" * 64,
168
168
  "size_bytes": 2048,
169
169
  "description": "Debian sysdev",
170
+ "netboot_ref": "debian-netboot",
170
171
  },
171
172
  )
172
173
  self.assertEqual(r.status_code, 201, r.text)
173
174
  stored = self.catalog.entries[0]
174
175
  self.assertEqual(stored["description"], "Debian sysdev")
175
176
  self.assertEqual(stored["resolved_src"], "https://example/debian.img.gz")
177
+ self.assertEqual(stored["netboot_ref"], "debian-netboot")
178
+
179
+ def test_netboot_ref_persists_through_toml_round_trip(self) -> None:
180
+ """netboot_ref must survive the catalog.toml write-then-reload."""
181
+ self.client.post(
182
+ "/catalog/entries",
183
+ json={
184
+ "name": "debian",
185
+ "src": "https://example/debian.img.gz",
186
+ "netboot_ref": "debian-netboot",
187
+ },
188
+ )
189
+ toml_path = os.path.join(self._tmpdir, "catalog.toml")
190
+ content = open(toml_path, encoding="utf-8").read()
191
+ self.assertIn('netboot_ref = "debian-netboot"', content)
176
192
 
177
193
  def test_add_persists_to_disk(self) -> None:
178
194
  self.client.post(
@@ -967,5 +967,71 @@ class TestHumanSize(unittest.TestCase):
967
967
  self.assertIn("GiB", server.human_size(3 * 1024 * 1024 * 1024))
968
968
 
969
969
 
970
+ # --------------------------------------------------------------------------
971
+ # client.is_healthy: GET /healthz reachability probe consumed by bty's
972
+ # Settings pill so a red URL is caught at render time instead of on first
973
+ # flash.
974
+ # --------------------------------------------------------------------------
975
+ class _HealthzOK(http.server.BaseHTTPRequestHandler):
976
+ def do_GET(self):
977
+ if self.path == "/healthz":
978
+ body = b'{"status":"ok"}'
979
+ self.send_response(200)
980
+ self.send_header("Content-Type", "application/json")
981
+ self.send_header("Content-Length", str(len(body)))
982
+ self.end_headers()
983
+ self.wfile.write(body)
984
+ else:
985
+ self.send_response(404)
986
+ self.end_headers()
987
+
988
+ def log_message(self, format, *args):
989
+ pass
990
+
991
+
992
+ class _HealthzFail(http.server.BaseHTTPRequestHandler):
993
+ def do_GET(self):
994
+ self.send_response(503)
995
+ self.end_headers()
996
+
997
+ def log_message(self, format, *args):
998
+ pass
999
+
1000
+
1001
+ class TestIsHealthy(unittest.TestCase):
1002
+ def _serve(self, handler_cls):
1003
+ httpd = socketserver.TCPServer(("127.0.0.1", 0), handler_cls)
1004
+ port = httpd.server_address[1]
1005
+ t = threading.Thread(target=httpd.serve_forever, daemon=True)
1006
+ t.start()
1007
+ return httpd, port
1008
+
1009
+ def test_is_healthy_true_on_200(self):
1010
+ from withcache import client
1011
+
1012
+ httpd, port = self._serve(_HealthzOK)
1013
+ try:
1014
+ self.assertTrue(client.is_healthy(f"http://127.0.0.1:{port}", timeout=2.0))
1015
+ finally:
1016
+ httpd.shutdown()
1017
+ httpd.server_close()
1018
+
1019
+ def test_is_healthy_false_on_non_2xx(self):
1020
+ from withcache import client
1021
+
1022
+ httpd, port = self._serve(_HealthzFail)
1023
+ try:
1024
+ self.assertFalse(client.is_healthy(f"http://127.0.0.1:{port}", timeout=2.0))
1025
+ finally:
1026
+ httpd.shutdown()
1027
+ httpd.server_close()
1028
+
1029
+ def test_is_healthy_false_when_unreachable(self):
1030
+ from withcache import client
1031
+
1032
+ # 127.0.0.1:1 -- reserved and closed on any sane host.
1033
+ self.assertFalse(client.is_healthy("http://127.0.0.1:1", timeout=0.5))
1034
+
1035
+
970
1036
  if __name__ == "__main__":
971
1037
  unittest.main(verbosity=2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes