netbox-super-cli 1.0.0__py3-none-any.whl
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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/output/flatten.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared dotted-path flattener used by table and csv formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def flatten(record: dict[str, Any], *, columns: list[str] | None = None) -> dict[str, Any]:
|
|
10
|
+
flat: dict[str, Any] = {}
|
|
11
|
+
_walk(record, "", flat)
|
|
12
|
+
if columns is None:
|
|
13
|
+
return flat
|
|
14
|
+
return {col: flat.get(col, "") for col in columns}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _walk(value: Any, prefix: str, out: dict[str, Any]) -> None:
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
for k, v in value.items():
|
|
20
|
+
child = f"{prefix}.{k}" if prefix else k
|
|
21
|
+
_walk(v, child, out)
|
|
22
|
+
elif isinstance(value, list):
|
|
23
|
+
out[prefix] = json.dumps(value)
|
|
24
|
+
else:
|
|
25
|
+
out[prefix] = value
|
nsc/output/headers.py
ADDED
nsc/output/json_.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""JSON output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render(
|
|
11
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
stream: TextIO = sys.stdout,
|
|
14
|
+
compact: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
if compact:
|
|
17
|
+
stream.write(json.dumps(data, separators=(",", ":")))
|
|
18
|
+
stream.write("\n")
|
|
19
|
+
return
|
|
20
|
+
json.dump(data, stream, indent=2, sort_keys=False)
|
|
21
|
+
stream.write("\n")
|
nsc/output/jsonl.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""JSON Lines output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render(
|
|
11
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
stream: TextIO = sys.stdout,
|
|
14
|
+
) -> None:
|
|
15
|
+
if isinstance(data, dict):
|
|
16
|
+
stream.write(json.dumps(data))
|
|
17
|
+
stream.write("\n")
|
|
18
|
+
return
|
|
19
|
+
for record in data:
|
|
20
|
+
stream.write(json.dumps(record))
|
|
21
|
+
stream.write("\n")
|
nsc/output/render.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Single output entry point + format selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
from nsc.config.models import OutputFormat
|
|
9
|
+
from nsc.output import csv_, json_, jsonl, table, yaml_
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render(
|
|
13
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
14
|
+
*,
|
|
15
|
+
format: OutputFormat,
|
|
16
|
+
columns: list[str] | None = None,
|
|
17
|
+
stream: TextIO = sys.stdout,
|
|
18
|
+
compact: bool = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
if format is OutputFormat.JSON:
|
|
21
|
+
json_.render(data, stream=stream, compact=compact)
|
|
22
|
+
elif format is OutputFormat.JSONL:
|
|
23
|
+
jsonl.render(data, stream=stream)
|
|
24
|
+
elif format is OutputFormat.YAML:
|
|
25
|
+
yaml_.render(data, stream=stream)
|
|
26
|
+
elif format is OutputFormat.CSV:
|
|
27
|
+
csv_.render(data, stream=stream, columns=columns)
|
|
28
|
+
elif format is OutputFormat.TABLE:
|
|
29
|
+
table.render(data, stream=stream, columns=columns)
|
|
30
|
+
else: # pragma: no cover (StrEnum exhaustively covered above)
|
|
31
|
+
raise ValueError(f"unknown output format: {format!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def select_format(
|
|
35
|
+
*,
|
|
36
|
+
cli_value: str | None,
|
|
37
|
+
env_value: str | None,
|
|
38
|
+
is_tty: bool,
|
|
39
|
+
default: OutputFormat,
|
|
40
|
+
) -> OutputFormat:
|
|
41
|
+
if cli_value is not None:
|
|
42
|
+
return OutputFormat(cli_value)
|
|
43
|
+
if env_value is not None:
|
|
44
|
+
return OutputFormat(env_value)
|
|
45
|
+
if not is_tty:
|
|
46
|
+
return OutputFormat.JSON
|
|
47
|
+
return default
|
nsc/output/table.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Rich table output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from nsc.output.flatten import flatten
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render(
|
|
15
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
16
|
+
*,
|
|
17
|
+
stream: TextIO = sys.stdout,
|
|
18
|
+
columns: list[str] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
records = [data] if isinstance(data, dict) else list(data)
|
|
21
|
+
if not records:
|
|
22
|
+
Console(file=stream, force_terminal=False).print("(no records)")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
flat_records = [flatten(r, columns=columns) for r in records]
|
|
26
|
+
fieldnames = columns if columns is not None else _gather_fieldnames(flat_records)
|
|
27
|
+
|
|
28
|
+
table = Table(show_header=True, header_style="bold")
|
|
29
|
+
for col in fieldnames:
|
|
30
|
+
table.add_column(col)
|
|
31
|
+
for r in flat_records:
|
|
32
|
+
table.add_row(*[_format_cell(r.get(col, "")) for col in fieldnames])
|
|
33
|
+
|
|
34
|
+
Console(file=stream, force_terminal=False).print(table)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _gather_fieldnames(records: list[dict[str, Any]]) -> list[str]:
|
|
38
|
+
seen: dict[str, None] = {}
|
|
39
|
+
for r in records:
|
|
40
|
+
for k in r:
|
|
41
|
+
seen.setdefault(k, None)
|
|
42
|
+
return list(seen.keys())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format_cell(value: Any) -> str:
|
|
46
|
+
if value is None:
|
|
47
|
+
return ""
|
|
48
|
+
if isinstance(value, bool):
|
|
49
|
+
return "true" if value else "false"
|
|
50
|
+
return str(value)
|
nsc/output/yaml_.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""YAML output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
from ruamel.yaml import YAML
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _emitter() -> YAML:
|
|
12
|
+
"""A safe-mode emitter configured for stable, block-style output.
|
|
13
|
+
|
|
14
|
+
ruamel.yaml's safe dumper preserves regular `dict` insertion order natively
|
|
15
|
+
(Python 3.7+), so no explicit `sort_keys=False` toggle is needed — and
|
|
16
|
+
`YAML.sort_keys` is not part of the public API anyway.
|
|
17
|
+
"""
|
|
18
|
+
yaml = YAML(typ="safe", pure=True)
|
|
19
|
+
yaml.default_flow_style = False
|
|
20
|
+
return yaml
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(
|
|
24
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
stream: TextIO = sys.stdout,
|
|
27
|
+
) -> None:
|
|
28
|
+
_emitter().dump(data, stream)
|
nsc/schema/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""OpenAPI parsing layer."""
|
nsc/schema/hashing.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Canonical hashing for schema bodies.
|
|
2
|
+
|
|
3
|
+
Two schema bodies that differ only in key ordering (or whitespace) hash to the
|
|
4
|
+
same value. This is the cache key for generated command-models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def canonical_sha256(body: bytes) -> str:
|
|
14
|
+
"""Return the SHA-256 of `body` after JSON canonicalization.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ValueError: if `body` is not valid JSON.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
decoded = json.loads(body)
|
|
21
|
+
except json.JSONDecodeError as exc:
|
|
22
|
+
raise ValueError(f"schema body is not valid JSON: {exc}") from exc
|
|
23
|
+
canonical = json.dumps(decoded, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
24
|
+
return hashlib.sha256(canonical).hexdigest()
|
nsc/schema/loader.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Load an OpenAPI schema from a URL or local file path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from nsc.schema.hashing import canonical_sha256
|
|
12
|
+
from nsc.schema.models import OpenAPIDocument
|
|
13
|
+
|
|
14
|
+
_HTTP_ERROR_THRESHOLD = 400
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaLoadError(Exception):
|
|
18
|
+
"""Raised when a schema cannot be loaded or parsed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class LoadedSchema:
|
|
23
|
+
"""A parsed OpenAPI document plus the hash of its canonical body."""
|
|
24
|
+
|
|
25
|
+
source: str
|
|
26
|
+
body: bytes
|
|
27
|
+
hash: str
|
|
28
|
+
document: OpenAPIDocument
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_schema(source: str, *, verify_ssl: bool = True, timeout: float = 30.0) -> LoadedSchema:
|
|
32
|
+
"""Load and parse the schema at `source`.
|
|
33
|
+
|
|
34
|
+
`source` is either an `http(s)://` URL or a local filesystem path. The path
|
|
35
|
+
can be absolute or relative to the current working directory.
|
|
36
|
+
"""
|
|
37
|
+
body = _fetch_body(source, verify_ssl=verify_ssl, timeout=timeout)
|
|
38
|
+
if source.endswith(".gz"):
|
|
39
|
+
try:
|
|
40
|
+
body = gzip.decompress(body)
|
|
41
|
+
except gzip.BadGzipFile as exc:
|
|
42
|
+
raise SchemaLoadError(f"{source}: not valid gzip ({exc})") from exc
|
|
43
|
+
try:
|
|
44
|
+
h = canonical_sha256(body)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise SchemaLoadError(f"{source}: not valid JSON ({exc})") from exc
|
|
47
|
+
try:
|
|
48
|
+
doc = OpenAPIDocument.model_validate_json(body)
|
|
49
|
+
except Exception as exc: # pydantic.ValidationError
|
|
50
|
+
raise SchemaLoadError(f"{source}: schema does not match expected shape: {exc}") from exc
|
|
51
|
+
return LoadedSchema(source=source, body=body, hash=h, document=doc)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fetch_body(source: str, *, verify_ssl: bool, timeout: float) -> bytes:
|
|
55
|
+
if source.startswith(("http://", "https://")):
|
|
56
|
+
try:
|
|
57
|
+
response = httpx.get(source, verify=verify_ssl, timeout=timeout, follow_redirects=True)
|
|
58
|
+
except httpx.HTTPError as exc:
|
|
59
|
+
raise SchemaLoadError(f"{source}: request failed ({exc})") from exc
|
|
60
|
+
if response.status_code >= _HTTP_ERROR_THRESHOLD:
|
|
61
|
+
raise SchemaLoadError(f"{source}: HTTP {response.status_code}")
|
|
62
|
+
return response.content
|
|
63
|
+
p = Path(source)
|
|
64
|
+
if not p.exists():
|
|
65
|
+
raise SchemaLoadError(f"{source}: not found")
|
|
66
|
+
return p.read_bytes()
|
nsc/schema/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Pydantic models for the OpenAPI 3.x subset that NetBox emits and we consume.
|
|
2
|
+
|
|
3
|
+
Anything we don't explicitly model is tolerated via `extra="allow"` on root
|
|
4
|
+
container types, so vendor extensions and OpenAPI features we don't use can't
|
|
5
|
+
break parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Tolerant(BaseModel):
|
|
17
|
+
"""Base for models that should accept unknown fields."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ParameterIn(StrEnum):
|
|
23
|
+
QUERY = "query"
|
|
24
|
+
PATH = "path"
|
|
25
|
+
HEADER = "header"
|
|
26
|
+
COOKIE = "cookie"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SchemaObject(_Tolerant):
|
|
30
|
+
"""A JSON Schema fragment as it appears inside an OpenAPI document.
|
|
31
|
+
|
|
32
|
+
We only model the fields we use; the rest is preserved via `extra="allow"`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
type: str | None = None
|
|
36
|
+
format: str | None = None
|
|
37
|
+
enum: list[Any] | None = None
|
|
38
|
+
items: SchemaObject | None = None
|
|
39
|
+
properties: dict[str, SchemaObject] | None = None
|
|
40
|
+
required: list[str] | None = None
|
|
41
|
+
description: str | None = None
|
|
42
|
+
ref: str | None = Field(default=None, alias="$ref")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MediaType(_Tolerant):
|
|
46
|
+
schema_: SchemaObject | None = Field(default=None, alias="schema")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequestBody(_Tolerant):
|
|
50
|
+
description: str | None = None
|
|
51
|
+
required: bool = False
|
|
52
|
+
content: dict[str, MediaType] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Response(_Tolerant):
|
|
56
|
+
description: str | None = None
|
|
57
|
+
content: dict[str, MediaType] = Field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Parameter(_Tolerant):
|
|
61
|
+
name: str
|
|
62
|
+
in_: ParameterIn = Field(alias="in")
|
|
63
|
+
description: str | None = None
|
|
64
|
+
required: bool = False
|
|
65
|
+
schema_: SchemaObject | None = Field(default=None, alias="schema")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Operation(_Tolerant):
|
|
69
|
+
operation_id: str | None = Field(default=None, alias="operationId")
|
|
70
|
+
summary: str | None = None
|
|
71
|
+
description: str | None = None
|
|
72
|
+
tags: list[str] = Field(default_factory=list)
|
|
73
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
74
|
+
request_body: RequestBody | None = Field(default=None, alias="requestBody")
|
|
75
|
+
responses: dict[str, Response] = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PathItem(_Tolerant):
|
|
79
|
+
get: Operation | None = None
|
|
80
|
+
put: Operation | None = None
|
|
81
|
+
post: Operation | None = None
|
|
82
|
+
delete: Operation | None = None
|
|
83
|
+
patch: Operation | None = None
|
|
84
|
+
options: Operation | None = None
|
|
85
|
+
head: Operation | None = None
|
|
86
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Info(_Tolerant):
|
|
90
|
+
title: str
|
|
91
|
+
version: str
|
|
92
|
+
description: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Tag(_Tolerant):
|
|
96
|
+
name: str
|
|
97
|
+
description: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Components(_Tolerant):
|
|
101
|
+
schemas: dict[str, SchemaObject] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OpenAPIDocument(_Tolerant):
|
|
105
|
+
openapi: str
|
|
106
|
+
info: Info
|
|
107
|
+
paths: dict[str, PathItem] = Field(default_factory=dict)
|
|
108
|
+
components: Components = Field(default_factory=Components)
|
|
109
|
+
tags: list[Tag] = Field(default_factory=list)
|
nsc/schema/source.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Schema-source resolution chain (Phase 2).
|
|
2
|
+
|
|
3
|
+
Order:
|
|
4
|
+
1. --schema flag (path or URL)
|
|
5
|
+
2. profile.schema_url
|
|
6
|
+
3. {profile.url}/api/schema/?format=json
|
|
7
|
+
4. cache hit (matched by profile name + schema hash)
|
|
8
|
+
5. bundled fallback
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from nsc.builder.build import build_command_model
|
|
19
|
+
from nsc.cache.store import CacheStore
|
|
20
|
+
from nsc.cli.runtime import ResolvedProfile
|
|
21
|
+
from nsc.config.settings import Paths
|
|
22
|
+
from nsc.model.command_model import CommandModel
|
|
23
|
+
from nsc.schema.hashing import canonical_sha256
|
|
24
|
+
from nsc.schema.loader import LoadedSchema, load_schema
|
|
25
|
+
from nsc.schema.models import OpenAPIDocument
|
|
26
|
+
from nsc.schemas import bundled as _bundled_pkg
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SchemaSourceError(Exception):
|
|
30
|
+
"""Raised when no schema source could be resolved."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_command_model(
|
|
34
|
+
*,
|
|
35
|
+
paths: Paths,
|
|
36
|
+
profile: ResolvedProfile,
|
|
37
|
+
schema_override: str | None,
|
|
38
|
+
) -> CommandModel:
|
|
39
|
+
if schema_override is not None:
|
|
40
|
+
loaded = load_schema(
|
|
41
|
+
schema_override, verify_ssl=profile.verify_ssl, timeout=profile.timeout
|
|
42
|
+
)
|
|
43
|
+
return _build_and_cache(loaded, paths, profile)
|
|
44
|
+
|
|
45
|
+
schema_url = (
|
|
46
|
+
str(profile.schema_url)
|
|
47
|
+
if profile.schema_url is not None
|
|
48
|
+
else f"{str(profile.url).rstrip('/')}/api/schema/?format=json"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
loaded = _fetch_schema(schema_url, profile)
|
|
53
|
+
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
|
|
54
|
+
cached = _find_any_cached(paths, profile.name)
|
|
55
|
+
if cached is not None:
|
|
56
|
+
print(
|
|
57
|
+
f"Could not reach NetBox ({exc}); using cached schema.",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
return cached
|
|
61
|
+
bundled = _load_bundled_command_model()
|
|
62
|
+
if bundled is not None:
|
|
63
|
+
print(
|
|
64
|
+
f"Could not reach NetBox ({exc}) and no cache; using bundled schema.",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
return bundled
|
|
68
|
+
raise SchemaSourceError(
|
|
69
|
+
f"could not reach NetBox at {schema_url}, no cache, no bundled fallback: {exc}"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
return _build_and_cache(loaded, paths, profile)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fetch_schema(url: str, profile: ResolvedProfile) -> LoadedSchema:
|
|
76
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
77
|
+
if profile.token:
|
|
78
|
+
headers["Authorization"] = f"Token {profile.token}"
|
|
79
|
+
with httpx.Client(verify=profile.verify_ssl, timeout=profile.timeout, headers=headers) as c:
|
|
80
|
+
response = c.get(url)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
body = response.content
|
|
83
|
+
document = OpenAPIDocument.model_validate_json(body)
|
|
84
|
+
return LoadedSchema(source=url, body=body, hash=canonical_sha256(body), document=document)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_and_cache(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel:
|
|
88
|
+
store = CacheStore(root=paths.cache_dir)
|
|
89
|
+
cached = store.load(profile.name, loaded.hash)
|
|
90
|
+
if cached is not None:
|
|
91
|
+
return cached
|
|
92
|
+
model = build_command_model(loaded)
|
|
93
|
+
store.save(profile.name, model)
|
|
94
|
+
return model
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _find_any_cached(paths: Paths, profile_name: str) -> CommandModel | None:
|
|
98
|
+
profile_dir = paths.cache_dir / profile_name
|
|
99
|
+
if not profile_dir.exists():
|
|
100
|
+
return None
|
|
101
|
+
candidates = sorted(
|
|
102
|
+
profile_dir.glob("*.json"),
|
|
103
|
+
key=lambda p: p.stat().st_mtime,
|
|
104
|
+
reverse=True,
|
|
105
|
+
)
|
|
106
|
+
for candidate in candidates:
|
|
107
|
+
try:
|
|
108
|
+
return CommandModel.model_validate_json(candidate.read_text())
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_bundled_command_model() -> CommandModel | None:
|
|
115
|
+
pkg_dir = Path(_bundled_pkg.__file__).resolve().parent
|
|
116
|
+
candidates = sorted(list(pkg_dir.glob("*.json")) + list(pkg_dir.glob("*.json.gz")))
|
|
117
|
+
if not candidates:
|
|
118
|
+
return None
|
|
119
|
+
loaded = load_schema(str(candidates[0]))
|
|
120
|
+
return build_command_model(loaded)
|
nsc/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled NetBox OpenAPI snapshots and helpers."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Versioned NetBox OpenAPI snapshots, used as offline fallback."""
|
|
Binary file
|
nsc/skill/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Access to the bundled portable Skill file.
|
|
2
|
+
|
|
3
|
+
The canonical source of truth lives at `skills/netbox-super-cli/SKILL.md` at
|
|
4
|
+
the repo root; `pyproject.toml`'s wheel `force-include` ships the same path
|
|
5
|
+
inside the installed wheel. The helper below uses `importlib.resources` so
|
|
6
|
+
both layouts work without special-casing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from importlib.resources import as_file, files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
__all__ = ["BUNDLE_NAME", "bundle_path"]
|
|
17
|
+
|
|
18
|
+
BUNDLE_NAME = "netbox-super-cli"
|
|
19
|
+
"""The bundled Skill's directory name; used by the install helper to derive
|
|
20
|
+
the per-target destination directory."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def bundle_path() -> Iterator[Path]:
|
|
25
|
+
"""Yield an on-disk Path to the bundled `SKILL.md`.
|
|
26
|
+
|
|
27
|
+
Works in both the source-tree layout (file lives at
|
|
28
|
+
`<repo>/skills/netbox-super-cli/SKILL.md`) and the installed-wheel layout
|
|
29
|
+
(same relative path inside the wheel; `importlib.resources.as_file` will
|
|
30
|
+
materialize a real file when reading from a zipped wheel).
|
|
31
|
+
"""
|
|
32
|
+
nsc_root = files("nsc")
|
|
33
|
+
skill_md = nsc_root.joinpath("..", "skills", BUNDLE_NAME, "SKILL.md")
|
|
34
|
+
with as_file(skill_md) as p:
|
|
35
|
+
yield p
|