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
nsc/http/audit.py ADDED
@@ -0,0 +1,183 @@
1
+ """Audit log writers (Phase 3a).
2
+
3
+ Two files under `~/.nsc/logs/`:
4
+ - `last-request.json` — single-exchange ephemeral, overwritten on every call.
5
+ - `audit.jsonl` — append-only, write attempts only (POST/PATCH/PUT/DELETE).
6
+
7
+ Header redaction is centralized; body redaction is opt-in via `sensitive_paths`
8
+ threaded from the schema's `RequestBodyShape.sensitive_paths` field.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import json
15
+ import os
16
+ import sys
17
+ import tempfile
18
+ from collections.abc import Sequence
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+ from nsc.model.command_model import HttpMethod
25
+ from nsc.output.headers import SENSITIVE_HEADERS
26
+
27
+ DEFAULT_BODY_CAP_BYTES = 256 * 1024
28
+ DEFAULT_ROTATE_BYTES = 10 * 1024 * 1024
29
+ SCHEMA_VERSION = 1
30
+
31
+
32
+ class _Frozen(BaseModel):
33
+ model_config = ConfigDict(frozen=True, extra="forbid")
34
+
35
+
36
+ class AuditEntry(_Frozen):
37
+ timestamp: str
38
+ operation_id: str | None = None
39
+ method: HttpMethod
40
+ url: str
41
+ request_headers: dict[str, str] = Field(default_factory=dict)
42
+ request_query: dict[str, Any] = Field(default_factory=dict)
43
+ request_body: Any = None
44
+ sensitive_paths: tuple[str, ...] = ()
45
+ response_status_code: int | None = None
46
+ response_headers: dict[str, str] = Field(default_factory=dict)
47
+ response_body: Any = None
48
+ duration_ms: int | None = None
49
+ attempt_n: int = 1
50
+ final_attempt: bool = True
51
+ error_kind: str | None = None
52
+ dry_run: bool = False
53
+ preflight_blocked: bool = False
54
+ record_indices: list[int] = Field(default_factory=list)
55
+ applied: bool = False
56
+ explain: bool = False
57
+
58
+
59
+ def redact_headers(headers: dict[str, str]) -> dict[str, str]:
60
+ return {k: ("<redacted>" if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items()}
61
+
62
+
63
+ def redact_body(body: Any, sensitive_paths: Sequence[str]) -> Any:
64
+ """Return a deep-copy of `body` with all `sensitive_paths` rewritten to `"<redacted>"`.
65
+
66
+ Path semantics:
67
+ - Each path is a dotted string (e.g., "auth.password"). Empty paths are ignored.
68
+ - The walker descends into dicts by key.
69
+ - When it encounters a list, it applies the remainder of the path to every element.
70
+ - A missing key on the walk is a no-op (the schema may declare optional fields).
71
+ - A non-mapping/non-list intermediate aborts that path safely.
72
+ """
73
+ if body is None or not sensitive_paths:
74
+ return body
75
+ out = copy.deepcopy(body)
76
+ for path in sensitive_paths:
77
+ if not path:
78
+ continue
79
+ _apply_redaction(out, path.split("."))
80
+ return out
81
+
82
+
83
+ def _apply_redaction(node: Any, parts: list[str]) -> None:
84
+ if not parts:
85
+ return
86
+ head, *tail = parts
87
+ if isinstance(node, list):
88
+ for item in node:
89
+ _apply_redaction(item, parts)
90
+ return
91
+ if not isinstance(node, dict):
92
+ return
93
+ if head not in node:
94
+ return
95
+ if not tail:
96
+ node[head] = "<redacted>"
97
+ return
98
+ _apply_redaction(node[head], tail)
99
+
100
+
101
+ def truncate_body(body: Any, *, cap_bytes: int = DEFAULT_BODY_CAP_BYTES) -> tuple[Any, bool]:
102
+ """Return (possibly-truncated body, was_truncated)."""
103
+ if body is None:
104
+ return None, False
105
+ serialized = json.dumps(body, default=str).encode("utf-8")
106
+ if len(serialized) <= cap_bytes:
107
+ return body, False
108
+ return {"_truncated": True, "_size_bytes": len(serialized)}, True
109
+
110
+
111
+ def _to_dict(entry: AuditEntry) -> dict[str, Any]:
112
+ """Serialize to the wire shape documented in spec §4.3."""
113
+ req_body, req_trunc = truncate_body(redact_body(entry.request_body, entry.sensitive_paths))
114
+ resp_body, resp_trunc = truncate_body(entry.response_body)
115
+ return {
116
+ "schema_version": SCHEMA_VERSION,
117
+ "timestamp": entry.timestamp,
118
+ "operation_id": entry.operation_id,
119
+ "method": entry.method.value,
120
+ "url": entry.url,
121
+ "request": {
122
+ "headers": redact_headers(entry.request_headers),
123
+ "query": entry.request_query,
124
+ "body": req_body,
125
+ "body_truncated": req_trunc,
126
+ },
127
+ "response": (
128
+ None
129
+ if entry.response_status_code is None
130
+ else {
131
+ "status_code": entry.response_status_code,
132
+ "headers": dict(entry.response_headers),
133
+ "body": resp_body,
134
+ "body_truncated": resp_trunc,
135
+ }
136
+ ),
137
+ "duration_ms": entry.duration_ms,
138
+ "attempt_n": entry.attempt_n,
139
+ "final_attempt": entry.final_attempt,
140
+ "error_kind": entry.error_kind,
141
+ "dry_run": entry.dry_run,
142
+ "preflight_blocked": entry.preflight_blocked,
143
+ "record_indices": entry.record_indices,
144
+ "applied": entry.applied,
145
+ "explain": entry.explain,
146
+ }
147
+
148
+
149
+ def _warn(msg: str) -> None:
150
+ print(f"warning: {msg}", file=sys.stderr)
151
+
152
+
153
+ def write_last_request(entry: AuditEntry, *, path: Path) -> None:
154
+ """Atomically overwrite `path` with the entry. Failures emit a stderr warning."""
155
+ try:
156
+ path.parent.mkdir(parents=True, exist_ok=True)
157
+ with tempfile.NamedTemporaryFile(
158
+ "w", encoding="utf-8", dir=path.parent, delete=False
159
+ ) as tmp:
160
+ json.dump(_to_dict(entry), tmp)
161
+ tmp_path = Path(tmp.name)
162
+ os.replace(tmp_path, path)
163
+ except OSError as exc:
164
+ _warn(f"could not write audit log {path}: {exc}")
165
+
166
+
167
+ def append_audit_jsonl(
168
+ entry: AuditEntry,
169
+ *,
170
+ path: Path,
171
+ rotate_bytes: int = DEFAULT_ROTATE_BYTES,
172
+ ) -> None:
173
+ """Append the entry as a single line. Rotate to `path.1` when over the threshold."""
174
+ try:
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ if path.exists() and path.stat().st_size > rotate_bytes:
177
+ rolled = path.with_suffix(path.suffix + ".1")
178
+ os.replace(path, rolled)
179
+ with path.open("a", encoding="utf-8") as fh:
180
+ fh.write(json.dumps(_to_dict(entry)))
181
+ fh.write("\n")
182
+ except OSError as exc:
183
+ _warn(f"could not append audit log {path}: {exc}")
nsc/http/client.py ADDED
@@ -0,0 +1,365 @@
1
+ """Sync httpx-based NetBox client (Phase 3a: reads + retry + audit)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from collections.abc import Iterator
8
+ from datetime import UTC, datetime
9
+ from types import TracebackType
10
+ from typing import Any, Protocol, cast
11
+ from urllib.parse import parse_qs, urlsplit
12
+
13
+ import httpx
14
+
15
+ from nsc.config.settings import default_paths
16
+ from nsc.http.audit import (
17
+ AuditEntry,
18
+ append_audit_jsonl,
19
+ write_last_request,
20
+ )
21
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
22
+ from nsc.http.retry import (
23
+ ErrorClass,
24
+ backoff_delay,
25
+ classify_error,
26
+ policy_for_method,
27
+ should_retry,
28
+ )
29
+ from nsc.model.command_model import HttpMethod
30
+
31
+ _BODY_SNIPPET_BYTES = 2048
32
+ _HTTP_5XX_MIN = 500
33
+ _HTTP_4XX_MIN = 400
34
+
35
+
36
+ class _ProfileLike(Protocol):
37
+ url: Any
38
+ token: str | None
39
+ verify_ssl: bool
40
+ timeout: float
41
+
42
+
43
+ class NetBoxClient:
44
+ def __init__(self, profile: _ProfileLike, *, debug: bool = False) -> None:
45
+ if profile.token is None:
46
+ raise ValueError("NetBoxClient requires a non-None token on the profile")
47
+ self._url = str(profile.url).rstrip("/")
48
+ self._debug = debug
49
+ self._client = httpx.Client(
50
+ base_url=self._url,
51
+ headers={
52
+ "Authorization": f"Token {profile.token}",
53
+ "Accept": "application/json",
54
+ },
55
+ verify=profile.verify_ssl,
56
+ timeout=profile.timeout,
57
+ event_hooks=self._event_hooks() if debug else {},
58
+ )
59
+
60
+ def __enter__(self) -> NetBoxClient:
61
+ return self
62
+
63
+ def __exit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc: BaseException | None,
67
+ tb: TracebackType | None,
68
+ ) -> None:
69
+ self.close()
70
+
71
+ def close(self) -> None:
72
+ self._client.close()
73
+
74
+ def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
75
+ response = self._send_with_retry(HttpMethod.GET, path, params=params)
76
+ return _parse_json(response)
77
+
78
+ def post(
79
+ self,
80
+ path: str,
81
+ *,
82
+ json: Any | None = None,
83
+ operation_id: str | None = None,
84
+ record_indices: list[int] | None = None,
85
+ sensitive_paths: tuple[str, ...] = (),
86
+ ) -> dict[str, Any]:
87
+ response = self._send_with_retry(
88
+ HttpMethod.POST,
89
+ path,
90
+ json_body=json,
91
+ operation_id=operation_id,
92
+ record_indices=record_indices,
93
+ sensitive_paths=sensitive_paths,
94
+ )
95
+ return _parse_json(response)
96
+
97
+ def patch(
98
+ self,
99
+ path: str,
100
+ *,
101
+ json: Any | None = None,
102
+ operation_id: str | None = None,
103
+ record_indices: list[int] | None = None,
104
+ sensitive_paths: tuple[str, ...] = (),
105
+ ) -> dict[str, Any]:
106
+ response = self._send_with_retry(
107
+ HttpMethod.PATCH,
108
+ path,
109
+ json_body=json,
110
+ operation_id=operation_id,
111
+ record_indices=record_indices,
112
+ sensitive_paths=sensitive_paths,
113
+ )
114
+ return _parse_json(response)
115
+
116
+ def put(
117
+ self,
118
+ path: str,
119
+ *,
120
+ json: Any | None = None,
121
+ operation_id: str | None = None,
122
+ record_indices: list[int] | None = None,
123
+ sensitive_paths: tuple[str, ...] = (),
124
+ ) -> dict[str, Any]:
125
+ response = self._send_with_retry(
126
+ HttpMethod.PUT,
127
+ path,
128
+ json_body=json,
129
+ operation_id=operation_id,
130
+ record_indices=record_indices,
131
+ sensitive_paths=sensitive_paths,
132
+ )
133
+ return _parse_json(response)
134
+
135
+ def delete(
136
+ self,
137
+ path: str,
138
+ *,
139
+ operation_id: str | None = None,
140
+ record_indices: list[int] | None = None,
141
+ sensitive_paths: tuple[str, ...] = (),
142
+ ) -> dict[str, Any]:
143
+ response = self._send_with_retry(
144
+ HttpMethod.DELETE,
145
+ path,
146
+ operation_id=operation_id,
147
+ record_indices=record_indices,
148
+ sensitive_paths=sensitive_paths,
149
+ )
150
+ return _parse_json(response)
151
+
152
+ def paginate(
153
+ self,
154
+ path: str,
155
+ params: dict[str, Any] | None = None,
156
+ *,
157
+ limit: int | None = None,
158
+ ) -> Iterator[dict[str, Any]]:
159
+ url: str | None = path
160
+ first = True
161
+ emitted = 0
162
+ while url is not None:
163
+ if first:
164
+ req_path, req_params = url, params
165
+ else:
166
+ # next URLs are absolute; split them so respx (and httpx) can
167
+ # match on path + params independently rather than a raw string.
168
+ parsed = urlsplit(url)
169
+ req_path = parsed.path
170
+ qs = parse_qs(parsed.query, keep_blank_values=True)
171
+ req_params = {k: v[0] for k, v in qs.items()} if qs else None
172
+ response = self._send_with_retry(HttpMethod.GET, req_path, params=req_params)
173
+ payload = _parse_json(response)
174
+ for record in payload.get("results", []):
175
+ yield record
176
+ emitted += 1
177
+ if limit is not None and emitted >= limit:
178
+ return
179
+ url = payload.get("next")
180
+ first = False
181
+
182
+ def _send_with_retry(
183
+ self,
184
+ method: HttpMethod,
185
+ path: str,
186
+ *,
187
+ params: dict[str, Any] | None = None,
188
+ json_body: Any | None = None,
189
+ operation_id: str | None = None,
190
+ record_indices: list[int] | None = None,
191
+ sensitive_paths: tuple[str, ...] = (),
192
+ ) -> httpx.Response:
193
+ policy = policy_for_method(method)
194
+ attempt = 0
195
+ is_write = method not in {HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS}
196
+ indices: list[int] = list(record_indices) if record_indices is not None else []
197
+ while True:
198
+ attempt += 1
199
+ started = time.monotonic()
200
+ try:
201
+ response = self._client.request(method.value, path, params=params, json=json_body)
202
+ except httpx.RequestError as exc:
203
+ error_class: ErrorClass | None = classify_error(exc)
204
+ duration_ms = int((time.monotonic() - started) * 1000)
205
+ retry = should_retry(
206
+ policy, attempt=attempt, status_code=None, error_class=error_class
207
+ )
208
+ self._record_attempt(
209
+ method=method,
210
+ path=path,
211
+ params=params,
212
+ request_body=json_body,
213
+ response=None,
214
+ duration_ms=duration_ms,
215
+ attempt=attempt,
216
+ final=not retry,
217
+ operation_id=operation_id,
218
+ is_write=is_write,
219
+ record_indices=indices,
220
+ sensitive_paths=sensitive_paths,
221
+ )
222
+ if not retry:
223
+ raise NetBoxClientError(url=self._absolute(path, params), cause=exc) from exc
224
+ time.sleep(backoff_delay(policy, attempt=attempt))
225
+ continue
226
+ duration_ms = int((time.monotonic() - started) * 1000)
227
+ retry = should_retry(
228
+ policy,
229
+ attempt=attempt,
230
+ status_code=response.status_code,
231
+ error_class=None,
232
+ )
233
+ self._record_attempt(
234
+ method=method,
235
+ path=path,
236
+ params=params,
237
+ request_body=json_body,
238
+ response=response,
239
+ duration_ms=duration_ms,
240
+ attempt=attempt,
241
+ final=not retry,
242
+ operation_id=operation_id,
243
+ is_write=is_write,
244
+ record_indices=indices,
245
+ sensitive_paths=sensitive_paths,
246
+ )
247
+ if not retry:
248
+ if response.is_success:
249
+ return response
250
+ self._raise_for_status(response)
251
+ time.sleep(backoff_delay(policy, attempt=attempt))
252
+
253
+ def _absolute(self, path: str, params: dict[str, Any] | None) -> str:
254
+ request = self._client.build_request("GET", path, params=params)
255
+ return str(request.url)
256
+
257
+ def _raise_for_status(self, response: httpx.Response) -> None:
258
+ if response.is_success:
259
+ return
260
+ try:
261
+ body = response.text[:_BODY_SNIPPET_BYTES]
262
+ except Exception: # pragma: no cover
263
+ body = ""
264
+ raise NetBoxAPIError(
265
+ status_code=response.status_code,
266
+ url=str(response.request.url),
267
+ body_snippet=body,
268
+ headers=dict(response.headers),
269
+ )
270
+
271
+ def _record_attempt(
272
+ self,
273
+ *,
274
+ method: HttpMethod,
275
+ path: str,
276
+ params: dict[str, Any] | None,
277
+ request_body: Any | None,
278
+ response: httpx.Response | None,
279
+ duration_ms: int,
280
+ attempt: int,
281
+ final: bool,
282
+ operation_id: str | None,
283
+ is_write: bool,
284
+ record_indices: list[int],
285
+ sensitive_paths: tuple[str, ...] = (),
286
+ ) -> None:
287
+ url = self._absolute(path, params)
288
+ # httpx lowercases all header names; title-case them so the audit log
289
+ # uses the conventional HTTP capitalisation (e.g. "Authorization").
290
+ request_headers = {k.title(): v for k, v in self._client.headers.items()}
291
+ if response is not None:
292
+ status_code: int = response.status_code
293
+ response_headers = dict(response.headers)
294
+ try:
295
+ response_body: Any = response.json() if response.content else None
296
+ except ValueError:
297
+ response_body = response.text[:_BODY_SNIPPET_BYTES]
298
+ if status_code >= _HTTP_5XX_MIN:
299
+ error_kind: str | None = "5xx"
300
+ elif status_code >= _HTTP_4XX_MIN:
301
+ error_kind = "4xx"
302
+ else:
303
+ error_kind = None
304
+ audit_status: int | None = status_code
305
+ else:
306
+ audit_status = None
307
+ response_headers = {}
308
+ response_body = None
309
+ error_kind = "transport"
310
+
311
+ entry = AuditEntry(
312
+ timestamp=_now_iso(),
313
+ operation_id=operation_id,
314
+ method=method,
315
+ url=url,
316
+ request_headers=request_headers,
317
+ request_query=dict(params or {}),
318
+ request_body=request_body,
319
+ sensitive_paths=sensitive_paths,
320
+ response_status_code=audit_status,
321
+ response_headers=response_headers,
322
+ response_body=response_body,
323
+ duration_ms=duration_ms,
324
+ attempt_n=attempt,
325
+ final_attempt=final,
326
+ error_kind=error_kind,
327
+ dry_run=False,
328
+ preflight_blocked=False,
329
+ record_indices=list(record_indices),
330
+ applied=is_write,
331
+ explain=False,
332
+ )
333
+ log_dir = default_paths().logs_dir
334
+ write_last_request(entry, path=log_dir / "last-request.json")
335
+ if is_write or self._debug:
336
+ append_audit_jsonl(entry, path=log_dir / "audit.jsonl")
337
+
338
+ def _event_hooks(self) -> dict[str, list[Any]]:
339
+ # Phase 2 stderr streaming — keep alongside Phase 3a audit.jsonl writes.
340
+ def on_request(request: httpx.Request) -> None:
341
+ print(f">>> {request.method} {request.url}", file=sys.stderr)
342
+ for k, v in request.headers.items():
343
+ masked = "<redacted>" if k.lower() == "authorization" else v
344
+ print(f">>> {k}: {masked}", file=sys.stderr)
345
+
346
+ def on_response(response: httpx.Response) -> None:
347
+ response.read()
348
+ print(f"<<< {response.status_code} {response.reason_phrase}", file=sys.stderr)
349
+ for k, v in response.headers.items():
350
+ print(f"<<< {k}: {v}", file=sys.stderr)
351
+ body = response.text[:_BODY_SNIPPET_BYTES]
352
+ if body:
353
+ print(f"<<< {body}", file=sys.stderr)
354
+
355
+ return {"request": [on_request], "response": [on_response]}
356
+
357
+
358
+ def _now_iso() -> str:
359
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
360
+
361
+
362
+ def _parse_json(response: httpx.Response) -> dict[str, Any]:
363
+ if not response.content:
364
+ return {}
365
+ return cast(dict[str, Any], response.json())
nsc/http/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """HTTP error types raised by NetBoxClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NetBoxAPIError(Exception):
7
+ """A non-2xx response from NetBox."""
8
+
9
+ def __init__(
10
+ self,
11
+ status_code: int,
12
+ url: str,
13
+ body_snippet: str,
14
+ headers: dict[str, str],
15
+ ) -> None:
16
+ super().__init__(f"NetBox API {status_code} on {url}")
17
+ self.status_code = status_code
18
+ self.url = url
19
+ self.body_snippet = body_snippet
20
+ self.headers = headers
21
+
22
+ def render_for_cli(self) -> str:
23
+ return f"NetBox API {self.status_code} on {self.url}: {self.body_snippet}"
24
+
25
+
26
+ class NetBoxClientError(Exception):
27
+ """A transport-level failure reaching NetBox (DNS, TCP, TLS, timeout)."""
28
+
29
+ def __init__(self, url: str, cause: BaseException) -> None:
30
+ super().__init__(f"NetBox transport error on {url}: {cause}")
31
+ self.url = url
32
+ self.cause = cause
33
+
34
+ def render_for_cli(self) -> str:
35
+ return f"NetBox transport error on {self.url}: {self.cause}"
nsc/http/retry.py ADDED
@@ -0,0 +1,90 @@
1
+ """Per-method retry policy + error classification (Phase 3a).
2
+
3
+ Pure functions only. The retry *loop* lives in `NetBoxClient`; this module
4
+ decides whether to keep going.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ from enum import Enum
11
+
12
+ import httpx
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from nsc.model.command_model import HttpMethod
16
+
17
+
18
+ class _Frozen(BaseModel):
19
+ model_config = ConfigDict(frozen=True, extra="forbid")
20
+
21
+
22
+ class RetryPolicy(_Frozen):
23
+ max_attempts: int = 3
24
+ base_delay: float = 0.5
25
+ jitter: float = 0.25
26
+ retry_on_5xx: bool
27
+ retry_on_connect: bool
28
+
29
+
30
+ class ErrorClass(Enum):
31
+ """Classification of a transport-layer exception."""
32
+
33
+ CONNECT = "connect" # provably no-op (request never sent)
34
+ READ_TIMEOUT = "read_timeout" # bytes sent, server may have processed
35
+ TRANSPORT_AMBIGUOUS = "transport_ambig" # could be either; treat as may-have-sent
36
+
37
+
38
+ _READ_METHODS = {HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS}
39
+
40
+ _5XX_MIN = 500
41
+ _5XX_MAX = 600
42
+
43
+
44
+ def policy_for_method(method: HttpMethod) -> RetryPolicy:
45
+ if method in _READ_METHODS:
46
+ return RetryPolicy(retry_on_5xx=True, retry_on_connect=True)
47
+ return RetryPolicy(retry_on_5xx=False, retry_on_connect=True)
48
+
49
+
50
+ def classify_error(exc: BaseException) -> ErrorClass:
51
+ """Map an httpx transport exception to a retry-relevant class."""
52
+ if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
53
+ return ErrorClass.CONNECT
54
+ if isinstance(exc, httpx.ReadTimeout):
55
+ return ErrorClass.READ_TIMEOUT
56
+ return ErrorClass.TRANSPORT_AMBIGUOUS
57
+
58
+
59
+ def should_retry(
60
+ policy: RetryPolicy,
61
+ *,
62
+ attempt: int,
63
+ status_code: int | None,
64
+ error_class: ErrorClass | None,
65
+ ) -> bool:
66
+ """Should we attempt request `attempt + 1`?
67
+
68
+ Args:
69
+ attempt: The 1-indexed attempt that just finished.
70
+ status_code: HTTP status from the response, or None if no response.
71
+ error_class: The transport classification, or None if a response arrived.
72
+ """
73
+ if attempt >= policy.max_attempts:
74
+ return False
75
+ if error_class is ErrorClass.CONNECT:
76
+ return policy.retry_on_connect
77
+ if error_class in (ErrorClass.READ_TIMEOUT, ErrorClass.TRANSPORT_AMBIGUOUS):
78
+ return False # never retry possibly-already-sent writes; reads don't either (safety)
79
+ if status_code is not None and _5XX_MIN <= status_code < _5XX_MAX:
80
+ return policy.retry_on_5xx
81
+ return False
82
+
83
+
84
+ def backoff_delay(policy: RetryPolicy, *, attempt: int) -> float:
85
+ """Exponential backoff with ± jitter%, in seconds."""
86
+ base: float = policy.base_delay * (2 ** (attempt - 1))
87
+ if policy.jitter == 0.0:
88
+ return base
89
+ span: float = base * policy.jitter
90
+ return float(base + random.uniform(-span, span))
nsc/model/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Framework-free command-model: the normalized command tree."""
2
+
3
+ from nsc.model.command_model import (
4
+ CommandModel,
5
+ HttpMethod,
6
+ Operation,
7
+ Parameter,
8
+ ParameterLocation,
9
+ PrimitiveType,
10
+ Resource,
11
+ Tag,
12
+ )
13
+
14
+ __all__ = [
15
+ "CommandModel",
16
+ "HttpMethod",
17
+ "Operation",
18
+ "Parameter",
19
+ "ParameterLocation",
20
+ "PrimitiveType",
21
+ "Resource",
22
+ "Tag",
23
+ ]