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,465 @@
1
+ """Walk a CommandModel and register Typer commands for read AND write operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import typer
10
+ from click import Choice
11
+
12
+ from nsc.cli.handlers import (
13
+ handle_create,
14
+ handle_custom_action,
15
+ handle_custom_action_write,
16
+ handle_delete,
17
+ handle_get,
18
+ handle_list,
19
+ handle_update,
20
+ )
21
+ from nsc.cli.runtime import RuntimeContext
22
+ from nsc.config.models import OutputFormat
23
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
24
+ from nsc.model.command_model import (
25
+ CommandModel,
26
+ HttpMethod,
27
+ Operation,
28
+ Parameter,
29
+ ParameterLocation,
30
+ PrimitiveType,
31
+ Resource,
32
+ )
33
+
34
+ CtxFactory = Callable[[], RuntimeContext]
35
+
36
+
37
+ def register_dynamic_commands(app: typer.Typer, model: CommandModel, get_ctx: CtxFactory) -> None:
38
+ for tag_name, tag in sorted(model.tags.items()):
39
+ tag_app = typer.Typer(no_args_is_help=True, help=tag.description or "")
40
+ app.add_typer(tag_app, name=tag_name)
41
+ for resource_name, resource in sorted(tag.resources.items()):
42
+ resource_app = typer.Typer(no_args_is_help=True)
43
+ tag_app.add_typer(resource_app, name=resource_name)
44
+ _register_resource_commands(resource_app, tag_name, resource_name, resource, get_ctx)
45
+
46
+
47
+ def _register_resource_commands(
48
+ app: typer.Typer,
49
+ tag_name: str,
50
+ resource_name: str,
51
+ resource: Resource,
52
+ get_ctx: CtxFactory,
53
+ ) -> None:
54
+ if resource.list_op is not None:
55
+ _register_read(app, "list", resource.list_op, tag_name, resource_name, get_ctx, handle_list)
56
+ if resource.get_op is not None:
57
+ _register_read(app, "get", resource.get_op, tag_name, resource_name, get_ctx, handle_get)
58
+ if resource.create_op is not None:
59
+ _register_write(
60
+ app, "create", resource.create_op, tag_name, resource_name, get_ctx, handle_create
61
+ )
62
+ if resource.update_op is not None:
63
+ _register_write(
64
+ app, "update", resource.update_op, tag_name, resource_name, get_ctx, handle_update
65
+ )
66
+ if resource.replace_op is not None:
67
+ _register_write(
68
+ app, "replace", resource.replace_op, tag_name, resource_name, get_ctx, handle_update
69
+ )
70
+ if resource.delete_op is not None:
71
+ _register_write(
72
+ app, "delete", resource.delete_op, tag_name, resource_name, get_ctx, handle_delete
73
+ )
74
+ for action in resource.custom_actions:
75
+ if action.http_method is HttpMethod.GET:
76
+ verb = _custom_action_verb(action.operation_id, resource_name, is_write=False)
77
+ _register_read(
78
+ app, verb, action, tag_name, resource_name, get_ctx, handle_custom_action
79
+ )
80
+ elif action.http_method in {
81
+ HttpMethod.POST,
82
+ HttpMethod.PATCH,
83
+ HttpMethod.PUT,
84
+ HttpMethod.DELETE,
85
+ }:
86
+ verb = _custom_action_verb(action.operation_id, resource_name, is_write=True)
87
+ _register_write(
88
+ app, verb, action, tag_name, resource_name, get_ctx, handle_custom_action_write
89
+ )
90
+
91
+
92
+ def _register_read(
93
+ app: typer.Typer,
94
+ name: str,
95
+ operation: Operation,
96
+ tag_name: str,
97
+ resource_name: str,
98
+ get_ctx: CtxFactory,
99
+ handler: Callable[..., None],
100
+ ) -> None:
101
+ closure = _build_read_closure(operation, tag_name, resource_name, get_ctx, handler)
102
+ app.command(name=name, help=operation.summary or operation.description or "")(closure)
103
+
104
+
105
+ def _register_write(
106
+ app: typer.Typer,
107
+ name: str,
108
+ operation: Operation,
109
+ tag_name: str,
110
+ resource_name: str,
111
+ get_ctx: CtxFactory,
112
+ handler: Callable[..., None],
113
+ ) -> None:
114
+ closure = _build_write_closure(operation, tag_name, resource_name, get_ctx, handler)
115
+ app.command(name=name, help=operation.summary or operation.description or "")(closure)
116
+
117
+
118
+ _GLOBAL_FLAG_NAMES: frozenset[str] = frozenset(
119
+ {"output", "compact", "columns", "limit", "all_", "filter_"}
120
+ )
121
+
122
+
123
+ def _build_read_closure(
124
+ operation: Operation,
125
+ tag_name: str,
126
+ resource_name: str,
127
+ get_ctx: CtxFactory,
128
+ handler: Callable[..., None],
129
+ ) -> Callable[..., None]:
130
+ sig_params: list[inspect.Parameter] = []
131
+ for p in operation.parameters:
132
+ if p.location is ParameterLocation.PATH:
133
+ sig_params.append(_to_positional(p))
134
+ elif p.location is ParameterLocation.QUERY:
135
+ if "__" in p.name:
136
+ continue
137
+ if p.name in _GLOBAL_FLAG_NAMES:
138
+ continue
139
+ sig_params.append(_to_typed_option(p))
140
+
141
+ sig_params.extend(_global_flag_params())
142
+
143
+ def impl(**kwargs: Any) -> None:
144
+ output = kwargs.pop("output", None)
145
+ compact = kwargs.pop("compact", False)
146
+ columns_csv = kwargs.pop("columns", None)
147
+ limit = kwargs.pop("limit", None)
148
+ fetch_all = kwargs.pop("all_", False)
149
+ filters_raw: list[str] = kwargs.pop("filter_", None) or []
150
+ ctx = get_ctx()
151
+ update: dict[str, Any] = {
152
+ "compact": compact,
153
+ "columns_override": columns_csv.split(",") if columns_csv else None,
154
+ "limit": limit,
155
+ "fetch_all": fetch_all,
156
+ "filters": [
157
+ (item.split("=", 1)[0], item.split("=", 1)[1])
158
+ for item in filters_raw
159
+ if "=" in item
160
+ ],
161
+ }
162
+ if output:
163
+ update["output_format"] = OutputFormat(output)
164
+ ctx = ctx.model_copy(update=update)
165
+ try:
166
+ handler(operation, op_tag=tag_name, op_resource=resource_name, ctx=ctx, **kwargs)
167
+ except (NetBoxAPIError, NetBoxClientError) as exc:
168
+ typer.echo(f"Error: {exc.render_for_cli()}", err=True)
169
+ raise typer.Exit(1) from exc
170
+
171
+ impl.__signature__ = inspect.Signature(parameters=sig_params) # type: ignore[attr-defined]
172
+ impl.__name__ = operation.operation_id
173
+ return impl
174
+
175
+
176
+ def _build_write_closure(
177
+ operation: Operation,
178
+ tag_name: str,
179
+ resource_name: str,
180
+ get_ctx: CtxFactory,
181
+ handler: Callable[..., None],
182
+ ) -> Callable[..., None]:
183
+ sig_params: list[inspect.Parameter] = []
184
+ for p in operation.parameters:
185
+ if p.location is ParameterLocation.PATH:
186
+ sig_params.append(_to_positional(p))
187
+ # Query parameters on writes are rare in NetBox; skip them in 3b.
188
+
189
+ sig_params.extend(_write_flag_params())
190
+
191
+ def impl(**kwargs: Any) -> None:
192
+ output = kwargs.pop("output", None)
193
+ compact = kwargs.pop("compact", False)
194
+ columns_csv = kwargs.pop("columns", None)
195
+ apply_flag: bool = kwargs.pop("apply", False)
196
+ explain: bool = kwargs.pop("explain", False)
197
+ strict: bool = kwargs.pop("strict", False)
198
+ file: str | None = kwargs.pop("file", None)
199
+ fields_raw: list[str] = list(kwargs.pop("field", None) or [])
200
+ format_: str | None = kwargs.pop("format_", None)
201
+ bulk: bool | None = kwargs.pop("bulk", None)
202
+ no_bulk: bool | None = kwargs.pop("no_bulk", None)
203
+ on_error: str = kwargs.pop("on_error", "stop")
204
+ ctx = get_ctx()
205
+ update: dict[str, Any] = {
206
+ "compact": compact,
207
+ "columns_override": columns_csv.split(",") if columns_csv else None,
208
+ "apply": apply_flag,
209
+ "explain": explain,
210
+ "strict": strict,
211
+ "file": file,
212
+ "fields": fields_raw,
213
+ "file_format": format_,
214
+ "bulk": bulk,
215
+ "no_bulk": no_bulk,
216
+ "on_error": on_error,
217
+ }
218
+ if output:
219
+ update["output_format"] = OutputFormat(output)
220
+ ctx = ctx.model_copy(update=update)
221
+ handler(operation, op_tag=tag_name, op_resource=resource_name, ctx=ctx, **kwargs)
222
+
223
+ impl.__signature__ = inspect.Signature(parameters=sig_params) # type: ignore[attr-defined]
224
+ impl.__name__ = operation.operation_id
225
+ return impl
226
+
227
+
228
+ def _to_positional(p: Parameter) -> inspect.Parameter:
229
+ py_type = _python_type(p)
230
+ return inspect.Parameter(
231
+ name=p.name,
232
+ kind=inspect.Parameter.KEYWORD_ONLY,
233
+ annotation=py_type,
234
+ default=typer.Argument(...),
235
+ )
236
+
237
+
238
+ def _to_typed_option(p: Parameter) -> inspect.Parameter:
239
+ flag_name = f"--{p.name.replace('_', '-')}"
240
+ py_type: Any = _python_type(p)
241
+ if p.enum:
242
+ option = typer.Option(
243
+ None,
244
+ flag_name,
245
+ help=p.description or "",
246
+ click_type=Choice(p.enum, case_sensitive=True),
247
+ )
248
+ py_type = str | None
249
+ elif p.primitive is PrimitiveType.BOOLEAN:
250
+ option = typer.Option(
251
+ None,
252
+ f"{flag_name}/--no-{p.name.replace('_', '-')}",
253
+ help=p.description or "",
254
+ )
255
+ py_type = bool | None
256
+ elif p.primitive is PrimitiveType.ARRAY:
257
+ option = typer.Option(None, flag_name, help=p.description or "")
258
+ py_type = list[str] | None
259
+ else:
260
+ option = typer.Option(None, flag_name, help=p.description or "")
261
+ py_type = py_type | None
262
+ return inspect.Parameter(
263
+ name=p.name,
264
+ kind=inspect.Parameter.KEYWORD_ONLY,
265
+ annotation=py_type,
266
+ default=option,
267
+ )
268
+
269
+
270
+ def _global_flag_params() -> list[inspect.Parameter]:
271
+ return [
272
+ inspect.Parameter(
273
+ name="output",
274
+ kind=inspect.Parameter.KEYWORD_ONLY,
275
+ annotation=str | None,
276
+ default=typer.Option(None, "--output", "-o"),
277
+ ),
278
+ inspect.Parameter(
279
+ name="compact",
280
+ kind=inspect.Parameter.KEYWORD_ONLY,
281
+ annotation=bool,
282
+ default=typer.Option(False, "--compact"),
283
+ ),
284
+ inspect.Parameter(
285
+ name="columns",
286
+ kind=inspect.Parameter.KEYWORD_ONLY,
287
+ annotation=str | None,
288
+ default=typer.Option(None, "--columns"),
289
+ ),
290
+ inspect.Parameter(
291
+ name="limit",
292
+ kind=inspect.Parameter.KEYWORD_ONLY,
293
+ annotation=int | None,
294
+ default=typer.Option(None, "--limit"),
295
+ ),
296
+ inspect.Parameter(
297
+ name="all_",
298
+ kind=inspect.Parameter.KEYWORD_ONLY,
299
+ annotation=bool,
300
+ default=typer.Option(False, "--all"),
301
+ ),
302
+ inspect.Parameter(
303
+ name="filter_",
304
+ kind=inspect.Parameter.KEYWORD_ONLY,
305
+ annotation=list[str] | None,
306
+ default=typer.Option(None, "--filter"),
307
+ ),
308
+ ]
309
+
310
+
311
+ def _write_flag_params() -> list[inspect.Parameter]:
312
+ return [
313
+ inspect.Parameter(
314
+ name="output",
315
+ kind=inspect.Parameter.KEYWORD_ONLY,
316
+ annotation=str | None,
317
+ default=typer.Option(None, "--output", "-o"),
318
+ ),
319
+ inspect.Parameter(
320
+ name="compact",
321
+ kind=inspect.Parameter.KEYWORD_ONLY,
322
+ annotation=bool,
323
+ default=typer.Option(False, "--compact"),
324
+ ),
325
+ inspect.Parameter(
326
+ name="columns",
327
+ kind=inspect.Parameter.KEYWORD_ONLY,
328
+ annotation=str | None,
329
+ default=typer.Option(None, "--columns"),
330
+ ),
331
+ inspect.Parameter(
332
+ name="apply",
333
+ kind=inspect.Parameter.KEYWORD_ONLY,
334
+ annotation=bool,
335
+ default=typer.Option(
336
+ False,
337
+ "--apply",
338
+ "-a",
339
+ help="Send the request. Without this, dry-run only (no wire effect).",
340
+ ),
341
+ ),
342
+ inspect.Parameter(
343
+ name="explain",
344
+ kind=inspect.Parameter.KEYWORD_ONLY,
345
+ annotation=bool,
346
+ default=typer.Option(
347
+ False,
348
+ "--explain",
349
+ help="Print the resolved request and field-level provenance.",
350
+ ),
351
+ ),
352
+ inspect.Parameter(
353
+ name="strict",
354
+ kind=inspect.Parameter.KEYWORD_ONLY,
355
+ annotation=bool,
356
+ default=typer.Option(
357
+ False,
358
+ "--strict",
359
+ help="On DELETE: fail with exit 9 if the object is already absent.",
360
+ ),
361
+ ),
362
+ inspect.Parameter(
363
+ name="file",
364
+ kind=inspect.Parameter.KEYWORD_ONLY,
365
+ annotation=str | None,
366
+ default=typer.Option(
367
+ None,
368
+ "-f",
369
+ "--file",
370
+ help="Path to a yaml/yml/json file with the request body. Use `-` for stdin.",
371
+ ),
372
+ ),
373
+ inspect.Parameter(
374
+ name="field",
375
+ kind=inspect.Parameter.KEYWORD_ONLY,
376
+ annotation=list[str] | None,
377
+ default=typer.Option(
378
+ None,
379
+ "--field",
380
+ help=(
381
+ "key=value field override; repeatable; dotted paths allowed "
382
+ "(site.name=us-east-1)."
383
+ ),
384
+ ),
385
+ ),
386
+ inspect.Parameter(
387
+ name="format_",
388
+ kind=inspect.Parameter.KEYWORD_ONLY,
389
+ annotation=str | None,
390
+ default=typer.Option(
391
+ None,
392
+ "--format",
393
+ help="Override file-format detection (yaml|yml|json).",
394
+ ),
395
+ ),
396
+ inspect.Parameter(
397
+ name="bulk",
398
+ kind=inspect.Parameter.KEYWORD_ONLY,
399
+ annotation=bool | None,
400
+ default=typer.Option(
401
+ None,
402
+ "--bulk",
403
+ help=(
404
+ "Force a bulk POST/PATCH (single HTTP call with array body). "
405
+ "Errors if the endpoint is not bulk-capable."
406
+ ),
407
+ ),
408
+ ),
409
+ inspect.Parameter(
410
+ name="no_bulk",
411
+ kind=inspect.Parameter.KEYWORD_ONLY,
412
+ annotation=bool | None,
413
+ default=typer.Option(
414
+ None,
415
+ "--no-bulk",
416
+ help="Force per-record looping (N sequential HTTP calls).",
417
+ ),
418
+ ),
419
+ inspect.Parameter(
420
+ name="on_error",
421
+ kind=inspect.Parameter.KEYWORD_ONLY,
422
+ annotation=str,
423
+ default=typer.Option(
424
+ "stop",
425
+ "--on-error",
426
+ help=(
427
+ "stop|continue. stop: abort on first failure (default). "
428
+ "continue: attempt every record; final exit code = worst error type."
429
+ ),
430
+ ),
431
+ ),
432
+ ]
433
+
434
+
435
+ def _python_type(p: Parameter) -> Any:
436
+ match p.primitive:
437
+ case PrimitiveType.INTEGER:
438
+ return int
439
+ case PrimitiveType.NUMBER:
440
+ return float
441
+ case PrimitiveType.BOOLEAN:
442
+ return bool
443
+ case _:
444
+ return str
445
+
446
+
447
+ def _custom_action_verb(operation_id: str, resource_name: str, *, is_write: bool) -> str:
448
+ """Derive a CLI verb from a custom-action operationId.
449
+
450
+ Reads strip `_list`/`_retrieve` so list/retrieve custom-actions read
451
+ naturally (e.g. `available-asns list` → `available-asns`). Writes keep
452
+ their action suffix so PUT/PATCH/DELETE on the same base don't collide
453
+ in the command tree.
454
+ """
455
+ name = operation_id
456
+ read_suffixes = ("_list", "_retrieve")
457
+ if not is_write:
458
+ for suffix in read_suffixes:
459
+ if name.endswith(suffix):
460
+ name = name[: -len(suffix)]
461
+ break
462
+ res_underscored = resource_name.replace("-", "_") if resource_name else ""
463
+ if res_underscored and res_underscored in name:
464
+ name = name.split(res_underscored, 1)[-1].lstrip("_")
465
+ return name.replace("_", "-") or operation_id