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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <source>: <reason>`, 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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture/schema-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
Highest priority first, given the active `defaults.schema_refresh` policy
and an optional `--refresh-schema` override:

1. `--schema <path-or-url>` — one-shot override. Always wins; never
consults the TTL fast-path or the network.
1. `--schema <path-or-url>` (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 <source>: …`.
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
Expand Down
3 changes: 2 additions & 1 deletion nsc/cli/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
7 changes: 6 additions & 1 deletion nsc/cli/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions tests/cli/test_schema_env_override.py
Original file line number Diff line number Diff line change
@@ -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
Loading