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/cli/handlers.py ADDED
@@ -0,0 +1,660 @@
1
+ """Read and write handlers consumed by the dynamic Typer commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any, TextIO
10
+
11
+ import typer
12
+
13
+ from nsc.cli.runtime import RuntimeContext, apply_limit, emit_envelope, map_error
14
+ from nsc.cli.writes.apply import ResolvedRequest
15
+ from nsc.cli.writes.apply import resolve as resolve_request
16
+ from nsc.cli.writes.bulk import (
17
+ BulkCapability,
18
+ RoutingDecision,
19
+ RoutingMode,
20
+ UnsupportedBulkError,
21
+ detect_bulk_capability,
22
+ route_to_bulk_or_loop,
23
+ run_loop,
24
+ )
25
+ from nsc.cli.writes.confirmation import (
26
+ refuse_all_on_writes,
27
+ refuse_bulk_and_no_bulk_together,
28
+ refuse_delete_without_id,
29
+ refuse_unknown_on_error,
30
+ refuse_unsupported_bulk,
31
+ )
32
+ from nsc.cli.writes.input import InputError, NDJSONParseError, RawWriteInput
33
+ from nsc.cli.writes.input import collect as collect_input
34
+ from nsc.cli.writes.preflight import PreflightResult
35
+ from nsc.cli.writes.preflight import check as preflight_check
36
+ from nsc.config.models import OutputFormat
37
+ from nsc.config.settings import default_paths
38
+ from nsc.http.audit import AuditEntry, append_audit_jsonl
39
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
40
+ from nsc.model.command_model import HttpMethod, Operation, ParameterLocation
41
+ from nsc.output.errors import (
42
+ ClientError,
43
+ ErrorEnvelope,
44
+ ErrorType,
45
+ client_envelope,
46
+ input_error_envelope,
47
+ summary_envelope,
48
+ )
49
+ from nsc.output.explain import (
50
+ ExplainTrace,
51
+ )
52
+ from nsc.output.explain import (
53
+ render_to_json as render_explain_json,
54
+ )
55
+ from nsc.output.explain import (
56
+ render_to_rich_stdout as render_explain_rich,
57
+ )
58
+ from nsc.output.render import render
59
+
60
+ _STATUS_NOT_FOUND_DELETE = 404
61
+
62
+
63
+ def parse_filters(raw: list[str]) -> dict[str, str]:
64
+ out: dict[str, str] = {}
65
+ for item in raw:
66
+ if "=" not in item:
67
+ raise ValueError(f"--filter expects key=value, got: {item!r}")
68
+ key, _, value = item.partition("=")
69
+ out[key.strip()] = value.strip()
70
+ return out
71
+
72
+
73
+ def handle_list(
74
+ operation: Operation,
75
+ op_tag: str,
76
+ op_resource: str,
77
+ ctx: RuntimeContext,
78
+ *,
79
+ stream: TextIO | None = None,
80
+ **kwargs: Any,
81
+ ) -> None:
82
+ try:
83
+ params, _ = _split_params(operation, kwargs)
84
+ params.update(parse_filters([f"{k}={v}" for k, v in ctx.filters]))
85
+ iterator = ctx.client.paginate(operation.path, params)
86
+ rows = list(
87
+ apply_limit(
88
+ iterator,
89
+ limit=ctx.limit,
90
+ fetch_all=ctx.fetch_all,
91
+ page_size=ctx.page_size,
92
+ )
93
+ )
94
+ render(
95
+ rows,
96
+ format=ctx.output_format,
97
+ columns=ctx.resolve_columns(op_tag, op_resource, operation),
98
+ stream=stream if stream is not None else sys.stdout,
99
+ compact=ctx.compact,
100
+ )
101
+ except (NetBoxAPIError, NetBoxClientError) as exc:
102
+ env = map_error(exc, operation_id=operation.operation_id)
103
+ code = emit_envelope(env, output_format=ctx.output_format)
104
+ raise typer.Exit(code) from exc
105
+
106
+
107
+ def handle_get(
108
+ operation: Operation,
109
+ op_tag: str,
110
+ op_resource: str,
111
+ ctx: RuntimeContext,
112
+ *,
113
+ stream: TextIO | None = None,
114
+ **kwargs: Any,
115
+ ) -> None:
116
+ try:
117
+ params, path_vars = _split_params(operation, kwargs)
118
+ obj = ctx.client.get(operation.path.format(**path_vars), params)
119
+ render(
120
+ obj,
121
+ format=ctx.output_format,
122
+ columns=ctx.resolve_columns(op_tag, op_resource, operation),
123
+ stream=stream if stream is not None else sys.stdout,
124
+ compact=ctx.compact,
125
+ )
126
+ except (NetBoxAPIError, NetBoxClientError) as exc:
127
+ env = map_error(exc, operation_id=operation.operation_id)
128
+ code = emit_envelope(env, output_format=ctx.output_format)
129
+ raise typer.Exit(code) from exc
130
+
131
+
132
+ def handle_custom_action(
133
+ operation: Operation,
134
+ op_tag: str,
135
+ op_resource: str,
136
+ ctx: RuntimeContext,
137
+ *,
138
+ stream: TextIO | None = None,
139
+ **kwargs: Any,
140
+ ) -> None:
141
+ handle_get(operation, op_tag, op_resource, ctx, stream=stream, **kwargs)
142
+
143
+
144
+ def handle_create(
145
+ operation: Operation,
146
+ op_tag: str,
147
+ op_resource: str,
148
+ ctx: RuntimeContext,
149
+ *,
150
+ stream: TextIO | None = None,
151
+ **kwargs: Any,
152
+ ) -> None:
153
+ _handle_write(
154
+ operation,
155
+ op_tag=op_tag,
156
+ op_resource=op_resource,
157
+ ctx=ctx,
158
+ path_vars=_extract_path_vars(operation, kwargs),
159
+ stream=stream,
160
+ require_id=False,
161
+ )
162
+
163
+
164
+ def handle_update(
165
+ operation: Operation,
166
+ op_tag: str,
167
+ op_resource: str,
168
+ ctx: RuntimeContext,
169
+ *,
170
+ stream: TextIO | None = None,
171
+ **kwargs: Any,
172
+ ) -> None:
173
+ _handle_write(
174
+ operation,
175
+ op_tag=op_tag,
176
+ op_resource=op_resource,
177
+ ctx=ctx,
178
+ path_vars=_extract_path_vars(operation, kwargs),
179
+ stream=stream,
180
+ require_id=True,
181
+ )
182
+
183
+
184
+ def handle_delete(
185
+ operation: Operation,
186
+ op_tag: str,
187
+ op_resource: str,
188
+ ctx: RuntimeContext,
189
+ *,
190
+ stream: TextIO | None = None,
191
+ **kwargs: Any,
192
+ ) -> None:
193
+ _handle_write(
194
+ operation,
195
+ op_tag=op_tag,
196
+ op_resource=op_resource,
197
+ ctx=ctx,
198
+ path_vars=_extract_path_vars(operation, kwargs),
199
+ stream=stream,
200
+ require_id=True,
201
+ )
202
+
203
+
204
+ def handle_custom_action_write(
205
+ operation: Operation,
206
+ op_tag: str,
207
+ op_resource: str,
208
+ ctx: RuntimeContext,
209
+ *,
210
+ stream: TextIO | None = None,
211
+ **kwargs: Any,
212
+ ) -> None:
213
+ _handle_write(
214
+ operation,
215
+ op_tag=op_tag,
216
+ op_resource=op_resource,
217
+ ctx=ctx,
218
+ path_vars=_extract_path_vars(operation, kwargs),
219
+ stream=stream,
220
+ require_id=False,
221
+ )
222
+
223
+
224
+ def _handle_write(
225
+ operation: Operation,
226
+ *,
227
+ op_tag: str,
228
+ op_resource: str,
229
+ ctx: RuntimeContext,
230
+ path_vars: dict[str, str],
231
+ stream: TextIO | None,
232
+ require_id: bool,
233
+ ) -> None:
234
+ out = stream if stream is not None else sys.stdout
235
+ try:
236
+ if ctx.fetch_all:
237
+ refuse_all_on_writes(operation_id=operation.operation_id)
238
+ path_param_names = {
239
+ p.name for p in operation.parameters if p.location is ParameterLocation.PATH
240
+ }
241
+ if require_id and "id" in path_param_names and not path_vars.get("id"):
242
+ refuse_delete_without_id(operation_id=operation.operation_id)
243
+
244
+ refuse_bulk_and_no_bulk_together(
245
+ bulk=ctx.bulk is True,
246
+ no_bulk=ctx.no_bulk is True,
247
+ operation_id=operation.operation_id,
248
+ )
249
+ refuse_unknown_on_error(ctx.on_error)
250
+
251
+ is_delete = operation.http_method is HttpMethod.DELETE
252
+ if is_delete:
253
+ raw = RawWriteInput(records=[{}], source="fields_only")
254
+ else:
255
+ raw = collect_input(
256
+ file=Path(ctx.file) if ctx.file else None,
257
+ fields=list(ctx.fields),
258
+ stdin=sys.stdin if ctx.file == "-" else None,
259
+ )
260
+
261
+ decision = _decide_routing(operation, ctx, raw)
262
+
263
+ preflight = preflight_check(raw, operation)
264
+ resolved = resolve_request(
265
+ raw,
266
+ operation,
267
+ path_vars=path_vars,
268
+ base_url=str(ctx.resolved_profile.url),
269
+ headers={
270
+ "Authorization": f"Token {ctx.resolved_profile.token}",
271
+ "Accept": "application/json",
272
+ },
273
+ mode=decision.mode,
274
+ )
275
+ field_overrides = {f.split("=", 1)[0].split(".")[0] for f in ctx.fields if "=" in f}
276
+ trace = ExplainTrace.build_for(
277
+ operation,
278
+ raw,
279
+ preflight,
280
+ resolved,
281
+ field_overrides=field_overrides,
282
+ routing_decision=decision,
283
+ )
284
+
285
+ if _handle_dry_run_or_preflight(operation, ctx, resolved, preflight, trace, out=out):
286
+ return
287
+
288
+ if ctx.explain:
289
+ _render_explain_or_dry_run(trace, ctx, stream=out)
290
+
291
+ if decision.mode is RoutingMode.LOOP:
292
+ _execute_loop(
293
+ operation,
294
+ resolved,
295
+ ctx,
296
+ op_tag=op_tag,
297
+ op_resource=op_resource,
298
+ stream=out,
299
+ total_records=len(raw.records),
300
+ is_delete=is_delete,
301
+ )
302
+ return
303
+
304
+ response = _send_one(operation, resolved[0], ctx)
305
+ _render_response(
306
+ operation,
307
+ response,
308
+ ctx,
309
+ op_tag=op_tag,
310
+ op_resource=op_resource,
311
+ stream=out,
312
+ is_delete=is_delete,
313
+ )
314
+ except ClientError as exc:
315
+ code = emit_envelope(exc.envelope, output_format=ctx.output_format)
316
+ raise typer.Exit(code) from exc
317
+ except NDJSONParseError as exc:
318
+ env = input_error_envelope(
319
+ message=str(exc),
320
+ bad_lines=exc.bad_lines,
321
+ operation_id=operation.operation_id,
322
+ )
323
+ code = emit_envelope(env, output_format=ctx.output_format)
324
+ raise typer.Exit(code) from exc
325
+ except InputError as exc:
326
+ env = client_envelope(str(exc), operation_id=operation.operation_id)
327
+ code = emit_envelope(env, output_format=ctx.output_format)
328
+ raise typer.Exit(code) from exc
329
+ except (NetBoxAPIError, NetBoxClientError) as exc:
330
+ if (
331
+ isinstance(exc, NetBoxAPIError)
332
+ and exc.status_code == _STATUS_NOT_FOUND_DELETE
333
+ and operation.http_method is HttpMethod.DELETE
334
+ and not ctx.strict
335
+ ):
336
+ _render_delete_already_absent(ctx, stream=out)
337
+ return
338
+ env = map_error(exc, operation_id=operation.operation_id)
339
+ code = emit_envelope(env, output_format=ctx.output_format)
340
+ raise typer.Exit(code) from exc
341
+
342
+
343
+ def _handle_dry_run_or_preflight(
344
+ operation: Operation,
345
+ ctx: RuntimeContext,
346
+ resolved: list[ResolvedRequest],
347
+ preflight: PreflightResult,
348
+ trace: ExplainTrace,
349
+ *,
350
+ out: TextIO,
351
+ ) -> bool:
352
+ """Handle dry-run rendering and preflight-blocked envelope emission.
353
+
354
+ Returns True if the caller should return immediately (dry-run path);
355
+ False to continue to the apply path. Raises `typer.Exit` if preflight
356
+ failed (in either dry-run or apply mode).
357
+ """
358
+ if not ctx.apply:
359
+ _emit_dry_run_audit(operation, resolved, preflight, ctx)
360
+ _render_explain_or_dry_run(trace, ctx, stream=out)
361
+ if not preflight.ok:
362
+ env = _preflight_envelope(operation, preflight, applied=False)
363
+ code = emit_envelope(env, output_format=ctx.output_format)
364
+ raise typer.Exit(code)
365
+ return True
366
+ if not preflight.ok:
367
+ _emit_dry_run_audit(operation, resolved, preflight, ctx, preflight_blocked=True)
368
+ env = _preflight_envelope(operation, preflight, applied=False)
369
+ code = emit_envelope(env, output_format=ctx.output_format)
370
+ raise typer.Exit(code)
371
+ return False
372
+
373
+
374
+ def _decide_routing(
375
+ operation: Operation,
376
+ ctx: RuntimeContext,
377
+ raw: RawWriteInput,
378
+ ) -> RoutingDecision:
379
+ """Compute the routing decision and emit the AMBIGUOUS warning.
380
+
381
+ Wraps `UnsupportedBulkError` into a ClientError via `refuse_unsupported_bulk`.
382
+ """
383
+ capability = detect_bulk_capability(operation)
384
+ if ctx.bulk is True:
385
+ bulk_flag: bool | None = True
386
+ elif ctx.no_bulk is True:
387
+ bulk_flag = False
388
+ else:
389
+ bulk_flag = None
390
+ try:
391
+ decision = route_to_bulk_or_loop(
392
+ record_count=len(raw.records),
393
+ capability=capability,
394
+ bulk_flag=bulk_flag,
395
+ )
396
+ except UnsupportedBulkError as exc:
397
+ refuse_unsupported_bulk(exc, operation_id=operation.operation_id)
398
+ raise # unreachable; refuse_unsupported_bulk always raises
399
+ if capability is BulkCapability.AMBIGUOUS:
400
+ print(
401
+ f"warning: bulk capability for {operation.operation_id} is ambiguous; "
402
+ f"treating as {decision.mode.value} (use --bulk or --no-bulk to be explicit)",
403
+ file=sys.stderr,
404
+ )
405
+ return decision
406
+
407
+
408
+ def _execute_loop(
409
+ operation: Operation,
410
+ requests: list[ResolvedRequest],
411
+ ctx: RuntimeContext,
412
+ *,
413
+ op_tag: str,
414
+ op_resource: str,
415
+ stream: TextIO,
416
+ total_records: int,
417
+ is_delete: bool,
418
+ ) -> None:
419
+ """Run the sequential loop and emit summary envelope on any failure."""
420
+
421
+ def _send_one_loop(op: Operation, request: ResolvedRequest) -> dict[str, Any]:
422
+ return _send_one(op, request, ctx)
423
+
424
+ def _audit_one(
425
+ _request: ResolvedRequest,
426
+ _response: dict[str, Any] | None,
427
+ _err: Exception | None,
428
+ ) -> None:
429
+ # NetBoxClient.{post,patch,put,delete} already writes one audit entry
430
+ # per HTTP request. Writing here would double-count. The callback
431
+ # exists so unit tests can verify per-attempt ordering; production
432
+ # wiring leaves it as a no-op.
433
+ return
434
+
435
+ def _to_envelope(exc: Exception) -> ErrorEnvelope:
436
+ if isinstance(exc, NetBoxAPIError | NetBoxClientError):
437
+ return map_error(exc, operation_id=operation.operation_id)
438
+ return ErrorEnvelope(
439
+ error=str(exc),
440
+ type=ErrorType.INTERNAL,
441
+ operation_id=operation.operation_id,
442
+ )
443
+
444
+ result = run_loop(
445
+ requests,
446
+ operation=operation,
447
+ on_error=ctx.on_error,
448
+ send_one=_send_one_loop,
449
+ audit_attempt=_audit_one,
450
+ to_envelope=_to_envelope,
451
+ )
452
+
453
+ failures: list[ErrorEnvelope] = []
454
+ for attempt in result.attempts:
455
+ if attempt.failure is None:
456
+ continue
457
+ idx = attempt.request.record_indices[0]
458
+ failures.append(attempt.failure.model_copy(update={"record_index": idx}))
459
+
460
+ if not failures:
461
+ last_response = result.attempts[-1].response or {}
462
+ _render_response(
463
+ operation,
464
+ last_response,
465
+ ctx,
466
+ op_tag=op_tag,
467
+ op_resource=op_resource,
468
+ stream=stream,
469
+ is_delete=is_delete,
470
+ )
471
+ return
472
+
473
+ env = summary_envelope(
474
+ attempted=result.attempted,
475
+ failures=failures,
476
+ on_error=ctx.on_error,
477
+ operation_id=operation.operation_id,
478
+ total_records=total_records,
479
+ )
480
+ code = emit_envelope(env, output_format=ctx.output_format)
481
+ raise typer.Exit(code)
482
+
483
+
484
+ def _extract_path_vars(operation: Operation, kwargs: dict[str, Any]) -> dict[str, str]:
485
+ path_names = {p.name for p in operation.parameters if p.location is ParameterLocation.PATH}
486
+ return {k: str(v) for k, v in kwargs.items() if k in path_names and v is not None}
487
+
488
+
489
+ def _emit_dry_run_audit(
490
+ operation: Operation,
491
+ resolved: list[ResolvedRequest],
492
+ preflight: PreflightResult,
493
+ ctx: RuntimeContext,
494
+ *,
495
+ preflight_blocked: bool = False,
496
+ ) -> None:
497
+ log_dir = default_paths().logs_dir
498
+ sensitive_paths = (
499
+ operation.request_body.sensitive_paths if operation.request_body is not None else ()
500
+ )
501
+ for r in resolved:
502
+ entry = AuditEntry(
503
+ timestamp=_now_iso(),
504
+ operation_id=operation.operation_id,
505
+ method=operation.http_method,
506
+ url=r.url,
507
+ request_headers={
508
+ "Authorization": f"Token {ctx.resolved_profile.token}",
509
+ "Accept": "application/json",
510
+ },
511
+ request_query=dict(r.query or {}),
512
+ request_body=r.body,
513
+ sensitive_paths=sensitive_paths,
514
+ response_status_code=None,
515
+ response_headers={},
516
+ response_body=None,
517
+ duration_ms=None,
518
+ attempt_n=1,
519
+ final_attempt=True,
520
+ error_kind="preflight" if preflight_blocked else None,
521
+ dry_run=True,
522
+ preflight_blocked=preflight_blocked,
523
+ record_indices=list(r.record_indices),
524
+ applied=False,
525
+ explain=ctx.explain,
526
+ )
527
+ append_audit_jsonl(entry, path=log_dir / "audit.jsonl")
528
+ _ = preflight # currently consumed only via preflight_blocked
529
+
530
+
531
+ def _now_iso() -> str:
532
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
533
+
534
+
535
+ def _render_explain_or_dry_run(trace: ExplainTrace, ctx: RuntimeContext, *, stream: TextIO) -> None:
536
+ if ctx.output_format is OutputFormat.JSON:
537
+ print(render_explain_json(trace), file=stream)
538
+ elif ctx.output_format is OutputFormat.TABLE:
539
+ render_explain_rich(trace, stream=stream)
540
+ else:
541
+ # CSV/YAML/JSONL on dry-run → JSON to stdout (the formatters expect rows,
542
+ # not a structured trace). Consistent with spec §4.2.3 fallback rules.
543
+ print(render_explain_json(trace), file=stream)
544
+
545
+
546
+ def _send_one(
547
+ operation: Operation, request: ResolvedRequest, ctx: RuntimeContext
548
+ ) -> dict[str, Any]:
549
+ relative = operation.path.format(**request.path_vars)
550
+ indices = list(request.record_indices)
551
+ sensitive_paths = (
552
+ operation.request_body.sensitive_paths if operation.request_body is not None else ()
553
+ )
554
+ if operation.http_method is HttpMethod.POST:
555
+ return ctx.client.post(
556
+ relative,
557
+ json=request.body,
558
+ operation_id=operation.operation_id,
559
+ record_indices=indices,
560
+ sensitive_paths=sensitive_paths,
561
+ )
562
+ if operation.http_method is HttpMethod.PATCH:
563
+ return ctx.client.patch(
564
+ relative,
565
+ json=request.body,
566
+ operation_id=operation.operation_id,
567
+ record_indices=indices,
568
+ sensitive_paths=sensitive_paths,
569
+ )
570
+ if operation.http_method is HttpMethod.PUT:
571
+ return ctx.client.put(
572
+ relative,
573
+ json=request.body,
574
+ operation_id=operation.operation_id,
575
+ record_indices=indices,
576
+ sensitive_paths=sensitive_paths,
577
+ )
578
+ if operation.http_method is HttpMethod.DELETE:
579
+ return ctx.client.delete(
580
+ relative,
581
+ operation_id=operation.operation_id,
582
+ record_indices=indices,
583
+ sensitive_paths=sensitive_paths,
584
+ )
585
+ raise RuntimeError(f"unsupported write method: {operation.http_method}")
586
+
587
+
588
+ def _render_response(
589
+ operation: Operation,
590
+ response: dict[str, Any],
591
+ ctx: RuntimeContext,
592
+ *,
593
+ op_tag: str,
594
+ op_resource: str,
595
+ stream: TextIO,
596
+ is_delete: bool,
597
+ ) -> None:
598
+ if is_delete:
599
+ _render_delete_ok(ctx, stream=stream)
600
+ return
601
+ render(
602
+ response,
603
+ format=ctx.output_format,
604
+ columns=ctx.resolve_columns(op_tag, op_resource, operation),
605
+ stream=stream,
606
+ compact=ctx.compact,
607
+ )
608
+
609
+
610
+ def _render_delete_ok(ctx: RuntimeContext, *, stream: TextIO) -> None:
611
+ payload = {"deleted": True}
612
+ if ctx.output_format is OutputFormat.JSON:
613
+ print(_json.dumps(payload), file=stream)
614
+ else:
615
+ print("deleted", file=stream)
616
+
617
+
618
+ def _render_delete_already_absent(ctx: RuntimeContext, *, stream: TextIO) -> None:
619
+ payload = {"deleted": False, "reason": "already_absent"}
620
+ if ctx.output_format is OutputFormat.JSON:
621
+ print(_json.dumps(payload), file=stream)
622
+ else:
623
+ print("already absent (no change)", file=stream)
624
+
625
+
626
+ def _preflight_envelope(
627
+ operation: Operation, preflight: PreflightResult, *, applied: bool
628
+ ) -> ErrorEnvelope:
629
+ issues = [
630
+ {
631
+ "record_index": i.record_index,
632
+ "field_path": i.field_path,
633
+ "kind": i.kind,
634
+ "message": i.message,
635
+ "expected": i.expected,
636
+ }
637
+ for i in preflight.issues
638
+ ]
639
+ return ErrorEnvelope(
640
+ error="preflight validation failed",
641
+ type=ErrorType.VALIDATION,
642
+ operation_id=operation.operation_id,
643
+ details={"source": "preflight", "issues": issues, "applied": applied},
644
+ )
645
+
646
+
647
+ def _split_params(
648
+ operation: Operation, kwargs: dict[str, Any]
649
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
650
+ path_names = {p.name for p in operation.parameters if p.location is ParameterLocation.PATH}
651
+ query: dict[str, Any] = {}
652
+ path: dict[str, Any] = {}
653
+ for k, v in kwargs.items():
654
+ if v is None:
655
+ continue
656
+ if k in path_names:
657
+ path[k] = v
658
+ else:
659
+ query[k] = v
660
+ return query, path