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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The normalized command-model.
|
|
2
|
+
|
|
3
|
+
This module imports nothing from `nsc.cli`, `nsc.http`, Typer, Rich, or any
|
|
4
|
+
other framework. It is pure data plus a few traversal helpers. Anything else
|
|
5
|
+
in `nsc/` that needs the command tree depends on this module — and only this
|
|
6
|
+
module — for it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpMethod(StrEnum):
|
|
19
|
+
GET = "GET"
|
|
20
|
+
POST = "POST"
|
|
21
|
+
PATCH = "PATCH"
|
|
22
|
+
PUT = "PUT"
|
|
23
|
+
DELETE = "DELETE"
|
|
24
|
+
OPTIONS = "OPTIONS"
|
|
25
|
+
HEAD = "HEAD"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParameterLocation(StrEnum):
|
|
29
|
+
QUERY = "query"
|
|
30
|
+
PATH = "path"
|
|
31
|
+
HEADER = "header"
|
|
32
|
+
COOKIE = "cookie"
|
|
33
|
+
BODY = "body"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PrimitiveType(StrEnum):
|
|
37
|
+
STRING = "string"
|
|
38
|
+
INTEGER = "integer"
|
|
39
|
+
NUMBER = "number"
|
|
40
|
+
BOOLEAN = "boolean"
|
|
41
|
+
ARRAY = "array"
|
|
42
|
+
OBJECT = "object"
|
|
43
|
+
UNKNOWN = "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Frozen(BaseModel):
|
|
47
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Parameter(_Frozen):
|
|
51
|
+
name: str
|
|
52
|
+
location: ParameterLocation
|
|
53
|
+
primitive: PrimitiveType = PrimitiveType.UNKNOWN
|
|
54
|
+
required: bool = False
|
|
55
|
+
description: str | None = None
|
|
56
|
+
enum: list[str] | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FieldShape(_Frozen):
|
|
60
|
+
primitive: PrimitiveType = PrimitiveType.UNKNOWN
|
|
61
|
+
enum: list[str] | None = None
|
|
62
|
+
nullable: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RequestBodyShape(_Frozen):
|
|
66
|
+
top_level: Literal["object", "array", "object_or_array"]
|
|
67
|
+
required: list[str] = Field(default_factory=list)
|
|
68
|
+
fields: dict[str, FieldShape] = Field(default_factory=dict)
|
|
69
|
+
sensitive_paths: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Operation(_Frozen):
|
|
73
|
+
operation_id: str
|
|
74
|
+
http_method: HttpMethod
|
|
75
|
+
path: str
|
|
76
|
+
summary: str | None = None
|
|
77
|
+
description: str | None = None
|
|
78
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
79
|
+
request_body: RequestBodyShape | None = None
|
|
80
|
+
default_columns: list[str] | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Resource(_Frozen):
|
|
84
|
+
name: str
|
|
85
|
+
list_op: Operation | None = None
|
|
86
|
+
get_op: Operation | None = None
|
|
87
|
+
create_op: Operation | None = None
|
|
88
|
+
update_op: Operation | None = None
|
|
89
|
+
replace_op: Operation | None = None
|
|
90
|
+
delete_op: Operation | None = None
|
|
91
|
+
custom_actions: list[Operation] = Field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Tag(_Frozen):
|
|
95
|
+
name: str
|
|
96
|
+
description: str | None = None
|
|
97
|
+
resources: dict[str, Resource] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CommandModel(_Frozen):
|
|
101
|
+
"""The full normalized tree: tags → resources → operations."""
|
|
102
|
+
|
|
103
|
+
info_title: str
|
|
104
|
+
info_version: str
|
|
105
|
+
schema_hash: str
|
|
106
|
+
tags: dict[str, Tag] = Field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
def iter_operations(self) -> Iterator[tuple[str, str, Operation]]:
|
|
109
|
+
"""Yield `(tag, resource, operation)` triples in deterministic order."""
|
|
110
|
+
for tag_name in sorted(self.tags):
|
|
111
|
+
tag = self.tags[tag_name]
|
|
112
|
+
for resource_name in sorted(tag.resources):
|
|
113
|
+
resource = tag.resources[resource_name]
|
|
114
|
+
for op in (
|
|
115
|
+
resource.list_op,
|
|
116
|
+
resource.get_op,
|
|
117
|
+
resource.create_op,
|
|
118
|
+
resource.update_op,
|
|
119
|
+
resource.replace_op,
|
|
120
|
+
resource.delete_op,
|
|
121
|
+
):
|
|
122
|
+
if op is not None:
|
|
123
|
+
yield (tag_name, resource_name, op)
|
|
124
|
+
for op in resource.custom_actions:
|
|
125
|
+
yield (tag_name, resource_name, op)
|
nsc/output/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output formatters: flatten + json/jsonl/yaml/csv. Table + dispatch land in Task 9."""
|
nsc/output/csv_.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CSV output formatter (with nested-field flattening)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
from nsc.output.flatten import flatten
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render(
|
|
13
|
+
data: list[dict[str, Any]] | dict[str, Any],
|
|
14
|
+
*,
|
|
15
|
+
stream: TextIO = sys.stdout,
|
|
16
|
+
columns: list[str] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
records = [data] if isinstance(data, dict) else list(data)
|
|
19
|
+
if not records:
|
|
20
|
+
return
|
|
21
|
+
flat_records = [flatten(r, columns=columns) for r in records]
|
|
22
|
+
fieldnames = columns if columns is not None else _gather_fieldnames(flat_records)
|
|
23
|
+
writer = csv.DictWriter(stream, fieldnames=fieldnames, extrasaction="ignore")
|
|
24
|
+
writer.writeheader()
|
|
25
|
+
for row in flat_records:
|
|
26
|
+
writer.writerow({k: ("" if v is None else v) for k, v in row.items()})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gather_fieldnames(records: list[dict[str, Any]]) -> list[str]:
|
|
30
|
+
seen: dict[str, None] = {}
|
|
31
|
+
for r in records:
|
|
32
|
+
for k in r:
|
|
33
|
+
seen.setdefault(k, None)
|
|
34
|
+
return list(seen.keys())
|
nsc/output/errors.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Stable JSON error envelope and exit-code mapping for Phase 3+.
|
|
2
|
+
|
|
3
|
+
This module is the agent contract. Field names, values of `ErrorType`, and the
|
|
4
|
+
`EXIT_CODES` table must never change once shipped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum, StrEnum
|
|
10
|
+
from typing import Any, Literal, TextIO
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from nsc.config.models import OutputFormat
|
|
17
|
+
from nsc.model.command_model import HttpMethod
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorType(StrEnum):
|
|
21
|
+
AUTH = "auth"
|
|
22
|
+
NOT_FOUND = "not_found"
|
|
23
|
+
VALIDATION = "validation"
|
|
24
|
+
CONFLICT = "conflict"
|
|
25
|
+
RATE_LIMITED = "rate_limited"
|
|
26
|
+
SERVER = "server"
|
|
27
|
+
TRANSPORT = "transport"
|
|
28
|
+
SCHEMA = "schema"
|
|
29
|
+
CONFIG = "config"
|
|
30
|
+
CLIENT = "client"
|
|
31
|
+
INTERNAL = "internal"
|
|
32
|
+
AMBIGUOUS_ALIAS = "ambiguous_alias"
|
|
33
|
+
UNKNOWN_ALIAS = "unknown_alias"
|
|
34
|
+
INPUT_ERROR = "input_error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
EXIT_CODES: dict[ErrorType, int] = {
|
|
38
|
+
ErrorType.INTERNAL: 1,
|
|
39
|
+
ErrorType.SCHEMA: 3,
|
|
40
|
+
ErrorType.VALIDATION: 4,
|
|
41
|
+
ErrorType.INPUT_ERROR: 4,
|
|
42
|
+
ErrorType.SERVER: 5,
|
|
43
|
+
ErrorType.CLIENT: 6,
|
|
44
|
+
ErrorType.TRANSPORT: 7,
|
|
45
|
+
ErrorType.AUTH: 8,
|
|
46
|
+
ErrorType.NOT_FOUND: 9,
|
|
47
|
+
ErrorType.CONFLICT: 10,
|
|
48
|
+
ErrorType.RATE_LIMITED: 11,
|
|
49
|
+
ErrorType.CONFIG: 12,
|
|
50
|
+
ErrorType.AMBIGUOUS_ALIAS: 13,
|
|
51
|
+
ErrorType.UNKNOWN_ALIAS: 14,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Frozen(BaseModel):
|
|
56
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ErrorEnvelope(_Frozen):
|
|
60
|
+
error: str
|
|
61
|
+
type: ErrorType
|
|
62
|
+
endpoint: str | None = None
|
|
63
|
+
method: HttpMethod | None = None
|
|
64
|
+
operation_id: str | None = None
|
|
65
|
+
status_code: int | None = None
|
|
66
|
+
attempt_n: int | None = None
|
|
67
|
+
audit_log_path: str | None = None
|
|
68
|
+
record_index: int | None = None
|
|
69
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_to_json(env: ErrorEnvelope) -> str:
|
|
73
|
+
"""Serialize the envelope as compact, one-line JSON for stdout."""
|
|
74
|
+
return env.model_dump_json()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RenderTarget(Enum):
|
|
78
|
+
JSON_STDOUT = "json_stdout"
|
|
79
|
+
JSON_STDERR = "json_stderr"
|
|
80
|
+
RICH_STDERR = "rich_stderr"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def select_render_target(*, output_format: OutputFormat, stdout_is_tty: bool) -> RenderTarget:
|
|
84
|
+
"""Decide where the error envelope should render.
|
|
85
|
+
|
|
86
|
+
Spec §4.2.3:
|
|
87
|
+
- --output json (or table-piped, which falls back to json) → JSON to stdout.
|
|
88
|
+
- --output table on a TTY → Rich panel to stderr.
|
|
89
|
+
- Any other piped format (csv/yaml/jsonl) → JSON to stderr; stdout
|
|
90
|
+
reserved for partial-success records produced by the formatter.
|
|
91
|
+
"""
|
|
92
|
+
if output_format is OutputFormat.JSON:
|
|
93
|
+
return RenderTarget.JSON_STDOUT
|
|
94
|
+
if output_format is OutputFormat.TABLE:
|
|
95
|
+
if stdout_is_tty:
|
|
96
|
+
return RenderTarget.RICH_STDERR
|
|
97
|
+
return RenderTarget.JSON_STDOUT
|
|
98
|
+
return RenderTarget.JSON_STDERR
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_to_rich_stderr(env: ErrorEnvelope, *, stream: TextIO) -> None:
|
|
102
|
+
"""Render the envelope as a Rich panel to the given stream (stderr in prod)."""
|
|
103
|
+
console = Console(file=stream, soft_wrap=True, force_terminal=False)
|
|
104
|
+
body_lines = [
|
|
105
|
+
f"[bold red]{env.type.value}[/]: {env.error}",
|
|
106
|
+
]
|
|
107
|
+
if env.endpoint:
|
|
108
|
+
body_lines.append(f"endpoint: {env.endpoint}")
|
|
109
|
+
if env.method is not None:
|
|
110
|
+
body_lines.append(f"method: {env.method.value}")
|
|
111
|
+
if env.status_code is not None:
|
|
112
|
+
body_lines.append(f"status: {env.status_code}")
|
|
113
|
+
if env.operation_id:
|
|
114
|
+
body_lines.append(f"op: {env.operation_id}")
|
|
115
|
+
if env.attempt_n is not None:
|
|
116
|
+
body_lines.append(f"attempt: {env.attempt_n}")
|
|
117
|
+
if env.audit_log_path:
|
|
118
|
+
body_lines.append(f"audit: {env.audit_log_path}")
|
|
119
|
+
if env.details:
|
|
120
|
+
body_lines.append(f"details: {env.details}")
|
|
121
|
+
console.print(Panel("\n".join(body_lines), title="nsc error", border_style="red"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ClientError(Exception):
|
|
125
|
+
"""A user-facing CLI usage error that should map directly to an ErrorEnvelope.
|
|
126
|
+
|
|
127
|
+
Carries a fully-shaped envelope so the handler can re-emit it without
|
|
128
|
+
re-classifying.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, envelope: ErrorEnvelope) -> None:
|
|
132
|
+
super().__init__(envelope.error)
|
|
133
|
+
self.envelope = envelope
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def client_envelope(
|
|
137
|
+
message: str, *, operation_id: str | None = None, **details: Any
|
|
138
|
+
) -> ErrorEnvelope:
|
|
139
|
+
return ErrorEnvelope(
|
|
140
|
+
error=message,
|
|
141
|
+
type=ErrorType.CLIENT,
|
|
142
|
+
operation_id=operation_id,
|
|
143
|
+
details=details,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def input_error_envelope(
|
|
148
|
+
*,
|
|
149
|
+
message: str,
|
|
150
|
+
bad_lines: list[dict[str, Any]],
|
|
151
|
+
operation_id: str | None = None,
|
|
152
|
+
) -> ErrorEnvelope:
|
|
153
|
+
"""Build the structured envelope for NDJSON parse failures.
|
|
154
|
+
|
|
155
|
+
`bad_lines` is a list of `{"line": int, "reason": str}` dicts. The caller
|
|
156
|
+
is responsible for capping the list at 20 entries (spec §4.4).
|
|
157
|
+
"""
|
|
158
|
+
return ErrorEnvelope(
|
|
159
|
+
error=message,
|
|
160
|
+
type=ErrorType.INPUT_ERROR,
|
|
161
|
+
operation_id=operation_id,
|
|
162
|
+
details={"bad_lines": bad_lines},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_VERB_TO_FULL_PATH_VERB: dict[str, str] = {
|
|
167
|
+
"ls": "list",
|
|
168
|
+
"get": "get",
|
|
169
|
+
"rm": "delete",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ambiguous_alias_envelope(
|
|
174
|
+
*,
|
|
175
|
+
verb: str,
|
|
176
|
+
term: str,
|
|
177
|
+
candidates: list[tuple[str, str]],
|
|
178
|
+
) -> ErrorEnvelope:
|
|
179
|
+
"""Build the envelope for an alias term that resolves to ≥2 resources.
|
|
180
|
+
|
|
181
|
+
`candidates` is a list of `(tag, resource)` pairs; the envelope renders
|
|
182
|
+
them as a list of `{"tag": ..., "resource": ...}` objects so JSON
|
|
183
|
+
consumers don't have to parse positional pairs.
|
|
184
|
+
"""
|
|
185
|
+
if verb not in _VERB_TO_FULL_PATH_VERB:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"ambiguous_alias_envelope does not support verb={verb!r} "
|
|
188
|
+
f"(supported: {sorted(_VERB_TO_FULL_PATH_VERB)})"
|
|
189
|
+
)
|
|
190
|
+
rendered = [{"tag": t, "resource": r} for t, r in candidates]
|
|
191
|
+
pretty = ", ".join(f"`{t} {r}`" for t, r in candidates)
|
|
192
|
+
return ErrorEnvelope(
|
|
193
|
+
error=(
|
|
194
|
+
f"`nsc {verb} {term}` is ambiguous — matches: {pretty}. "
|
|
195
|
+
f"Use the full path: `nsc <tag> <resource> {_VERB_TO_FULL_PATH_VERB[verb]}`."
|
|
196
|
+
),
|
|
197
|
+
type=ErrorType.AMBIGUOUS_ALIAS,
|
|
198
|
+
details={"verb": verb, "term": term, "candidates": rendered},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def unknown_alias_envelope(
|
|
203
|
+
*,
|
|
204
|
+
verb: str,
|
|
205
|
+
term: str,
|
|
206
|
+
reason: str = "no_such_resource",
|
|
207
|
+
) -> ErrorEnvelope:
|
|
208
|
+
"""Build the envelope for an alias term that resolves to zero resources.
|
|
209
|
+
|
|
210
|
+
`reason="no_such_resource"` is the standard ls/get/rm case; the message
|
|
211
|
+
suggests `nsc commands` for resource discovery. `reason="search_endpoint_unavailable"`
|
|
212
|
+
is the search-specific case (schema does not expose `/api/search/`); the
|
|
213
|
+
message must not suggest `nsc commands` since that wouldn't help.
|
|
214
|
+
"""
|
|
215
|
+
if reason == "search_endpoint_unavailable":
|
|
216
|
+
message = (
|
|
217
|
+
"this NetBox schema does not expose `/api/search/`; "
|
|
218
|
+
"`nsc search` is unavailable for this server"
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
message = (
|
|
222
|
+
f"unknown resource `{term}` for `nsc {verb}`; "
|
|
223
|
+
f"run `nsc commands` to list known resources"
|
|
224
|
+
)
|
|
225
|
+
return ErrorEnvelope(
|
|
226
|
+
error=message,
|
|
227
|
+
type=ErrorType.UNKNOWN_ALIAS,
|
|
228
|
+
details={"verb": verb, "term": term, "reason": reason},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
ERROR_TYPE_PRECEDENCE: list[ErrorType] = [
|
|
233
|
+
ErrorType.INTERNAL,
|
|
234
|
+
ErrorType.TRANSPORT,
|
|
235
|
+
ErrorType.SERVER,
|
|
236
|
+
ErrorType.VALIDATION,
|
|
237
|
+
ErrorType.CONFLICT,
|
|
238
|
+
ErrorType.RATE_LIMITED,
|
|
239
|
+
ErrorType.NOT_FOUND,
|
|
240
|
+
ErrorType.AUTH,
|
|
241
|
+
ErrorType.INPUT_ERROR,
|
|
242
|
+
ErrorType.CLIENT,
|
|
243
|
+
ErrorType.SCHEMA,
|
|
244
|
+
ErrorType.CONFIG,
|
|
245
|
+
ErrorType.AMBIGUOUS_ALIAS,
|
|
246
|
+
ErrorType.UNKNOWN_ALIAS,
|
|
247
|
+
]
|
|
248
|
+
"""Strict ordering for picking a single exit code from a mixed-failure run.
|
|
249
|
+
|
|
250
|
+
Used by `--on-error continue` to map a list of per-record failures to one
|
|
251
|
+
final exit code. Spec §4.5.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def worst_error_type(types: list[ErrorType]) -> ErrorType:
|
|
256
|
+
if not types:
|
|
257
|
+
raise ValueError("worst_error_type called with empty list")
|
|
258
|
+
return next(t for t in ERROR_TYPE_PRECEDENCE if t in types)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def summary_envelope(
|
|
262
|
+
*,
|
|
263
|
+
attempted: int,
|
|
264
|
+
failures: list[ErrorEnvelope],
|
|
265
|
+
on_error: Literal["stop", "continue"],
|
|
266
|
+
operation_id: str | None,
|
|
267
|
+
total_records: int,
|
|
268
|
+
) -> ErrorEnvelope:
|
|
269
|
+
"""Build the final envelope for a multi-record loop (spec §4.5, §7.3).
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
attempted: Number of records the loop reached (success + failure).
|
|
273
|
+
failures: Per-record failure envelopes (one per failed attempt).
|
|
274
|
+
on_error: "stop" or "continue" — controls envelope shape.
|
|
275
|
+
operation_id: Operation that the loop ran.
|
|
276
|
+
total_records: Total records in the input (for partial_progress.remaining).
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
ErrorEnvelope whose `type` equals the worst failure type by
|
|
280
|
+
precedence, with `details.partial_progress = {success, failed, remaining}`.
|
|
281
|
+
On "stop" sets `record_index` to the first failure's index. On "continue"
|
|
282
|
+
adds `details.failures: [{record_index, type, status_code, error}, ...]`.
|
|
283
|
+
"""
|
|
284
|
+
if not failures:
|
|
285
|
+
raise ValueError("summary_envelope requires at least one failure")
|
|
286
|
+
failed = len(failures)
|
|
287
|
+
success = attempted - failed
|
|
288
|
+
remaining = max(0, total_records - attempted)
|
|
289
|
+
chosen_type = worst_error_type([f.type for f in failures])
|
|
290
|
+
|
|
291
|
+
details: dict[str, Any] = {
|
|
292
|
+
"partial_progress": {
|
|
293
|
+
"success": success,
|
|
294
|
+
"failed": failed,
|
|
295
|
+
"remaining": remaining,
|
|
296
|
+
},
|
|
297
|
+
"on_error": on_error,
|
|
298
|
+
"applied": True,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if on_error == "stop":
|
|
302
|
+
first = failures[0]
|
|
303
|
+
return ErrorEnvelope(
|
|
304
|
+
error=first.error,
|
|
305
|
+
type=chosen_type,
|
|
306
|
+
operation_id=operation_id,
|
|
307
|
+
status_code=first.status_code,
|
|
308
|
+
record_index=first.record_index,
|
|
309
|
+
details=details,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
details["failures"] = [
|
|
313
|
+
{
|
|
314
|
+
"record_index": f.record_index,
|
|
315
|
+
"type": f.type.value,
|
|
316
|
+
"status_code": f.status_code,
|
|
317
|
+
"error": f.error,
|
|
318
|
+
}
|
|
319
|
+
for f in failures
|
|
320
|
+
]
|
|
321
|
+
return ErrorEnvelope(
|
|
322
|
+
error=(f"{failed} of {attempted} records failed (worst type: {chosen_type.value})"),
|
|
323
|
+
type=chosen_type,
|
|
324
|
+
operation_id=operation_id,
|
|
325
|
+
record_index=None,
|
|
326
|
+
details=details,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
__all__ = [
|
|
331
|
+
"ERROR_TYPE_PRECEDENCE",
|
|
332
|
+
"EXIT_CODES",
|
|
333
|
+
"ClientError",
|
|
334
|
+
"ErrorEnvelope",
|
|
335
|
+
"ErrorType",
|
|
336
|
+
"RenderTarget",
|
|
337
|
+
"ambiguous_alias_envelope",
|
|
338
|
+
"client_envelope",
|
|
339
|
+
"input_error_envelope",
|
|
340
|
+
"render_to_json",
|
|
341
|
+
"render_to_rich_stderr",
|
|
342
|
+
"select_render_target",
|
|
343
|
+
"summary_envelope",
|
|
344
|
+
"unknown_alias_envelope",
|
|
345
|
+
"worst_error_type",
|
|
346
|
+
]
|
nsc/output/explain.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""ExplainTrace and FieldDecision — agent contract for `--explain`.
|
|
2
|
+
|
|
3
|
+
Phase 3a defined the types; Phase 3b adds `build_for(...)` and renderers.
|
|
4
|
+
|
|
5
|
+
The schema_version on ExplainTrace is part of the agent contract: bump it on
|
|
6
|
+
any breaking change to the JSON shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal, TextIO
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
|
|
17
|
+
from nsc.cli.writes.apply import ResolvedRequest
|
|
18
|
+
from nsc.cli.writes.bulk import RoutingDecision
|
|
19
|
+
from nsc.cli.writes.input import RawWriteInput
|
|
20
|
+
from nsc.cli.writes.preflight import PreflightResult
|
|
21
|
+
from nsc.model.command_model import Operation
|
|
22
|
+
|
|
23
|
+
DECISION_CAP = 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Frozen(BaseModel):
|
|
27
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FieldDecision(_Frozen):
|
|
31
|
+
field_path: str
|
|
32
|
+
source: Literal["file", "field_flag", "default", "schema_cast"]
|
|
33
|
+
raw_value: Any
|
|
34
|
+
resolved_value: Any
|
|
35
|
+
note: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExplainTrace(_Frozen):
|
|
39
|
+
schema_version: int = 1
|
|
40
|
+
operation_id: str
|
|
41
|
+
operation_summary: str | None = None
|
|
42
|
+
method_reasoning: str
|
|
43
|
+
url_reasoning: str
|
|
44
|
+
bulk_reasoning: str | None = None
|
|
45
|
+
decisions: list[FieldDecision] = Field(default_factory=list)
|
|
46
|
+
decisions_truncated: bool = False
|
|
47
|
+
requests: list[ResolvedRequest] = Field(default_factory=list)
|
|
48
|
+
preflight: PreflightResult | None = None
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def build_for(
|
|
52
|
+
cls,
|
|
53
|
+
operation: Operation,
|
|
54
|
+
raw: RawWriteInput,
|
|
55
|
+
preflight: PreflightResult,
|
|
56
|
+
requests: list[ResolvedRequest],
|
|
57
|
+
*,
|
|
58
|
+
field_overrides: set[str],
|
|
59
|
+
routing_decision: RoutingDecision | None = None,
|
|
60
|
+
) -> ExplainTrace:
|
|
61
|
+
decisions, truncated = _build_decisions(raw, requests, field_overrides)
|
|
62
|
+
return cls(
|
|
63
|
+
operation_id=operation.operation_id,
|
|
64
|
+
operation_summary=operation.summary,
|
|
65
|
+
method_reasoning=_method_reasoning(operation),
|
|
66
|
+
url_reasoning=_url_reasoning(operation, requests),
|
|
67
|
+
bulk_reasoning=routing_decision.reasoning if routing_decision else None,
|
|
68
|
+
decisions=decisions,
|
|
69
|
+
decisions_truncated=truncated,
|
|
70
|
+
requests=list(requests),
|
|
71
|
+
preflight=preflight,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_decisions(
|
|
76
|
+
raw: RawWriteInput,
|
|
77
|
+
requests: list[ResolvedRequest],
|
|
78
|
+
field_overrides: set[str],
|
|
79
|
+
) -> tuple[list[FieldDecision], bool]:
|
|
80
|
+
decisions: list[FieldDecision] = []
|
|
81
|
+
for index, record in enumerate(raw.records):
|
|
82
|
+
resolved_body = _resolved_body_for_record(requests, index)
|
|
83
|
+
for key, raw_value in record.items():
|
|
84
|
+
if len(decisions) >= DECISION_CAP:
|
|
85
|
+
return decisions, True
|
|
86
|
+
resolved_value = (
|
|
87
|
+
resolved_body.get(key) if isinstance(resolved_body, dict) else raw_value
|
|
88
|
+
)
|
|
89
|
+
source = _decide_source(key, field_overrides, raw)
|
|
90
|
+
note = _decide_note(raw_value, resolved_value, key, field_overrides)
|
|
91
|
+
decisions.append(
|
|
92
|
+
FieldDecision(
|
|
93
|
+
field_path=_record_path(index, key, raw),
|
|
94
|
+
source=source,
|
|
95
|
+
raw_value=raw_value,
|
|
96
|
+
resolved_value=resolved_value,
|
|
97
|
+
note=note,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return decisions, False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolved_body_for_record(
|
|
104
|
+
requests: list[ResolvedRequest], record_index: int
|
|
105
|
+
) -> dict[str, Any] | None:
|
|
106
|
+
for r in requests:
|
|
107
|
+
if record_index in r.record_indices and isinstance(r.body, dict):
|
|
108
|
+
return r.body
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decide_source(
|
|
113
|
+
key: str, field_overrides: set[str], raw: RawWriteInput
|
|
114
|
+
) -> Literal["file", "field_flag", "default", "schema_cast"]:
|
|
115
|
+
if key in field_overrides:
|
|
116
|
+
return "field_flag"
|
|
117
|
+
if raw.source == "fields_only":
|
|
118
|
+
return "field_flag"
|
|
119
|
+
return "file"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _decide_note(
|
|
123
|
+
raw_value: Any, resolved_value: Any, key: str, field_overrides: set[str]
|
|
124
|
+
) -> str | None:
|
|
125
|
+
parts: list[str] = []
|
|
126
|
+
if key in field_overrides and raw_value is not None:
|
|
127
|
+
parts.append("override from --field flag")
|
|
128
|
+
if raw_value != resolved_value:
|
|
129
|
+
parts.append(f"schema_cast: {type(raw_value).__name__} → {type(resolved_value).__name__}")
|
|
130
|
+
return "; ".join(parts) if parts else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _record_path(index: int, key: str, raw: RawWriteInput) -> str:
|
|
134
|
+
return f"records[{index}].{key}" if raw.is_explicit_list else key
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _method_reasoning(operation: Operation) -> str:
|
|
138
|
+
return f"{operation.http_method.value} per OpenAPI operation {operation.operation_id!r}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _url_reasoning(operation: Operation, requests: list[ResolvedRequest]) -> str:
|
|
142
|
+
if not requests:
|
|
143
|
+
return f"path template {operation.path!r}; no path vars resolved"
|
|
144
|
+
request = requests[0]
|
|
145
|
+
if request.path_vars:
|
|
146
|
+
return f"path template {operation.path!r} with vars {request.path_vars}"
|
|
147
|
+
return f"path template {operation.path!r}; no path vars"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_to_json(trace: ExplainTrace) -> str:
|
|
151
|
+
return trace.model_dump_json()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_to_rich_stdout(trace: ExplainTrace, *, stream: TextIO) -> None:
|
|
155
|
+
console = Console(file=stream, soft_wrap=True, force_terminal=False)
|
|
156
|
+
body = [
|
|
157
|
+
f"[bold cyan]operation:[/] {trace.operation_id}",
|
|
158
|
+
]
|
|
159
|
+
if trace.operation_summary:
|
|
160
|
+
body.append(f"summary: {trace.operation_summary}")
|
|
161
|
+
body.append(f"method: {trace.method_reasoning}")
|
|
162
|
+
body.append(f"url: {trace.url_reasoning}")
|
|
163
|
+
if trace.bulk_reasoning:
|
|
164
|
+
body.append(f"bulk: {trace.bulk_reasoning}")
|
|
165
|
+
for r in trace.requests:
|
|
166
|
+
body.append(f" → {r.method.value} {r.url}")
|
|
167
|
+
if r.body is not None:
|
|
168
|
+
body.append(f" body: {r.body}")
|
|
169
|
+
if trace.preflight is not None and not trace.preflight.ok:
|
|
170
|
+
body.append("preflight: [red]FAIL[/]")
|
|
171
|
+
for issue in trace.preflight.issues:
|
|
172
|
+
loc = f"records[{issue.record_index}].{issue.field_path}"
|
|
173
|
+
body.append(f" [{issue.kind}] {loc}: {issue.message}")
|
|
174
|
+
elif trace.preflight is not None:
|
|
175
|
+
body.append("preflight: [green]OK[/]")
|
|
176
|
+
if trace.decisions:
|
|
177
|
+
body.append("decisions:")
|
|
178
|
+
for d in trace.decisions:
|
|
179
|
+
extra = f" — {d.note}" if d.note else ""
|
|
180
|
+
body.append(
|
|
181
|
+
f" {d.field_path} [{d.source}] {d.raw_value!r} → {d.resolved_value!r}{extra}"
|
|
182
|
+
)
|
|
183
|
+
if trace.decisions_truncated:
|
|
184
|
+
body.append(f" … truncated at {DECISION_CAP} entries")
|
|
185
|
+
console.print(Panel("\n".join(body), title="nsc explain", border_style="cyan"))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"DECISION_CAP",
|
|
190
|
+
"ExplainTrace",
|
|
191
|
+
"FieldDecision",
|
|
192
|
+
"render_to_json",
|
|
193
|
+
"render_to_rich_stdout",
|
|
194
|
+
]
|