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/auth/verify.py ADDED
@@ -0,0 +1,143 @@
1
+ """Pre-flight verification of a profile's URL+token.
2
+
3
+ `verify(profile)` issues two probes against the candidate NetBox:
4
+
5
+ * `GET /api/status/` — confirms the URL is a NetBox and reports the version.
6
+ * `GET /api/users/tokens/?limit=1` — confirms the token is accepted (the
7
+ endpoint requires authentication) and the response carries the calling
8
+ user's identity in `results[0].user.username`. NetBox does not expose a
9
+ top-level "current user" endpoint, so the token list is the closest signal
10
+ to "authenticated as <user>".
11
+
12
+ Both must succeed. Either failure raises `VerifyError`; the caller maps the
13
+ exception to an `auth_error` envelope (`ErrorType.AUTH`, exit 8). Login is not
14
+ audited — `verify` deliberately bypasses `NetBoxClient` to keep the pre-flight
15
+ out of `audit.jsonl`. There is no retry loop: a single failed probe fails fast.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import httpx
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from nsc.config.models import Profile
24
+
25
+ _DEFAULT_TIMEOUT = 10.0
26
+
27
+
28
+ class VerifyResult(BaseModel):
29
+ """The success-shape of a pre-flight verification."""
30
+
31
+ model_config = ConfigDict(frozen=True, extra="forbid")
32
+ username: str
33
+ netbox_version: str
34
+
35
+
36
+ class VerifyError(Exception):
37
+ """Pre-flight verification failed.
38
+
39
+ `status_code` is the HTTP status of the failing probe, or `None` when the
40
+ failure was a transport error (connection refused, TLS, DNS, etc.).
41
+ `user_check_status` is set to the same status as `status_code` when the
42
+ auth probe (`/api/users/tokens/`) was the one that failed (i.e. `/api/status/`
43
+ returned 2xx but the token was rejected). This distinguishes
44
+ "wrong URL / NetBox down" from "URL fine, token rejected".
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ message: str,
50
+ *,
51
+ status_code: int | None = None,
52
+ user_check_status: int | None = None,
53
+ ) -> None:
54
+ super().__init__(message)
55
+ self.message = message
56
+ self.status_code = status_code
57
+ self.user_check_status = user_check_status
58
+
59
+ def __str__(self) -> str:
60
+ return self.message
61
+
62
+
63
+ def verify(profile: Profile, *, timeout: float = _DEFAULT_TIMEOUT) -> VerifyResult:
64
+ """Confirm `profile`'s URL+token reach an authenticated NetBox.
65
+
66
+ Raises `VerifyError` on any failure. Returns a `VerifyResult` on success.
67
+ """
68
+ if not profile.token:
69
+ raise VerifyError(message="profile has no token; cannot verify")
70
+ base = str(profile.url).rstrip("/")
71
+ headers = {
72
+ "Authorization": f"Token {profile.token}",
73
+ "Accept": "application/json",
74
+ }
75
+ with httpx.Client(
76
+ base_url=base,
77
+ headers=headers,
78
+ verify=profile.verify_ssl,
79
+ timeout=timeout,
80
+ ) as client:
81
+ version = _probe_status(client)
82
+ username = _probe_users_me(client)
83
+ return VerifyResult(username=username, netbox_version=version)
84
+
85
+
86
+ def _probe_status(client: httpx.Client) -> str:
87
+ try:
88
+ response = client.get("/api/status/")
89
+ except (httpx.RequestError, OSError) as exc:
90
+ raise VerifyError(message=f"could not reach NetBox: {exc}") from exc
91
+ if not response.is_success:
92
+ raise VerifyError(
93
+ message=f"NetBox /api/status/ returned {response.status_code}",
94
+ status_code=response.status_code,
95
+ )
96
+ body = _safe_json(response)
97
+ version = body.get("netbox-version") if isinstance(body, dict) else None
98
+ return str(version) if version else "unknown"
99
+
100
+
101
+ def _probe_users_me(client: httpx.Client) -> str:
102
+ """Verify the token via `GET /api/users/tokens/?limit=1`.
103
+
104
+ NetBox does not expose a top-level "current user" endpoint (`/api/users/me/`
105
+ and `/api/users/users/me/` both return 404 on 4.5+). The token-list endpoint
106
+ is authenticated and its response carries a nested `user.username` for each
107
+ token, which is the closest signal to "authenticated as <user>". A non-admin
108
+ user only sees their own tokens, so `results[0].user.username` is the
109
+ calling user's identity in the common case. If the user has no visible
110
+ tokens (an unusual admin state), we surface "(unknown)" rather than failing.
111
+ """
112
+ try:
113
+ response = client.get("/api/users/tokens/", params={"limit": 1})
114
+ except (httpx.RequestError, OSError) as exc:
115
+ raise VerifyError(message=f"token probe failed: {exc}") from exc
116
+ if not response.is_success:
117
+ raise VerifyError(
118
+ message=(
119
+ f"NetBox accepted the URL but rejected the token "
120
+ f"(/api/users/tokens/ returned {response.status_code})"
121
+ ),
122
+ status_code=response.status_code,
123
+ user_check_status=response.status_code,
124
+ )
125
+ body = _safe_json(response)
126
+ if not isinstance(body, dict):
127
+ return "(unknown)"
128
+ results = body.get("results") or []
129
+ if not results:
130
+ return "(unknown)"
131
+ user = results[0].get("user") if isinstance(results[0], dict) else None
132
+ if isinstance(user, dict):
133
+ username = user.get("username")
134
+ if username:
135
+ return str(username)
136
+ return "(unknown)"
137
+
138
+
139
+ def _safe_json(response: httpx.Response) -> object:
140
+ try:
141
+ return response.json()
142
+ except ValueError:
143
+ return None
@@ -0,0 +1,5 @@
1
+ """Convert a parsed OpenAPI document into a CommandModel."""
2
+
3
+ from nsc.builder.build import build_command_model
4
+
5
+ __all__ = ["build_command_model"]
nsc/builder/build.py ADDED
@@ -0,0 +1,514 @@
1
+ """Schema → CommandModel.
2
+
3
+ The algorithm is deterministic and depends solely on the OpenAPI document.
4
+ No NetBox-specific knowledge is hard-coded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Literal
11
+
12
+ from nsc.model.command_model import (
13
+ CommandModel,
14
+ FieldShape,
15
+ HttpMethod,
16
+ Operation,
17
+ Parameter,
18
+ ParameterLocation,
19
+ PrimitiveType,
20
+ RequestBodyShape,
21
+ Resource,
22
+ Tag,
23
+ )
24
+ from nsc.schema.loader import LoadedSchema
25
+ from nsc.schema.models import OpenAPIDocument, PathItem, SchemaObject
26
+ from nsc.schema.models import Operation as SchemaOperation
27
+ from nsc.schema.models import Parameter as SchemaParameter
28
+
29
+ _PARAM_SEGMENT = re.compile(r"^\{[^}]+\}$")
30
+ _CANONICAL_SENSITIVE_NAMES: frozenset[str] = frozenset(
31
+ {
32
+ "password",
33
+ "secret",
34
+ "token",
35
+ "api_key",
36
+ "apikey",
37
+ "private_key",
38
+ "passphrase",
39
+ "client_secret",
40
+ }
41
+ )
42
+ _HTTP_METHODS: tuple[tuple[str, HttpMethod], ...] = (
43
+ ("get", HttpMethod.GET),
44
+ ("post", HttpMethod.POST),
45
+ ("patch", HttpMethod.PATCH),
46
+ ("put", HttpMethod.PUT),
47
+ ("delete", HttpMethod.DELETE),
48
+ ("options", HttpMethod.OPTIONS),
49
+ ("head", HttpMethod.HEAD),
50
+ )
51
+ # api / <tag> / <resource> = at least 3 non-empty path parts
52
+ _MIN_PATH_PARTS = 3
53
+
54
+
55
+ def build_command_model(loaded: LoadedSchema) -> CommandModel:
56
+ doc = loaded.document
57
+ tags: dict[str, _MutableTag] = {}
58
+
59
+ for path, item in doc.paths.items():
60
+ for attr_name, http_method in _HTTP_METHODS:
61
+ schema_op: SchemaOperation | None = getattr(item, attr_name)
62
+ if schema_op is None:
63
+ continue
64
+ _assimilate(tags, path, http_method, schema_op, item, doc)
65
+
66
+ final_tags = {
67
+ name: Tag(
68
+ name=name,
69
+ description=mt.description,
70
+ resources={rname: _finalize_resource(rname, mr) for rname, mr in mt.resources.items()},
71
+ )
72
+ for name, mt in sorted(tags.items())
73
+ }
74
+
75
+ return CommandModel(
76
+ info_title=doc.info.title,
77
+ info_version=doc.info.version,
78
+ schema_hash=loaded.hash,
79
+ tags=final_tags,
80
+ )
81
+
82
+
83
+ # --- internals -------------------------------------------------------------
84
+
85
+
86
+ class _MutableResource:
87
+ def __init__(self, name: str) -> None:
88
+ self.name = name
89
+ self.list_op: Operation | None = None
90
+ self.get_op: Operation | None = None
91
+ self.create_op: Operation | None = None
92
+ self.update_op: Operation | None = None
93
+ self.replace_op: Operation | None = None
94
+ self.delete_op: Operation | None = None
95
+ self.custom_actions: list[Operation] = []
96
+
97
+
98
+ class _MutableTag:
99
+ def __init__(self, name: str, description: str | None) -> None:
100
+ self.name = name
101
+ self.description = description
102
+ self.resources: dict[str, _MutableResource] = {}
103
+
104
+
105
+ def _assimilate(
106
+ tags: dict[str, _MutableTag],
107
+ path: str,
108
+ http_method: HttpMethod,
109
+ schema_op: SchemaOperation,
110
+ item: PathItem,
111
+ doc: OpenAPIDocument,
112
+ ) -> None:
113
+ tag_name = schema_op.tags[0] if schema_op.tags else None
114
+ if tag_name is None:
115
+ return # untagged operations are skipped intentionally
116
+
117
+ resource_name, is_collection_path = _resource_from_path(path, tag_name)
118
+ if resource_name is None:
119
+ return # paths that don't match the /api/<tag>/<resource>/... shape
120
+
121
+ tag = tags.get(tag_name)
122
+ if tag is None:
123
+ tag = _MutableTag(tag_name, _tag_description(tag_name, doc))
124
+ tags[tag_name] = tag
125
+
126
+ resource = tag.resources.get(resource_name)
127
+ if resource is None:
128
+ resource = _MutableResource(resource_name)
129
+ tag.resources[resource_name] = resource
130
+
131
+ op = _to_model_operation(path, http_method, schema_op, item, doc)
132
+ classification = _classify(http_method, path, schema_op, resource_name)
133
+ _attach(resource, classification, op, is_collection_path)
134
+
135
+
136
+ def _resource_from_path(path: str, tag: str) -> tuple[str | None, bool]:
137
+ """Return (resource_name, is_collection_path).
138
+
139
+ A path is a collection path if it has no further parameter segments after
140
+ the resource name. `is_collection_path=True` for `/api/dcim/devices/`
141
+ and `False` for `/api/dcim/devices/{id}/`.
142
+ """
143
+ parts = [p for p in path.split("/") if p]
144
+ if not parts or parts[0] != "api":
145
+ return None, False
146
+ # Top-level resources (e.g., /api/search/, /api/status/) — single segment after /api/.
147
+ # The segment itself is the resource name and is always a collection path.
148
+ if len(parts) == 2: # noqa: PLR2004
149
+ return parts[1], True
150
+ # Expect: api / <tag-or-tag-with-dashes> / <resource> / [...]
151
+ if len(parts) < _MIN_PATH_PARTS:
152
+ return None, False
153
+ # Some plugin endpoints have nested tags (e.g., ['plugins', '<plugin>']).
154
+ # The tag string from the spec is authoritative; just find <resource>.
155
+ # Strategy: skip leading non-parameter segments until we've consumed the
156
+ # tag's words and reached a resource segment.
157
+ # Easier heuristic that works for NetBox: the resource is the first
158
+ # segment after the segment that begins the tag's path-form.
159
+ tag_form = tag.replace(" ", "-").lower()
160
+ try:
161
+ anchor = parts.index(tag_form)
162
+ except ValueError:
163
+ # Fall back: assume parts[1] is the tag, parts[2] is the resource.
164
+ anchor = 1
165
+ if anchor + 1 >= len(parts):
166
+ return None, False
167
+ resource = parts[anchor + 1]
168
+ if _PARAM_SEGMENT.match(resource):
169
+ return None, False
170
+ remainder = parts[anchor + 2 :]
171
+ is_collection = all(not _PARAM_SEGMENT.match(p) for p in remainder) and not remainder
172
+ # If the only remaining segment is `{id}` it's the per-item path:
173
+ if len(remainder) == 1 and _PARAM_SEGMENT.match(remainder[0]):
174
+ is_collection = False
175
+ return resource, is_collection
176
+
177
+
178
+ _CRUD_MAP: dict[tuple[HttpMethod, bool], str] = {
179
+ (HttpMethod.GET, False): "list",
180
+ (HttpMethod.GET, True): "get",
181
+ (HttpMethod.POST, False): "create",
182
+ (HttpMethod.PATCH, True): "update",
183
+ (HttpMethod.PUT, True): "replace",
184
+ (HttpMethod.DELETE, True): "delete",
185
+ }
186
+
187
+
188
+ def _classify(
189
+ method: HttpMethod,
190
+ path: str,
191
+ schema_op: SchemaOperation,
192
+ resource_name: str,
193
+ ) -> str:
194
+ has_id = "{id}" in path
195
+ extra_path_segments = path.rstrip("/").split("/")[-1]
196
+ # Path is a custom action endpoint if `{id}` appears AND the last segment
197
+ # is a literal name (not itself a path parameter like `{id}`).
198
+ is_action_endpoint = has_id and not _PARAM_SEGMENT.match(extra_path_segments)
199
+
200
+ if is_action_endpoint:
201
+ return "custom"
202
+
203
+ return _CRUD_MAP.get((method, has_id), "custom")
204
+
205
+
206
+ def _attach(
207
+ resource: _MutableResource,
208
+ classification: str,
209
+ op: Operation,
210
+ is_collection_path: bool, # kept for future heuristics
211
+ ) -> None:
212
+ match classification:
213
+ case "list":
214
+ resource.list_op = op
215
+ case "get":
216
+ resource.get_op = op
217
+ case "create":
218
+ resource.create_op = op
219
+ case "update":
220
+ resource.update_op = op
221
+ case "replace":
222
+ resource.replace_op = op
223
+ case "delete":
224
+ resource.delete_op = op
225
+ case _:
226
+ resource.custom_actions.append(op)
227
+
228
+
229
+ def _to_model_operation(
230
+ path: str,
231
+ http_method: HttpMethod,
232
+ schema_op: SchemaOperation,
233
+ item: PathItem,
234
+ doc: OpenAPIDocument,
235
+ ) -> Operation:
236
+ if schema_op.operation_id is None:
237
+ # Synthesize one — operationId is required in practice but we degrade
238
+ # gracefully rather than crashing on hand-rolled schemas.
239
+ synthesized = f"{http_method.value.lower()}_{path.strip('/').replace('/', '_')}"
240
+ operation_id = synthesized
241
+ else:
242
+ operation_id = schema_op.operation_id
243
+
244
+ seen: dict[str, Parameter] = {}
245
+ for source_param in (*item.parameters, *schema_op.parameters):
246
+ seen[source_param.name] = _to_model_parameter(source_param)
247
+ parameters = list(seen.values())
248
+
249
+ return Operation(
250
+ operation_id=operation_id,
251
+ http_method=http_method,
252
+ path=path,
253
+ summary=schema_op.summary,
254
+ description=schema_op.description,
255
+ parameters=parameters,
256
+ request_body=_to_request_body_shape(schema_op, doc),
257
+ default_columns=_default_columns_from_response(schema_op, doc),
258
+ )
259
+
260
+
261
+ def _to_model_parameter(p: SchemaParameter) -> Parameter:
262
+ enum_values: list[str] | None = None
263
+ primitive = PrimitiveType.UNKNOWN
264
+ if p.schema_ is not None:
265
+ primitive = _primitive(p.schema_)
266
+ if p.schema_.enum:
267
+ enum_values = [str(v) for v in p.schema_.enum]
268
+ return Parameter(
269
+ name=p.name,
270
+ location=ParameterLocation(p.in_.value),
271
+ primitive=primitive,
272
+ required=p.required,
273
+ description=p.description,
274
+ enum=enum_values,
275
+ )
276
+
277
+
278
+ def _to_request_body_shape(
279
+ schema_op: SchemaOperation, doc: OpenAPIDocument
280
+ ) -> RequestBodyShape | None:
281
+ if schema_op.request_body is None:
282
+ return None
283
+ media = schema_op.request_body.content.get("application/json")
284
+ if media is None or media.schema_ is None:
285
+ return None
286
+ schema = _resolve_ref(media.schema_, doc)
287
+ if schema is None:
288
+ return None
289
+ top_level = _classify_top_level(schema, doc)
290
+ if top_level is None:
291
+ return None
292
+ if top_level in ("object", "object_or_array"):
293
+ branch = _object_branch(schema, doc)
294
+ required = list(branch.required or []) if branch is not None else []
295
+ else:
296
+ required = []
297
+ fields = _flat_fields(schema, doc) if top_level in ("object", "object_or_array") else {}
298
+ raw_paths = _collect_sensitive_paths(schema, doc)
299
+ sensitive_paths = tuple(sorted({".".join(p) for p in raw_paths if p}))
300
+ return RequestBodyShape(
301
+ top_level=top_level,
302
+ required=required,
303
+ fields=fields,
304
+ sensitive_paths=sensitive_paths,
305
+ )
306
+
307
+
308
+ def _branch_type(raw: object, doc: OpenAPIDocument) -> str | None:
309
+ """Return the top-level "type" for a oneOf/anyOf branch, resolving $ref once."""
310
+ if not isinstance(raw, dict):
311
+ return None
312
+ direct = raw.get("type")
313
+ if direct in {"object", "array"}:
314
+ return str(direct)
315
+ ref = raw.get("$ref")
316
+ if isinstance(ref, str):
317
+ candidate = SchemaObject.model_validate({"$ref": ref})
318
+ resolved = _resolve_ref(candidate, doc)
319
+ if resolved is not None and resolved.type in ("object", "array"):
320
+ return resolved.type
321
+ return None
322
+
323
+
324
+ def _classify_top_level(
325
+ schema: SchemaObject, doc: OpenAPIDocument
326
+ ) -> Literal["object", "array", "object_or_array"] | None:
327
+ if schema.type in ("object", "array"):
328
+ return schema.type # type: ignore[return-value]
329
+ one_of = schema.model_extra.get("oneOf") if schema.model_extra else None
330
+ any_of = schema.model_extra.get("anyOf") if schema.model_extra else None
331
+ candidates = one_of or any_of or None
332
+ if not candidates:
333
+ return None
334
+ types = {t for raw in candidates if (t := _branch_type(raw, doc)) is not None}
335
+ if {"object", "array"}.issubset(types):
336
+ return "object_or_array"
337
+ if types == {"object"}:
338
+ return "object"
339
+ return "array" if types == {"array"} else None
340
+
341
+
342
+ def _object_branch(schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
343
+ """Find the object-shaped variant of a oneOf/anyOf, resolving $ref branches."""
344
+ if schema.type == "object":
345
+ return schema
346
+ candidates = (
347
+ (schema.model_extra or {}).get("oneOf") or (schema.model_extra or {}).get("anyOf") or []
348
+ )
349
+ for raw in candidates:
350
+ if not isinstance(raw, dict):
351
+ continue
352
+ if raw.get("type") == "object":
353
+ return SchemaObject.model_validate(raw)
354
+ ref = raw.get("$ref")
355
+ if isinstance(ref, str):
356
+ resolved = _resolve_ref(SchemaObject.model_validate({"$ref": ref}), doc)
357
+ if resolved is not None and resolved.type == "object":
358
+ return resolved
359
+ return None
360
+
361
+
362
+ def _flat_fields(schema: SchemaObject, doc: OpenAPIDocument) -> dict[str, FieldShape]:
363
+ target = _object_branch(schema, doc)
364
+ if target is None or not target.properties:
365
+ return {}
366
+ out: dict[str, FieldShape] = {}
367
+ for name, prop in target.properties.items():
368
+ resolved = _resolve_ref(prop, doc) or prop
369
+ primitive = _primitive(resolved)
370
+ enum = [str(v) for v in resolved.enum] if resolved.enum else None
371
+ out[name] = FieldShape(primitive=primitive, enum=enum)
372
+ return out
373
+
374
+
375
+ def _primitive(schema: SchemaObject) -> PrimitiveType:
376
+ if schema.type is None:
377
+ return PrimitiveType.UNKNOWN
378
+ try:
379
+ return PrimitiveType(schema.type)
380
+ except ValueError:
381
+ return PrimitiveType.UNKNOWN
382
+
383
+
384
+ _SCALAR_TYPES = {"string", "integer", "number", "boolean"}
385
+ _PRIORITY_NAMES = ("name", "slug", "display")
386
+ _MAX_DEFAULT_COLUMNS = 6
387
+
388
+
389
+ def _resolve_ref(schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
390
+ if schema.ref is None:
391
+ return schema
392
+ # OpenAPI refs look like "#/components/schemas/<Name>" — we only support that form.
393
+ prefix = "#/components/schemas/"
394
+ if not schema.ref.startswith(prefix):
395
+ return None
396
+ name = schema.ref[len(prefix) :]
397
+ return doc.components.schemas.get(name)
398
+
399
+
400
+ def _is_sensitive_field(name: str, schema: SchemaObject) -> bool:
401
+ if name.lower() in _CANONICAL_SENSITIVE_NAMES:
402
+ return True
403
+ return getattr(schema, "format", None) == "password"
404
+
405
+
406
+ def _collect_sensitive_paths(
407
+ schema: SchemaObject,
408
+ doc: OpenAPIDocument,
409
+ *,
410
+ prefix: tuple[str, ...] = (),
411
+ seen: frozenset[str] = frozenset(),
412
+ ) -> list[tuple[str, ...]]:
413
+ """Return all dotted paths to sensitive fields under `schema`.
414
+
415
+ `seen` carries already-traversed `$ref` targets to break cycles in
416
+ self-referential schemas (NetBox has a few via `tags`).
417
+ """
418
+ target = _resolve_ref(schema, doc) or schema
419
+ new_seen = seen
420
+ if target.ref is not None:
421
+ if target.ref in seen:
422
+ return []
423
+ new_seen = seen | {target.ref}
424
+ elif schema.ref is not None:
425
+ if schema.ref in seen:
426
+ return []
427
+ new_seen = seen | {schema.ref}
428
+
429
+ paths: list[tuple[str, ...]] = []
430
+
431
+ if target.type == "object" and target.properties:
432
+ for name, prop in target.properties.items():
433
+ child_prefix = (*prefix, name)
434
+ resolved_prop = _resolve_ref(prop, doc) or prop
435
+ if _is_sensitive_field(name, resolved_prop):
436
+ paths.append(child_prefix)
437
+ continue
438
+ paths.extend(
439
+ _collect_sensitive_paths(resolved_prop, doc, prefix=child_prefix, seen=new_seen)
440
+ )
441
+
442
+ if target.type == "array" and target.items is not None:
443
+ paths.extend(_collect_sensitive_paths(target.items, doc, prefix=prefix, seen=new_seen))
444
+
445
+ candidates = (
446
+ (target.model_extra or {}).get("oneOf") or (target.model_extra or {}).get("anyOf") or []
447
+ )
448
+ for raw in candidates:
449
+ if isinstance(raw, dict):
450
+ branch = SchemaObject.model_validate(raw)
451
+ paths.extend(_collect_sensitive_paths(branch, doc, prefix=prefix, seen=new_seen))
452
+
453
+ return paths
454
+
455
+
456
+ def _record_shape(response_schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
457
+ resolved = _resolve_ref(response_schema, doc)
458
+ if resolved is None:
459
+ return None
460
+ props = resolved.properties or {}
461
+ results = props.get("results")
462
+ if results is not None and results.type == "array" and results.items is not None:
463
+ return _resolve_ref(results.items, doc)
464
+ return resolved
465
+
466
+
467
+ def _default_columns_from_response(
468
+ schema_op: SchemaOperation, doc: OpenAPIDocument
469
+ ) -> list[str] | None:
470
+ response = schema_op.responses.get("200")
471
+ if response is None:
472
+ return None
473
+ media = response.content.get("application/json")
474
+ if media is None or media.schema_ is None:
475
+ return None
476
+ record = _record_shape(media.schema_, doc)
477
+ if record is None or not record.properties:
478
+ return None
479
+
480
+ cols: list[str] = []
481
+ if "id" in record.properties:
482
+ cols.append("id")
483
+ for name in _PRIORITY_NAMES:
484
+ if name in record.properties and name not in cols:
485
+ cols.append(name)
486
+ for name, prop in record.properties.items():
487
+ if len(cols) >= _MAX_DEFAULT_COLUMNS:
488
+ break
489
+ if name in cols:
490
+ continue
491
+ if (prop.type or "") not in _SCALAR_TYPES:
492
+ continue
493
+ cols.append(name)
494
+ return cols if cols else None
495
+
496
+
497
+ def _tag_description(name: str, doc: OpenAPIDocument) -> str | None:
498
+ for tag in doc.tags:
499
+ if tag.name == name:
500
+ return tag.description
501
+ return None
502
+
503
+
504
+ def _finalize_resource(name: str, m: _MutableResource) -> Resource:
505
+ return Resource(
506
+ name=name,
507
+ list_op=m.list_op,
508
+ get_op=m.get_op,
509
+ create_op=m.create_op,
510
+ update_op=m.update_op,
511
+ replace_op=m.replace_op,
512
+ delete_op=m.delete_op,
513
+ custom_actions=list(m.custom_actions),
514
+ )
nsc/cache/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """On-disk cache for generated CommandModels."""
2
+
3
+ from nsc.cache.store import CacheStore
4
+
5
+ __all__ = ["CacheStore"]