From fd77a5ab7e63a75e91c95ff6b94e422779f7bae4 Mon Sep 17 00:00:00 2001 From: Thomas Christory Date: Fri, 23 Oct 2026 23:29:11 +0200 Subject: [PATCH] fix(cli): NSC_SCHEMA is the env-var form of --schema (#192) NSC_SCHEMA only fed profile.schema_url, so a local path was ignored and an unreachable URL silently fell back to the cached/bundled schema. Resolve it alongside --schema (flag wins) and pass it as the schema override, so it loads the path or URL directly and a failure exits 3. Claude-Session: https://claude.ai/code/session_01RD8NgCVtp3yYFd6NSZpbUb --- CHANGELOG.md | 5 ++ docs/architecture/overview.md | 2 +- docs/architecture/schema-loading.md | 6 +- nsc/cli/globals.py | 3 +- nsc/cli/runtime.py | 7 +- tests/cli/test_schema_env_override.py | 100 ++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/cli/test_schema_env_override.py diff --git a/CHANGELOG.md b/CHANGELOG.md index add8e34..66bb7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a It used to crash with an internal error and a traceback. It now prints `Error: cannot load schema from : `, like other schema failures. +- **`NSC_SCHEMA` behaves like `--schema`** ([#192]). A local path used to be + silently ignored, and an unreachable URL quietly fell back to the cached or + bundled schema. It now loads the path or URL, and a failure exits 3 like + `--schema`. The flag wins when both are set. ## v1.8.4 — 2026-10-23 @@ -158,6 +162,7 @@ notices, and a fix for `nsc login` reporting the wrong user. [#170]: https://github.com/thomaschristory/netbox-super-cli/issues/170 [#186]: https://github.com/thomaschristory/netbox-super-cli/issues/186 [#189]: https://github.com/thomaschristory/netbox-super-cli/issues/189 +[#192]: https://github.com/thomaschristory/netbox-super-cli/issues/192 [#171]: https://github.com/thomaschristory/netbox-super-cli/issues/171 [#179]: https://github.com/thomaschristory/netbox-super-cli/issues/179 [#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 5fea7c4..c572dbc 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -31,7 +31,7 @@ If you need to add a dependency to `model/`, you're solving the wrong problem. ## Data flow at runtime 1. `nsc` startup → resolve profile (CLI flags > env > config > adhoc). -2. Resolve schema source (`--schema` override > fresh cache within the +2. Resolve schema source (`--schema`/`NSC_SCHEMA` override > fresh cache within the TTL policy > live fetch > stale cache > bundled fallback). 3. Hash + parse → build the command-model (or load cached). 4. Hand model to Typer; register commands dynamically. diff --git a/docs/architecture/schema-loading.md b/docs/architecture/schema-loading.md index 9b6f50d..3b9735e 100644 --- a/docs/architecture/schema-loading.md +++ b/docs/architecture/schema-loading.md @@ -7,8 +7,10 @@ Highest priority first, given the active `defaults.schema_refresh` policy and an optional `--refresh-schema` override: -1. `--schema ` — one-shot override. Always wins; never - consults the TTL fast-path or the network. +1. `--schema ` (or `NSC_SCHEMA`; the flag wins) — one-shot + override. Always wins; never consults the TTL fast-path, the cache + fallback or the bundled snapshot. A missing file, unreachable URL or + invalid document exits 3 with `cannot load schema from : …`. 2. **TTL fast-path.** If the policy is `daily`, `weekly`, or `manual` and the newest sidecar-validated cache entry for the active profile is fresher than the policy's TTL, `nsc` returns it directly with no HTTP diff --git a/nsc/cli/globals.py b/nsc/cli/globals.py index 14900c8..f980636 100644 --- a/nsc/cli/globals.py +++ b/nsc/cli/globals.py @@ -16,6 +16,7 @@ resolve_color, resolve_object_colors, resolve_profile, + resolve_schema_override, ) from nsc.config import default_paths from nsc.config.models import Config, ObjectColorMode @@ -44,7 +45,7 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext: model = resolve_command_model( paths=paths, profile=profile, - schema_override=state.overrides.schema_override, + schema_override=resolve_schema_override(state.overrides, os.environ), schema_refresh=state.config.defaults.schema_refresh, force_refresh=state.overrides.refresh_schema, ) diff --git a/nsc/cli/runtime.py b/nsc/cli/runtime.py index 1ca3e03..ed928f3 100644 --- a/nsc/cli/runtime.py +++ b/nsc/cli/runtime.py @@ -181,6 +181,11 @@ def resolve_profile( ) +def resolve_schema_override(overrides: CLIOverrides, env: Mapping[str, str]) -> str | None: + """`--schema` wins over `NSC_SCHEMA`; either may be a local path or a URL.""" + return _first_set(overrides.schema_override, env.get("NSC_SCHEMA")) + + def resolve_branch_selection( config: Config, overrides: CLIOverrides, @@ -265,7 +270,7 @@ def _url_only(value: str | None) -> str | None: `--schema`/`NSC_SCHEMA` may be a local path, in which case it should not populate the profile's `schema_url` (which is HttpUrl-validated). The - schema_override flow consumes the raw value directly from `CLIOverrides`. + schema_override flow consumes the raw value via `resolve_schema_override`. """ if not value: return None diff --git a/tests/cli/test_schema_env_override.py b/tests/cli/test_schema_env_override.py new file mode 100644 index 0000000..d55394f --- /dev/null +++ b/tests/cli/test_schema_env_override.py @@ -0,0 +1,100 @@ +"""`NSC_SCHEMA` is the env-var form of `--schema` (#192).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx2 +import pytest +import respx +from typer.testing import CliRunner + +from nsc.cli.app import app + +_NETBOX_SCHEMA = "https://nb.example/api/schema/?format=json" +_REMOTE_SCHEMA = "https://schemas.example/s.json" + + +@pytest.fixture(autouse=True) +def _env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("NSC_HOME", str(tmp_path)) + monkeypatch.setenv("NSC_URL", "https://nb.example/") + monkeypatch.setenv("NSC_TOKEN", "tok") + for var in ("NSC_PROFILE", "NSC_SCHEMA", "NSC_OUTPUT", "NSC_BRANCH"): + monkeypatch.delenv(var, raising=False) + + +def _widgets_schema() -> dict[str, object]: + # `dcim widgets` exists in no bundled schema, so seeing it proves this document was used. + return { + "openapi": "3.0.3", + "info": {"title": "T", "version": "1.0.0"}, + "tags": [{"name": "dcim"}], + "paths": { + "/api/dcim/widgets/": { + "get": { + "operationId": "dcim_widgets_list", + "tags": ["dcim"], + "parameters": [], + "responses": {"200": {"description": "ok", "content": {}}}, + } + } + }, + "components": {"schemas": {}}, + } + + +def _write_schema(tmp_path: Path) -> str: + path = tmp_path / "widgets.json" + path.write_text(json.dumps(_widgets_schema()), encoding="utf-8") + return str(path) + + +@pytest.mark.httpx2(assert_all_called=False) +def test_nsc_schema_local_path_is_used( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, httpx2_mock: respx.Router +) -> None: + httpx2_mock.get(_NETBOX_SCHEMA).mock(side_effect=httpx2.ConnectError("offline")) + monkeypatch.setenv("NSC_SCHEMA", _write_schema(tmp_path)) + result = CliRunner().invoke(app, ["dcim", "widgets", "--help"]) + assert result.exit_code == 0, result.output + assert "list" in result.output + + +def test_nsc_schema_missing_file_exits_three( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + missing = str(tmp_path / "missing.json") + monkeypatch.setenv("NSC_SCHEMA", missing) + result = CliRunner().invoke(app, ["dcim", "sites", "list"]) + assert result.exit_code == 3 + assert f"Error: cannot load schema from {missing}: not found" in result.stderr + + +def test_nsc_schema_unreachable_url_exits_three_without_fallback( + monkeypatch: pytest.MonkeyPatch, httpx2_mock: respx.Router +) -> None: + httpx2_mock.get(_REMOTE_SCHEMA).mock(side_effect=httpx2.ConnectError("offline")) + monkeypatch.setenv("NSC_SCHEMA", _REMOTE_SCHEMA) + result = CliRunner().invoke(app, ["dcim", "sites", "list"]) + assert result.exit_code == 3 + assert f"Error: cannot load schema from {_REMOTE_SCHEMA}: request failed" in result.stderr + assert "bundled schema" not in result.stderr + + +def test_schema_flag_wins_over_nsc_schema(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("NSC_SCHEMA", str(tmp_path / "missing.json")) + result = CliRunner().invoke( + app, ["--schema", _write_schema(tmp_path), "dcim", "widgets", "--help"] + ) + assert result.exit_code == 0, result.output + + +def test_empty_nsc_schema_is_unset( + monkeypatch: pytest.MonkeyPatch, httpx2_mock: respx.Router +) -> None: + httpx2_mock.get(_NETBOX_SCHEMA).respond(json=_widgets_schema()) + monkeypatch.setenv("NSC_SCHEMA", "") + result = CliRunner().invoke(app, ["dcim", "widgets", "--help"]) + assert result.exit_code == 0, result.output