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.
Files changed (71) hide show
  1. netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
  2. netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
  3. netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
  4. netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
  5. netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. nsc/__init__.py +5 -0
  7. nsc/__main__.py +6 -0
  8. nsc/_version.py +3 -0
  9. nsc/aliases/__init__.py +24 -0
  10. nsc/aliases/resolver.py +112 -0
  11. nsc/auth/__init__.py +5 -0
  12. nsc/auth/verify.py +143 -0
  13. nsc/builder/__init__.py +5 -0
  14. nsc/builder/build.py +514 -0
  15. nsc/cache/__init__.py +5 -0
  16. nsc/cache/store.py +295 -0
  17. nsc/cli/__init__.py +1 -0
  18. nsc/cli/aliases_commands.py +264 -0
  19. nsc/cli/app.py +291 -0
  20. nsc/cli/cache_commands.py +159 -0
  21. nsc/cli/commands_dump.py +57 -0
  22. nsc/cli/config_commands.py +156 -0
  23. nsc/cli/globals.py +65 -0
  24. nsc/cli/handlers.py +660 -0
  25. nsc/cli/init_commands.py +95 -0
  26. nsc/cli/login_commands.py +265 -0
  27. nsc/cli/profiles_commands.py +256 -0
  28. nsc/cli/registration.py +465 -0
  29. nsc/cli/runtime.py +290 -0
  30. nsc/cli/skill_commands.py +186 -0
  31. nsc/cli/writes/__init__.py +10 -0
  32. nsc/cli/writes/apply.py +177 -0
  33. nsc/cli/writes/bulk.py +231 -0
  34. nsc/cli/writes/coercion.py +9 -0
  35. nsc/cli/writes/confirmation.py +96 -0
  36. nsc/cli/writes/input.py +358 -0
  37. nsc/cli/writes/preflight.py +182 -0
  38. nsc/config/__init__.py +23 -0
  39. nsc/config/loader.py +69 -0
  40. nsc/config/models.py +54 -0
  41. nsc/config/settings.py +36 -0
  42. nsc/config/writer.py +207 -0
  43. nsc/http/__init__.py +6 -0
  44. nsc/http/audit.py +183 -0
  45. nsc/http/client.py +365 -0
  46. nsc/http/errors.py +35 -0
  47. nsc/http/retry.py +90 -0
  48. nsc/model/__init__.py +23 -0
  49. nsc/model/command_model.py +125 -0
  50. nsc/output/__init__.py +1 -0
  51. nsc/output/csv_.py +34 -0
  52. nsc/output/errors.py +346 -0
  53. nsc/output/explain.py +194 -0
  54. nsc/output/flatten.py +25 -0
  55. nsc/output/headers.py +9 -0
  56. nsc/output/json_.py +21 -0
  57. nsc/output/jsonl.py +21 -0
  58. nsc/output/render.py +47 -0
  59. nsc/output/table.py +50 -0
  60. nsc/output/yaml_.py +28 -0
  61. nsc/schema/__init__.py +1 -0
  62. nsc/schema/hashing.py +24 -0
  63. nsc/schema/loader.py +66 -0
  64. nsc/schema/models.py +109 -0
  65. nsc/schema/source.py +120 -0
  66. nsc/schemas/__init__.py +1 -0
  67. nsc/schemas/bundled/__init__.py +1 -0
  68. nsc/schemas/bundled/manifest.yaml +5 -0
  69. nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
  70. nsc/skill/__init__.py +35 -0
  71. 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
+ ]