ocmo-cli 0.8.19__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 (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,907 @@
1
+ """Generated command groups built from commands.yaml at import time.
2
+
3
+ For each unique action in commands.yaml that is NOT hand-written, a Click Group
4
+ is created (e.g. `get`, `create`, `delete`, ...). Within each group, a subcommand
5
+ is created for each resource type (e.g. `get config`, `get namespace`, ...).
6
+
7
+ Since commands.yaml is a committed file, this is equivalent to code generation
8
+ in terms of determinism and startup behaviour.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, cast
16
+
17
+ import click
18
+
19
+ from .._body_payload import (
20
+ BODY_PAYLOADS,
21
+ format_usage_error,
22
+ prepare_body_payload,
23
+ prepare_untag_body_payload,
24
+ sdk_path_for_body_payload,
25
+ validate_create_request,
26
+ validate_tag_request,
27
+ )
28
+ from .._click_groups import ResourceAliasGroup
29
+ from .._client import OcmoCtx
30
+ from .._item_output import (
31
+ emit_item_result,
32
+ item_output_format,
33
+ item_output_includes_token_in_payload,
34
+ uses_item_output,
35
+ )
36
+ from .._options import (
37
+ get_cast_output_option,
38
+ get_item_output_option,
39
+ namespace_option,
40
+ output_option,
41
+ tree_version_option,
42
+ )
43
+ from .._output_manifest import (
44
+ command_key as output_command_key,
45
+ )
46
+ from .._output_manifest import (
47
+ emit_command_output,
48
+ get_command_spec,
49
+ resolve_effective_format,
50
+ )
51
+ from .._sdk_dispatch import (
52
+ NO_ADDRESS_OPS as _NO_ADDRESS_OPS,
53
+ )
54
+ from .._sdk_dispatch import (
55
+ NON_TREE_ADDRESS_OPS as _NON_TREE_ADDRESS_OPS,
56
+ )
57
+ from .._sdk_dispatch import (
58
+ VERSION_ADDRESS_OPS as _VERSION_ADDRESS_OPS,
59
+ )
60
+ from .._sdk_dispatch import (
61
+ VERSION_FILTER_OPS as _VERSION_FILTER_OPS,
62
+ )
63
+ from .._sdk_dispatch import (
64
+ address_optional_for_command,
65
+ address_required_for_op,
66
+ build_sdk_call,
67
+ extra_params_for_ops,
68
+ pick_op_id,
69
+ )
70
+
71
+
72
+ def _op_scope(op_id: str, ops_meta: dict[str, Any]) -> str:
73
+ return str(ops_meta.get(op_id, {}).get("scope", "namespace"))
74
+
75
+
76
+ def _command_is_client_scoped(op_ids: list[str], ops_meta: dict[str, Any]) -> bool:
77
+ """True when every mapped operation for this command is client-scoped."""
78
+ if not op_ids:
79
+ return False
80
+ return all(_op_scope(op_id, ops_meta) == "client" for op_id in op_ids)
81
+
82
+
83
+ def _reject_namespace_if_client_scoped(
84
+ *,
85
+ op_ids: list[str],
86
+ ops_meta: dict[str, Any],
87
+ namespace_explicit: bool,
88
+ ) -> None:
89
+ from .._exit import USAGE_ERROR # deferred
90
+
91
+ if not _command_is_client_scoped(op_ids, ops_meta):
92
+ return
93
+ if not namespace_explicit:
94
+ return
95
+ print(
96
+ "Error: -n/--namespace is not valid for this command.",
97
+ file=sys.stderr,
98
+ )
99
+ raise SystemExit(USAGE_ERROR)
100
+
101
+
102
+ def _namespace_explicit_on_cli(
103
+ click_ctx: click.Context,
104
+ *,
105
+ use_namespace_option: bool,
106
+ ) -> bool:
107
+ """True when -n/--namespace was passed on the CLI (not from env/config alone)."""
108
+ from click.core import ParameterSource
109
+
110
+ if use_namespace_option:
111
+ source = click_ctx.get_parameter_source("namespace")
112
+ if source == ParameterSource.COMMANDLINE:
113
+ return True
114
+ ctx: click.Context | None = click_ctx
115
+ while ctx is not None:
116
+ try:
117
+ source = ctx.get_parameter_source("namespace")
118
+ except LookupError:
119
+ source = None
120
+ if source == ParameterSource.COMMANDLINE:
121
+ return True
122
+ ctx = ctx.parent
123
+ return False
124
+
125
+
126
+ from .._generated_registry import action_help as _action_help # noqa: E402
127
+
128
+ _RESOURCE_HELP: dict[str, str] = {
129
+ "namespace": "Namespace metadata.",
130
+ "item": "A tree item (config, template, secret, resolver, or folder). "
131
+ "Address may include @version or @tag (e.g. app/web@2, app/web@stable).",
132
+ "config": "A configuration item.",
133
+ "template": "A template item.",
134
+ "secret": "A secret item.",
135
+ "resolver": "A resolver item.",
136
+ "folder": "A folder in the tree.",
137
+ "version": "Version history for an item.",
138
+ "lock": "An active edit lock.",
139
+ "audit": "Audit log events.",
140
+ "globalpermission": "A global permission rule.",
141
+ "cast": "Available resolve cast formats.",
142
+ "tree": "Tree navigation and search results.",
143
+ "parameters": "Dynamic resolve parameters.",
144
+ "draft": "A resolve draft.",
145
+ "token": "A resolver authentication token.",
146
+ }
147
+
148
+ _ACTION_RESOURCE_HELP: dict[tuple[str, str], str] = {
149
+ ("get", "namespace"): (
150
+ "List namespaces or show one. ADDRESS is the namespace name (omit to list all). "
151
+ "Default table shows name and description; use -o wide for all fields."
152
+ ),
153
+ ("create", "namespace"): ("Create a namespace. ADDRESS is the name; use --description for details."),
154
+ ("update", "namespace"): "Update namespace metadata. ADDRESS is the namespace name.",
155
+ ("delete", "namespace"): "Delete a namespace. ADDRESS is the namespace name.",
156
+ ("get", "version"): (
157
+ "List version history for a tree item. ADDRESS is the item path, optionally "
158
+ "with @version or @tag (e.g. app/web@26, app/web@stable). Use --limit to "
159
+ "cap results and --tagged-only to omit untagged versions."
160
+ ),
161
+ ("get", "item"): (
162
+ "Show a tree item or list items in the namespace. ADDRESS is the item path "
163
+ "with optional @version or @tag (omit to list). Use --limit to cap list "
164
+ "results and --type to filter by item type (repeatable)."
165
+ ),
166
+ ("get", "cast"): (
167
+ "List resolve cast formats or show one format's option schema. "
168
+ "ADDRESS is the format name (e.g. yaml, json; omit to list all)."
169
+ ),
170
+ ("get", "audit"): (
171
+ "List audit events or show one. ADDRESS is the event id " "(full UUID or a unique hex prefix; omit to list)."
172
+ ),
173
+ ("get", "lock"): "List locks or show one. ADDRESS is the tree path (omit to list all).",
174
+ ("create", "lock"): ("Create a lock. ADDRESS is the tree path; use --reason and optionally --expires-at."),
175
+ ("update", "lock"): ("Update a lock. ADDRESS is the tree path; use --reason and optionally --expires-at."),
176
+ ("delete", "lock"): "Remove a lock. ADDRESS is the tree path.",
177
+ ("get", "globalpermission"): (
178
+ "List global permission rules or show one. "
179
+ "ADDRESS is the rule id (user-defined id or UUID; omit to list all)."
180
+ ),
181
+ ("create", "globalpermission"): (
182
+ "Create a rule. ADDRESS is optional rule id; use -f for the rule body "
183
+ "and --position to reorder after create."
184
+ ),
185
+ ("update", "globalpermission"): (
186
+ "Update a rule. ADDRESS is the rule id (user-defined id or UUID); use -f for the rule body."
187
+ ),
188
+ ("delete", "globalpermission"): ("Delete a rule. ADDRESS is the rule id (user-defined id or UUID)."),
189
+ ("move", "globalpermission"): (
190
+ "Reorder a rule. ADDRESS is the rule id (user-defined id or UUID); "
191
+ "use --position to set the new sort position."
192
+ ),
193
+ ("rotate", "token"): (
194
+ "Rotate a resolver access token. ADDRESS is the resolver path; "
195
+ "--token-number selects which token slot to rotate."
196
+ ),
197
+ }
198
+
199
+
200
+ def _resource_command_help(action: str, resource: str) -> str:
201
+ return _ACTION_RESOURCE_HELP.get(
202
+ (action, resource),
203
+ _RESOURCE_HELP.get(resource, f"{action.capitalize()} {resource}."),
204
+ )
205
+
206
+
207
+ # Resource type aliases (canonical → set of aliases)
208
+ from .._resource_aliases import RESOURCE_ALIASES # noqa: E402
209
+
210
+
211
+ def _load_commands_yaml() -> dict[str, Any]:
212
+ from ocmo_cli._commands_map import OPERATIONS # static Python dict, no YAML at startup
213
+
214
+ return OPERATIONS
215
+
216
+
217
+ def _entries_for_action(action: str) -> list[tuple[str, str, dict[str, Any]]]:
218
+ operations = _load_commands_yaml()
219
+ if action == "untag":
220
+ set_tag_cfg = operations.get("set_tag")
221
+ if isinstance(set_tag_cfg, dict) and not set_tag_cfg.get("hand_written") and not set_tag_cfg.get("skip"):
222
+ return [("set_tag", "item", dict(set_tag_cfg))]
223
+ entries: list[tuple[str, str, dict[str, Any]]] = []
224
+ for op_id, config in operations.items():
225
+ if not isinstance(config, dict):
226
+ continue
227
+ if config.get("hand_written") or config.get("skip"):
228
+ continue
229
+ entry_action = config.get("action")
230
+ resource = config.get("resource")
231
+ if entry_action != action or not resource:
232
+ continue
233
+ # Hand-written ``resolve`` command owns config resolve; only draft/parameters are generated.
234
+ if entry_action == "resolve":
235
+ continue
236
+ entries.append((op_id, resource, config))
237
+ return entries
238
+
239
+
240
+ def build_action_group(action: str) -> click.Group:
241
+ """Build one generated Click command group."""
242
+ entries = _entries_for_action(action)
243
+ if not entries:
244
+ raise ValueError(f"no generated operations for action {action!r}")
245
+ return _build_action_group(action, entries)
246
+
247
+
248
+ def build_generated_groups() -> list[click.Group]:
249
+ """Build and return all generated Click command groups."""
250
+ from .._generated_registry import generated_action_names
251
+
252
+ return [build_action_group(action) for action in generated_action_names()]
253
+
254
+
255
+ def build_resolve_subcommands() -> list[click.Command]:
256
+ """Subcommands merged into the hand-written ``resolve`` group."""
257
+ operations = _load_commands_yaml()
258
+ by_resource: dict[str, list[tuple[str, dict[str, Any]]]] = {}
259
+ for op_id, config in operations.items():
260
+ if not isinstance(config, dict) or config.get("hand_written") or config.get("skip"):
261
+ continue
262
+ if config.get("action") != "resolve":
263
+ continue
264
+ resource = config.get("resource")
265
+ if not resource:
266
+ continue
267
+ by_resource.setdefault(resource, []).append((op_id, config))
268
+
269
+ commands: list[click.Command] = []
270
+ for resource, resource_entries in sorted(by_resource.items()):
271
+ op_ids = [op_id for op_id, _ in resource_entries]
272
+ config = resource_entries[0][1]
273
+ commands.append(_build_resource_command("resolve", resource, op_ids, config))
274
+ return commands
275
+
276
+
277
+ def _build_action_group(action: str, entries: list[tuple[str, str, dict[str, Any]]]) -> click.Group:
278
+ """Build a Click Group for an action (e.g. 'get', 'create', 'delete')."""
279
+
280
+ group_help = _action_help(action)
281
+
282
+ @click.group(action, cls=ResourceAliasGroup, help=group_help)
283
+ @click.pass_context
284
+ def action_group(ctx: click.Context) -> None:
285
+ pass
286
+
287
+ # Group operations by resource (multiple op_ids may share action+resource).
288
+ by_resource: dict[str, list[tuple[str, dict[str, Any]]]] = {}
289
+ for op_id, resource, config in entries:
290
+ by_resource.setdefault(resource, []).append((op_id, config))
291
+
292
+ for resource, resource_entries in sorted(by_resource.items()):
293
+ op_ids = [op_id for op_id, _ in resource_entries]
294
+ config = resource_entries[0][1]
295
+ cmd = _build_resource_command(action, resource, op_ids, config)
296
+ action_group.add_resource_command(
297
+ cmd,
298
+ canonical=resource,
299
+ aliases=RESOURCE_ALIASES.get(resource, []),
300
+ )
301
+
302
+ return action_group
303
+
304
+
305
+ def _build_resource_command(action: str, resource: str, op_ids: list[str], config: dict[str, Any]) -> click.Command:
306
+ """Build a Click Command for action+resource, wired to the SDK method."""
307
+
308
+ confirm_mode = config.get("confirm")
309
+
310
+ is_rotate_token = action == "rotate" and resource == "token"
311
+ is_mutating = action in ("create", "update", "delete", "move", "copy", "tag", "untag", "rotate", "propagate")
312
+
313
+ resource_help = _resource_command_help(action, resource)
314
+ extra_specs = extra_params_for_ops(op_ids)
315
+ supports_version_address = any(op_id in _VERSION_ADDRESS_OPS for op_id in op_ids) or any(
316
+ op_id in _VERSION_FILTER_OPS for op_id in op_ids
317
+ )
318
+ supports_file_body = (
319
+ action not in ("tag", "untag", "delete", "propagate", "move", "copy")
320
+ and not is_rotate_token
321
+ and not (action == "create" and resource in ("namespace", "lock"))
322
+ and not (action == "update" and resource == "lock")
323
+ )
324
+ output_key = output_command_key(action, resource)
325
+ address_required = not address_optional_for_command(
326
+ op_ids,
327
+ action=action,
328
+ resource=resource,
329
+ )
330
+ get_item_list_mode = action == "get" and resource == "item"
331
+ get_cast_dual_mode = action == "get" and resource == "cast"
332
+ ops_meta = _load_ops_yaml()
333
+ use_namespace_option = not _command_is_client_scoped(op_ids, ops_meta)
334
+
335
+ @click.pass_context
336
+ def resource_cmd(
337
+ click_ctx: click.Context,
338
+ /,
339
+ address: str | None,
340
+ output_fmt: str | None = None,
341
+ field: str | None = None,
342
+ version_flag: str | None = None,
343
+ list_limit: int | None = None,
344
+ item_types: tuple[str, ...] = (),
345
+ dry_run: bool = False,
346
+ yes: bool = False,
347
+ file_path: str | None = None,
348
+ _op_ids: list[str] = op_ids,
349
+ _action: str = action,
350
+ _resource: str = resource,
351
+ _confirm_mode: str | None = confirm_mode,
352
+ _extra_specs: list[Any] = extra_specs,
353
+ **_ignored: Any,
354
+ ) -> None:
355
+ ocmo_ctx = click_ctx.obj
356
+ namespace = click_ctx.params.get("namespace")
357
+ namespace_explicit = _namespace_explicit_on_cli(
358
+ click_ctx,
359
+ use_namespace_option=use_namespace_option,
360
+ )
361
+ sdk_extra = {
362
+ spec.sdk_name: click_ctx.params.get(spec.sdk_name)
363
+ for spec in _extra_specs
364
+ if click_ctx.params.get(spec.sdk_name) is not None
365
+ }
366
+ if _action == "get" and _resource == "item":
367
+ if list_limit is not None:
368
+ sdk_extra["limit"] = list_limit
369
+ _execute_generated(
370
+ ctx=ocmo_ctx,
371
+ op_ids=_op_ids,
372
+ action=_action,
373
+ resource=_resource,
374
+ address=address,
375
+ namespace=namespace,
376
+ output_fmt=output_fmt,
377
+ field=field,
378
+ version_flag=version_flag,
379
+ dry_run=dry_run or (ocmo_ctx and ocmo_ctx.dry_run),
380
+ yes=yes or (ocmo_ctx and ocmo_ctx.yes),
381
+ file_path=file_path,
382
+ confirm_mode=_confirm_mode,
383
+ sdk_extra=sdk_extra,
384
+ namespace_explicit=namespace_explicit,
385
+ item_types=item_types,
386
+ )
387
+
388
+ # Apply Click decorators inside-out: options/arguments first, command last.
389
+ for spec in reversed(extra_specs):
390
+ opt_name = f"--{spec.click_name}"
391
+ if spec.is_flag:
392
+ resource_cmd = click.option(
393
+ opt_name,
394
+ spec.sdk_name,
395
+ is_flag=True,
396
+ default=False,
397
+ help=spec.help,
398
+ )(resource_cmd)
399
+ elif spec.type_ is int:
400
+ token_required = is_rotate_token and spec.sdk_name == "token_number"
401
+ resource_cmd = click.option(
402
+ opt_name,
403
+ spec.sdk_name,
404
+ type=int,
405
+ required=token_required,
406
+ default=None,
407
+ help=spec.help,
408
+ )(resource_cmd)
409
+ elif spec.type_ is float:
410
+ resource_cmd = click.option(
411
+ opt_name,
412
+ spec.sdk_name,
413
+ type=float,
414
+ default=None,
415
+ help=spec.help,
416
+ )(resource_cmd)
417
+ else:
418
+ tag_required = action in ("tag", "untag") and spec.sdk_name == "tag"
419
+ if tag_required:
420
+ resource_cmd = click.option(
421
+ opt_name,
422
+ spec.sdk_name,
423
+ required=True,
424
+ help=spec.help,
425
+ )(resource_cmd)
426
+ else:
427
+ resource_cmd = click.option(
428
+ opt_name,
429
+ spec.sdk_name,
430
+ default=None,
431
+ help=spec.help,
432
+ )(resource_cmd)
433
+ if not is_rotate_token:
434
+ resource_cmd = click.option(
435
+ "--field",
436
+ default=None,
437
+ metavar="PATH",
438
+ help="Extract a specific dot-path field from the response (e.g. version_data.data).",
439
+ )(resource_cmd)
440
+ if is_mutating:
441
+ if supports_file_body:
442
+ resource_cmd = click.option(
443
+ "-f", "--file", "file_path", default=None, help="YAML/JSON body from file ('-' for stdin)."
444
+ )(resource_cmd)
445
+ if confirm_mode == "destructive":
446
+ resource_cmd = click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompts.")(
447
+ resource_cmd
448
+ )
449
+ resource_cmd = click.option("--dry-run", is_flag=True, default=False, help="Print plan without sending.")(
450
+ resource_cmd
451
+ )
452
+ if get_item_list_mode:
453
+ from .._get_item_list import GET_ITEM_TYPE_CHOICES
454
+
455
+ resource_cmd = click.option(
456
+ "--limit",
457
+ "list_limit",
458
+ type=int,
459
+ default=None,
460
+ help="Maximum number of items to return.",
461
+ )(resource_cmd)
462
+ resource_cmd = click.option(
463
+ "--type",
464
+ "item_types",
465
+ multiple=True,
466
+ type=click.Choice(GET_ITEM_TYPE_CHOICES, case_sensitive=False),
467
+ help=("Filter by item type (repeatable). " "Default: config, template, secret, resolver."),
468
+ )(resource_cmd)
469
+ resource_cmd = get_item_output_option()(resource_cmd)
470
+ elif get_cast_dual_mode:
471
+ resource_cmd = get_cast_output_option()(resource_cmd)
472
+ elif not is_rotate_token:
473
+ resource_cmd = output_option(output_key)(resource_cmd)
474
+ if supports_version_address:
475
+ resource_cmd = tree_version_option()(resource_cmd)
476
+ if use_namespace_option:
477
+ resource_cmd = namespace_option()(resource_cmd)
478
+ resource_cmd = click.argument("address", required=address_required)(resource_cmd)
479
+ return click.command(resource, help=resource_help)(resource_cmd)
480
+
481
+
482
+ def _execute_generated(
483
+ *,
484
+ ctx: OcmoCtx,
485
+ op_ids: list[str],
486
+ action: str,
487
+ resource: str,
488
+ address: str | None,
489
+ namespace: str | None,
490
+ output_fmt: str | None,
491
+ field: str | None = None,
492
+ version_flag: str | None = None,
493
+ dry_run: bool,
494
+ yes: bool,
495
+ file_path: str | None,
496
+ confirm_mode: str | None,
497
+ sdk_extra: dict[str, Any] | None = None,
498
+ namespace_explicit: bool = False,
499
+ item_types: tuple[str, ...] = (),
500
+ ) -> None:
501
+ from .._address import AddressError, parse_address, parse_simple_address # deferred
502
+ from .._errors import handle_sdk_error # deferred
503
+ from .._exit import USAGE_ERROR # deferred
504
+ from .._output import as_dict, extract_field, sanitize_for_output, status # deferred # deferred
505
+ from .._output import confirm as _confirm
506
+ from .._resolver_output import print_resolver_token # deferred
507
+
508
+ list_mode = action == "get" and resource == "item" and not address
509
+ cast_list_mode = action == "get" and resource == "cast" and not address
510
+ cast_show_mode = action == "get" and resource == "cast" and bool(address)
511
+ cast_format: str | None = None
512
+
513
+ try:
514
+ ops_meta = _load_ops_yaml()
515
+ _reject_namespace_if_client_scoped(
516
+ op_ids=op_ids,
517
+ ops_meta=ops_meta,
518
+ namespace_explicit=namespace_explicit,
519
+ )
520
+ if list_mode:
521
+ op_id = "search_root"
522
+ elif cast_list_mode or cast_show_mode:
523
+ op_id = "list_cast_formats"
524
+ else:
525
+ op_id = pick_op_id(op_ids, address=address, namespace=namespace, ops_meta=ops_meta)
526
+
527
+ path: str | None = None
528
+ version: str | None = None
529
+ audit_show_mode = action == "get" and resource == "audit" and bool(address)
530
+ if address and not (cast_list_mode or cast_show_mode or audit_show_mode):
531
+ parser = parse_simple_address if op_id in _NON_TREE_ADDRESS_OPS else parse_address
532
+ try:
533
+ path, version = parser(address, version_flag=version_flag)
534
+ except AddressError as e:
535
+ print(f"Error: {e}", file=sys.stderr)
536
+ raise SystemExit(USAGE_ERROR)
537
+ elif cast_show_mode:
538
+ assert address is not None
539
+ try:
540
+ cast_format, version = parse_simple_address(
541
+ address,
542
+ version_flag=version_flag,
543
+ )
544
+ except AddressError as e:
545
+ print(f"Error: {e}", file=sys.stderr)
546
+ raise SystemExit(USAGE_ERROR)
547
+ if version:
548
+ print(
549
+ "Error: cast format ADDRESS does not support @version or --version.",
550
+ file=sys.stderr,
551
+ )
552
+ raise SystemExit(USAGE_ERROR)
553
+ elif audit_show_mode:
554
+ assert address is not None
555
+ try:
556
+ path, version = parse_simple_address(
557
+ address,
558
+ version_flag=version_flag,
559
+ )
560
+ except AddressError as e:
561
+ print(f"Error: {e}", file=sys.stderr)
562
+ raise SystemExit(USAGE_ERROR)
563
+ if version:
564
+ print(
565
+ "Error: audit event ADDRESS does not support @version or --version.",
566
+ file=sys.stderr,
567
+ )
568
+ raise SystemExit(USAGE_ERROR)
569
+
570
+ # Some operations use query-only parameters and ignore tree addresses.
571
+ if op_id in _NO_ADDRESS_OPS:
572
+ path = None
573
+ version = None
574
+
575
+ if path is None and address_required_for_op(
576
+ op_id,
577
+ action=action,
578
+ resource=resource,
579
+ ):
580
+ print(
581
+ f"Error: ADDRESS is required for ocmo {action} {resource}.",
582
+ file=sys.stderr,
583
+ )
584
+ raise SystemExit(USAGE_ERROR)
585
+
586
+ merged_extra = dict(sdk_extra or {})
587
+ if list_mode:
588
+ from .._get_item_list import prepare_get_item_list_extra # deferred
589
+
590
+ merged_extra = prepare_get_item_list_extra(merged_extra, item_types=item_types)
591
+ if op_id == "list_item_versions":
592
+ from .._version_output import apply_version_address_query # deferred
593
+
594
+ apply_version_address_query(merged_extra, version)
595
+ version = None
596
+ gp_create_position: float | None = None
597
+ if op_id == "create_global_permission":
598
+ raw_position = merged_extra.pop("position", None)
599
+ if raw_position is not None:
600
+ gp_create_position = float(raw_position)
601
+ content: str | None = None
602
+ if file_path:
603
+ content = sys.stdin.read() if file_path == "-" else Path(file_path).read_text()
604
+
605
+ body_payload: dict[str, Any] | None = None
606
+ untag_mode = action == "untag" and op_id == "set_tag"
607
+ if untag_mode:
608
+ tag_name = merged_extra.pop("tag", None)
609
+ validate_tag_request("set_tag", action="untag", extra={"tag": tag_name})
610
+ body_payload = prepare_untag_body_payload(str(tag_name))
611
+ merged_extra = {}
612
+ elif op_id in BODY_PAYLOADS:
613
+ try:
614
+ body_payload = prepare_body_payload(
615
+ op_id,
616
+ address=path,
617
+ content=content,
618
+ extra=merged_extra,
619
+ address_version=version,
620
+ )
621
+ except ValueError as exc:
622
+ print(f"Error: {format_usage_error(exc)}", file=sys.stderr)
623
+ raise SystemExit(USAGE_ERROR)
624
+ if action == "create" or op_id == "replace_lock":
625
+ validate_create_request(op_id, address=path, payload=body_payload)
626
+ content = None
627
+ merged_extra = {}
628
+
629
+ if op_id == "set_tag":
630
+ validate_tag_request(op_id, payload=body_payload, extra=merged_extra)
631
+
632
+ delete_preview = False
633
+ if op_id == "delete_item":
634
+ delete_preview = bool(merged_extra.pop("preview", False))
635
+
636
+ if dry_run:
637
+ args, kwargs = build_sdk_call(
638
+ op_id,
639
+ path=sdk_path_for_body_payload(op_id, path, body_payload),
640
+ version=version,
641
+ content=content,
642
+ extra=merged_extra,
643
+ )
644
+ if body_payload is not None:
645
+ kwargs["body"] = body_payload
646
+ from .._dry_run import emit_dry_run_plan, format_generated_dry_run # deferred
647
+
648
+ display_ns = namespace if isinstance(namespace, str) and namespace else None
649
+ if display_ns is None and ctx is not None:
650
+ ctx_ns = getattr(ctx, "namespace", None)
651
+ if isinstance(ctx_ns, str) and ctx_ns:
652
+ display_ns = ctx_ns
653
+ client_scope = ops_meta.get(op_id, {}).get("scope") == "client"
654
+ emit_dry_run_plan(
655
+ format_generated_dry_run(
656
+ op_id=op_id,
657
+ action=action,
658
+ resource=resource,
659
+ path=path,
660
+ version=version,
661
+ namespace=display_ns,
662
+ args=args,
663
+ kwargs=kwargs,
664
+ client_scope=client_scope,
665
+ file_path=file_path,
666
+ gp_create_position=gp_create_position,
667
+ cast_format=cast_format,
668
+ )
669
+ )
670
+ return
671
+
672
+ if confirm_mode == "destructive" and not yes and not delete_preview:
673
+ confirm_message = _destructive_confirm_message(
674
+ op_id=op_id,
675
+ action=action,
676
+ resource=resource,
677
+ path=path,
678
+ version=version,
679
+ )
680
+ if not _confirm(confirm_message, yes=False):
681
+ status("Aborted.")
682
+ raise SystemExit(0)
683
+
684
+ args, kwargs = build_sdk_call(
685
+ op_id,
686
+ path=sdk_path_for_body_payload(op_id, path, body_payload),
687
+ version=version,
688
+ content=content,
689
+ extra=merged_extra,
690
+ )
691
+ if op_id == "delete_item":
692
+ kwargs["preview"] = delete_preview
693
+ if body_payload is not None:
694
+ kwargs["body"] = body_payload
695
+ result = _call_sdk_method(ctx, op_id, namespace, args, kwargs)
696
+
697
+ if op_id == "set_tag" and path is not None:
698
+ from .._version_output import emit_item_versions_output
699
+
700
+ ns_view = ctx.namespace_view(namespace)
701
+ emit_item_versions_output(
702
+ ns_view,
703
+ path,
704
+ version=version,
705
+ output_fmt=output_fmt,
706
+ ctx_fmt=ctx.output if ctx else None,
707
+ field=field,
708
+ )
709
+ return
710
+
711
+ if op_id == "delete_item" and result is not None:
712
+ from .._delete_item_output import emit_delete_item_output
713
+
714
+ if field:
715
+ data = sanitize_for_output(as_dict(result, fallback_vars=False) or result)
716
+ extract_field(data, field)
717
+ else:
718
+ emit_delete_item_output(
719
+ result,
720
+ target_path=path,
721
+ version=version,
722
+ output_fmt=output_fmt,
723
+ ctx_fmt=ctx.output if ctx else None,
724
+ )
725
+ return
726
+
727
+ if op_id == "rotate_resolver_token":
728
+ if result is None or not print_resolver_token(result):
729
+ print(
730
+ "Error: resolver token was not returned by the API.",
731
+ file=sys.stderr,
732
+ )
733
+ raise SystemExit(USAGE_ERROR)
734
+ return
735
+
736
+ if op_id == "create_global_permission" and gp_create_position is not None and result is not None:
737
+ rule_id = _created_resource_label(result)
738
+ current_position = getattr(result, "position", None)
739
+ if current_position != gp_create_position and rule_id is not None:
740
+ result = _call_sdk_method(
741
+ ctx,
742
+ "move_global_permission",
743
+ namespace,
744
+ [rule_id],
745
+ {"body": {"position": gp_create_position}},
746
+ )
747
+
748
+ if action in ("create", "update"):
749
+ label = path or _created_resource_label(result)
750
+ if label:
751
+ verb = "Created" if action == "create" else "Updated"
752
+ status(f"{verb} {resource} {label!r}.")
753
+
754
+ output_key = (
755
+ "get item list"
756
+ if list_mode
757
+ else "get cast list"
758
+ if cast_list_mode
759
+ else output_command_key(action, resource)
760
+ )
761
+
762
+ if result is not None and (cast_list_mode or cast_show_mode):
763
+ from .._get_cast_output import CastFormatNotFoundError, cast_list_rows, cast_show_payload
764
+
765
+ if cast_list_mode:
766
+ data = cast_list_rows(as_dict(result, fallback_vars=False) or result)
767
+ else:
768
+ try:
769
+ data = cast_show_payload(
770
+ as_dict(result, fallback_vars=False) or result,
771
+ cast_format or "",
772
+ )
773
+ except CastFormatNotFoundError as exc:
774
+ print(f"Error: {exc}", file=sys.stderr)
775
+ raise SystemExit(USAGE_ERROR) from exc
776
+ if field:
777
+ extract_field(data, field)
778
+ else:
779
+ emit_command_output(
780
+ output_key,
781
+ data,
782
+ output_fmt,
783
+ ctx_fmt=ctx.output if ctx else None,
784
+ )
785
+ return
786
+
787
+ if result is not None and uses_item_output(op_id, action, resource, result):
788
+ fmt = item_output_format(
789
+ output_fmt,
790
+ ctx.output if ctx else None,
791
+ command_key=output_key,
792
+ )
793
+ if field:
794
+ data = sanitize_for_output(as_dict(result, fallback_vars=False) or result)
795
+ extract_field(data, field)
796
+ else:
797
+ emit_item_result(
798
+ result,
799
+ fmt,
800
+ no_color=bool(ctx and ctx.no_color),
801
+ resource=resource,
802
+ )
803
+ if op_id == "create_resolver" and not item_output_includes_token_in_payload(fmt, field):
804
+ print_resolver_token(result)
805
+ return
806
+
807
+ if result is not None:
808
+ if isinstance(result, list):
809
+ data = [sanitize_for_output(as_dict(r)) for r in result]
810
+ else:
811
+ data = sanitize_for_output(as_dict(result, fallback_vars=False) or result)
812
+
813
+ if field:
814
+ extract_field(data, field)
815
+ else:
816
+ emit_command_output(
817
+ output_key,
818
+ data,
819
+ output_fmt,
820
+ ctx_fmt=ctx.output if ctx else None,
821
+ )
822
+ if op_id == "create_resolver" and not _structured_output_includes_token(
823
+ resolve_effective_format(
824
+ output_fmt,
825
+ ctx.output if ctx else None,
826
+ get_command_spec(output_key),
827
+ ),
828
+ field,
829
+ ):
830
+ print_resolver_token(result)
831
+
832
+ except KeyboardInterrupt:
833
+ raise SystemExit(130)
834
+ except SystemExit:
835
+ raise
836
+ except Exception as exc:
837
+ handle_sdk_error(exc)
838
+
839
+
840
+ def _destructive_confirm_message(
841
+ *,
842
+ op_id: str,
843
+ action: str,
844
+ resource: str,
845
+ path: str | None,
846
+ version: str | None,
847
+ ) -> str:
848
+ if op_id == "delete_item" and version:
849
+ return f"This will delete version {version} for item {path!r}. Continue?"
850
+ return f"This will {action} {resource} {path!r}. Continue?"
851
+
852
+
853
+ def _created_resource_label(result: Any) -> str | None:
854
+ for key in ("name", "path", "id"):
855
+ value = getattr(result, key, None)
856
+ if value is not None and value != "":
857
+ return str(value)
858
+ return None
859
+
860
+
861
+ def _call_sdk_method(
862
+ ctx: OcmoCtx,
863
+ op_id: str,
864
+ namespace: str | None,
865
+ args: list[Any],
866
+ kwargs: dict[str, Any],
867
+ ) -> Any:
868
+ """Dispatch to the correct SDK method based on operation scope."""
869
+ ops_yaml = _load_ops_yaml()
870
+ scope = ops_yaml.get(op_id, {}).get("scope", "namespace")
871
+
872
+ if scope == "client":
873
+ client = ctx.client()
874
+ method = getattr(client, op_id, None)
875
+ if method is None:
876
+ print(f"Error: SDK method {op_id!r} not found on client.", file=sys.stderr)
877
+ raise SystemExit(1)
878
+ return method(*args, **kwargs)
879
+
880
+ view = ctx.namespace_view(namespace)
881
+ method = getattr(view, op_id, None)
882
+ if method is None:
883
+ print(f"Error: SDK method {op_id!r} not found on namespace view.", file=sys.stderr)
884
+ raise SystemExit(1)
885
+ return method(*args, **kwargs)
886
+
887
+
888
+ def _structured_output_includes_token(fmt: str, field: str | None) -> bool:
889
+ """True when stdout already carries the resolver token in structured output."""
890
+ if field:
891
+ return field in ("token1", "token")
892
+ if fmt in ("yaml", "json", "table"):
893
+ return True
894
+ return fmt.startswith("jsonpath=token") or fmt.startswith("jsonpath=token1")
895
+
896
+
897
+ def _load_ops_yaml() -> dict[str, Any]:
898
+ from pathlib import Path
899
+
900
+ import yaml # deferred
901
+
902
+ ops_path = Path(__file__).parent.parent.parent.parent / "sdk" / "operations.yaml"
903
+ if not ops_path.exists():
904
+ return {}
905
+ with ops_path.open() as f:
906
+ data = yaml.safe_load(f) or {}
907
+ return cast(dict[str, Any], data.get("operations", {}))