From c9467f1174e42342052558313765b3d2914be546 Mon Sep 17 00:00:00 2001 From: Thomas Christory Date: Fri, 3 Jul 2026 22:33:24 +0200 Subject: [PATCH 1/2] fix(cache): persist offline bundled fallback so format-stale caches don't rebuild every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the only cached command-model is format-stale (rejected by the format_version gate added in #139) and NetBox is unreachable, resolution fell back to the bundled schema and rebuilt it (~180ms) on every invocation, and tab-completion returned nothing for the profile until the first online command. Persist the bundled fallback under the profile via CacheStore.save( record_fetch=False) — no fetch timestamp, so the TTL fast-path still refetches once NetBox is reachable, but the next offline invocation hits _find_any_cached and completion surfaces a format-current entry immediately. Bumps version to 1.6.3. Closes #140 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++++ nsc/_version.py | 2 +- nsc/cache/store.py | 14 ++++++++-- nsc/schema/source.py | 19 +++++++++++++ pyproject.toml | 2 +- tests/cache/test_store.py | 14 ++++++++++ tests/schema/test_source.py | 55 ++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 8 files changed, 120 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bc8df..14ea00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`. +## v1.6.3 — 2026-07-03 + +Patch release. Stops an offline instance from rebuilding the command model from +the bundled schema on every invocation after a format-version bump. + +### Fixed + +- **Offline invocations rebuilt from the bundled schema every time when the only + cached model was format-stale** ([#140]). After the v1.6.2 format-version bump + (#139), a cache entry built by an older `nsc` is rejected so it rebuilds with + the new metadata. Online this self-heals on the first call, but while NetBox + was unreachable nothing re-saved: each invocation re-parsed the bundled schema + (~180ms) and tab-completion went briefly blank for the profile. The offline + bundled fallback is now persisted under the profile — without a fetch + timestamp, so the TTL fast-path still refetches once NetBox is reachable — so + the next invocation hits the cache and completion recovers immediately. + +[#140]: https://github.com/thomaschristory/netbox-super-cli/issues/140 + ## v1.6.2 — 2026-06-30 Patch release. Fixes TUI foreign-key pickers resolving to the wrong resource. diff --git a/nsc/_version.py b/nsc/_version.py index bf7fd3e..dc2fcbb 100644 --- a/nsc/_version.py +++ b/nsc/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the package version.""" -__version__ = "1.6.2" +__version__ = "1.6.3" diff --git a/nsc/cache/store.py b/nsc/cache/store.py index 8781e87..a6b97e1 100644 --- a/nsc/cache/store.py +++ b/nsc/cache/store.py @@ -82,13 +82,21 @@ def load(self, profile: str, schema_hash: str) -> CommandModel | None: return None return model - def save(self, profile: str, model: CommandModel) -> Path: + def save(self, profile: str, model: CommandModel, *, record_fetch: bool = True) -> Path: + """Persist `model` under the profile. `record_fetch=True` also stamps + the `fetched_at` sidecar (a live fetch just produced this model, so + the TTL fast-path may trust it). Pass `record_fetch=False` for models + that were *not* fetched from this NetBox — e.g. the offline bundled + fallback (issue #140): we want the persisted copy so future offline + invocations skip the rebuild, but the TTL fast-path must keep + distrusting it so a real fetch is attempted once NetBox is reachable.""" self._validate_profile(profile) target = self._path_for(profile, model.schema_hash) target.parent.mkdir(parents=True, exist_ok=True) _atomic_write(target, model.model_dump_json(indent=2)) - meta = self._meta_path_for(profile, model.schema_hash) - _atomic_write(meta, json.dumps({"fetched_at": time.time()})) + if record_fetch: + meta = self._meta_path_for(profile, model.schema_hash) + _atomic_write(meta, json.dumps({"fetched_at": time.time()})) return target def touch_fetched_at(self, profile: str, schema_hash: str) -> None: diff --git a/nsc/schema/source.py b/nsc/schema/source.py index 04ee2d4..119b196 100644 --- a/nsc/schema/source.py +++ b/nsc/schema/source.py @@ -15,6 +15,7 @@ from __future__ import annotations +import contextlib import sys import time from pathlib import Path @@ -97,6 +98,7 @@ def resolve_command_model( return cached bundled = _load_bundled_command_model() if bundled is not None: + _persist_offline_fallback(paths, profile.name, bundled) print( f"Could not reach NetBox ({exc}) and no cache; using bundled schema.", file=sys.stderr, @@ -197,6 +199,23 @@ def _find_fresh_cached( return None +def _persist_offline_fallback(paths: Paths, profile_name: str, model: CommandModel) -> None: + """Cache a bundled-schema model under the profile so subsequent offline + invocations hit `_find_any_cached` (a cheap JSON load) instead of + re-building from the bundled schema on every call (issue #140). This also + ends the completion blackout: `cache_probe` can now surface a + format-current entry. + + No fetch timestamp is recorded (`record_fetch=False`): the model did not + come from this NetBox, so the TTL fast-path must keep distrusting it and + attempt a real fetch on the next invocation — self-healing once NetBox is + reachable again. A write failure here is non-fatal; we still return the + in-memory model to the caller.""" + store = CacheStore(root=paths.cache_dir) + with contextlib.suppress(OSError): + store.save(profile_name, model, record_fetch=False) + + def _load_bundled_command_model() -> CommandModel | None: pkg_dir = Path(_bundled_pkg.__file__).resolve().parent manifest_path = pkg_dir / "manifest.yaml" diff --git a/pyproject.toml b/pyproject.toml index 70d74b4..ca69b19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-super-cli" -version = "1.6.2" +version = "1.6.3" description = "Dynamic NetBox CLI generated from the live OpenAPI schema." readme = "README.md" requires-python = ">=3.12" diff --git a/tests/cache/test_store.py b/tests/cache/test_store.py index f486b3a..8f4cb32 100644 --- a/tests/cache/test_store.py +++ b/tests/cache/test_store.py @@ -148,6 +148,20 @@ def test_save_writes_fetched_at_sidecar(tmp_path: Path) -> None: assert before <= fetched_at <= after +def test_save_without_record_fetch_writes_no_sidecar(tmp_path: Path) -> None: + """`save(record_fetch=False)` persists the model but leaves no fetch + timestamp, so the TTL fast-path keeps distrusting the entry. Used by the + offline bundled-fallback path (issue #140): we cache the rebuilt model to + skip per-invocation rebuilds, but a real fetch must still be attempted + once NetBox is reachable again.""" + store = CacheStore(root=tmp_path) + store.save("prod", _model("a" * 64), record_fetch=False) + + assert store.load("prod", "a" * 64) is not None + assert not (tmp_path / "prod" / ("a" * 64 + ".meta.json")).exists() + assert store.load_fetched_at("prod", "a" * 64) is None + + def test_load_fetched_at_returns_none_when_missing(tmp_path: Path) -> None: """No sidecar means we can't prove freshness — distrust the entry.""" store = CacheStore(root=tmp_path) diff --git a/tests/schema/test_source.py b/tests/schema/test_source.py index 84acb12..f790b67 100644 --- a/tests/schema/test_source.py +++ b/tests/schema/test_source.py @@ -10,9 +10,11 @@ import pytest import respx +from nsc.cache.store import CacheStore from nsc.cli.runtime import ResolvedProfile +from nsc.completion.cache_probe import load_cached_model_for_profile from nsc.config.settings import Paths -from nsc.model.command_model import CommandModel +from nsc.model.command_model import MODEL_FORMAT_VERSION, CommandModel from nsc.schema.source import ( SchemaSourceError, resolve_command_model, @@ -143,6 +145,57 @@ def test_offline_no_cache_falls_back_to_bundled( assert "bundled" in err.lower() +def _stale_the_only_cache_entry(paths: Paths, profile_name: str) -> str: + """Downgrade the single cached entry to a pre-versioning format so + `CacheStore.load` rejects it purely on `format_version`. Returns its hash.""" + profile_dir = paths.cache_dir / profile_name + cache_files = [p for p in profile_dir.glob("*.json") if not p.name.endswith(".meta.json")] + assert len(cache_files) == 1 + stale_file = cache_files[0] + data = json.loads(stale_file.read_text()) + data["format_version"] = 0 + stale_file.write_text(json.dumps(data)) + return stale_file.stem + + +@respx.mock +def test_offline_format_stale_cache_persists_bundled_fallback( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Issue #140: when the only cached entry is format-stale (hash-fresh) and + NetBox is unreachable, the bundled fallback is persisted under the profile + so the next invocation skips the per-call rebuild — without a fetch + timestamp, so the TTL fast-path still refetches once NetBox returns.""" + route = respx.get("https://nb.example/api/schema/?format=json").mock( + return_value=httpx.Response(200, json=_minimal_schema_doc()) + ) + paths = _paths(tmp_path) + profile = _profile() + resolve_command_model(paths=paths, profile=profile, schema_override=None) + stale_hash = _stale_the_only_cache_entry(paths, "prod") + capsys.readouterr() # drain + + route.mock(side_effect=httpx.ConnectError("offline")) + first = resolve_command_model(paths=paths, profile=profile, schema_override=None) + assert first.format_version == MODEL_FORMAT_VERSION + assert "bundled" in capsys.readouterr().err.lower() + + # The bundled model is now persisted under the profile (a distinct hash), + # carries no fetch timestamp, and is visible to completion — no blackout. + store = CacheStore(root=paths.cache_dir) + assert first.schema_hash != stale_hash + assert store.load("prod", first.schema_hash) is not None + assert store.load_fetched_at("prod", first.schema_hash) is None + probed = load_cached_model_for_profile(paths, "prod") + assert probed is not None + assert probed.format_version == MODEL_FORMAT_VERSION + + # Second offline invocation hits `_find_any_cached` instead of rebuilding. + second = resolve_command_model(paths=paths, profile=profile, schema_override=None) + assert second.schema_hash == first.schema_hash + assert "cached" in capsys.readouterr().err.lower() + + @respx.mock def test_offline_no_cache_no_bundled_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/uv.lock b/uv.lock index 9943851..668ed14 100644 --- a/uv.lock +++ b/uv.lock @@ -675,7 +675,7 @@ wheels = [ [[package]] name = "netbox-super-cli" -version = "1.6.2" +version = "1.6.3" source = { editable = "." } dependencies = [ { name = "click" }, From 9c22331e0c9a0a518b69996290c2be32eeffab38 Mon Sep 17 00:00:00 2001 From: Thomas Christory Date: Fri, 3 Jul 2026 22:39:40 +0200 Subject: [PATCH 2/2] fix(cache): also suppress ValueError in offline bundled-fallback persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _validate_profile raises ValueError (not OSError) for a profile name outside _PROFILE_RE — and names aren't validated at config time. Suppress it too so the offline bundled fallback honors its non-fatal contract and still returns the in-memory model. Adversarial-review finding on #142. Co-Authored-By: Claude Opus 4.8 (1M context) --- nsc/schema/source.py | 5 ++++- tests/schema/test_source.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/nsc/schema/source.py b/nsc/schema/source.py index 119b196..86b1380 100644 --- a/nsc/schema/source.py +++ b/nsc/schema/source.py @@ -212,7 +212,10 @@ def _persist_offline_fallback(paths: Paths, profile_name: str, model: CommandMod reachable again. A write failure here is non-fatal; we still return the in-memory model to the caller.""" store = CacheStore(root=paths.cache_dir) - with contextlib.suppress(OSError): + # OSError: disk/permission failure. ValueError: CacheStore rejects a + # profile name outside `_PROFILE_RE` (names aren't validated at config + # time). Both are non-fatal here — the in-memory model is still returned. + with contextlib.suppress(OSError, ValueError): store.save(profile_name, model, record_fetch=False) diff --git a/tests/schema/test_source.py b/tests/schema/test_source.py index f790b67..5e9cd2b 100644 --- a/tests/schema/test_source.py +++ b/tests/schema/test_source.py @@ -196,6 +196,24 @@ def test_offline_format_stale_cache_persists_bundled_fallback( assert "cached" in capsys.readouterr().err.lower() +@respx.mock +def test_offline_bundled_fallback_survives_unpersistable_profile_name( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A profile name outside `_PROFILE_RE` (names aren't validated at config + time) makes `CacheStore.save` raise `ValueError`; the offline bundled + fallback must swallow it and still return the in-memory model.""" + respx.get("https://nb.example/api/schema/?format=json").mock( + side_effect=httpx.ConnectError("offline") + ) + paths = _paths(tmp_path) + model = resolve_command_model( + paths=paths, profile=_profile(name="has space"), schema_override=None + ) + assert isinstance(model, CommandModel) + assert "bundled" in capsys.readouterr().err.lower() + + @respx.mock def test_offline_no_cache_no_bundled_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch