Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion nsc/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the package version."""

__version__ = "1.6.2"
__version__ = "1.6.3"
14 changes: 11 additions & 3 deletions nsc/cache/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions nsc/schema/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import contextlib
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -197,6 +199,26 @@ 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)
# 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)


def _load_bundled_command_model() -> CommandModel | None:
pkg_dir = Path(_bundled_pkg.__file__).resolve().parent
manifest_path = pkg_dir / "manifest.yaml"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
14 changes: 14 additions & 0 deletions tests/cache/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 72 additions & 1 deletion tests/schema/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -143,6 +145,75 @@ 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_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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.