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,203 @@
1
+ """Parse and validate structured (-f) bodies for SDK BODY_PAYLOAD operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ __all__ = [
9
+ "BODY_PAYLOADS",
10
+ "format_usage_error",
11
+ "merge_tag_address_version",
12
+ "parse_structured_content",
13
+ "prepare_body_payload",
14
+ "prepare_untag_body_payload",
15
+ "sdk_path_for_body_payload",
16
+ "validate_create_request",
17
+ "validate_tag_request",
18
+ ]
19
+
20
+ from ._exit import USAGE_ERROR
21
+ from ._sdk_dispatch import address_keyword_for_op
22
+
23
+ try:
24
+ from ocmo._facade_meta import BODY_PAYLOADS
25
+ except ImportError: # pragma: no cover - CLI tests may run without SDK on path quirks
26
+ BODY_PAYLOADS = {}
27
+
28
+
29
+ _CREATE_REQUIREMENTS: dict[str, tuple[tuple[str, ...], str]] = {
30
+ "create_namespace": (
31
+ ("name",),
32
+ "create namespace requires a name (ADDRESS).",
33
+ ),
34
+ "create_global_permission": (
35
+ ("namespace",),
36
+ "create global permission requires namespace in -f " "(and optionally rule id via ADDRESS).",
37
+ ),
38
+ "create_lock": (
39
+ ("reason",),
40
+ "create lock requires --reason with a lock rationale.",
41
+ ),
42
+ "replace_lock": (
43
+ ("reason",),
44
+ "update lock requires --reason with a lock rationale.",
45
+ ),
46
+ }
47
+
48
+ _ADDRESS_REQUIRED_OPS = frozenset({"create_lock"})
49
+
50
+ # BODY_PAYLOAD ops where ADDRESS is a positional tree path, not a body field.
51
+ _BODY_PAYLOAD_PATH_ARG_OPS = frozenset(
52
+ {
53
+ "create_lock",
54
+ "replace_lock",
55
+ "rotate_resolver_token",
56
+ "set_tag",
57
+ }
58
+ )
59
+
60
+ # Extra CLI kwargs that must not be merged into the rule/document body.
61
+ _BODY_PAYLOAD_EXCLUDED_EXTRA: dict[str, frozenset[str]] = {
62
+ "create_global_permission": frozenset({"position"}),
63
+ }
64
+
65
+
66
+ def parse_structured_content(content: str) -> dict[str, Any]:
67
+ """Parse YAML or JSON file/stdin content into an object dict."""
68
+ import yaml # deferred
69
+
70
+ text = content.strip()
71
+ if not text:
72
+ return {}
73
+ try:
74
+ data = json.loads(text)
75
+ except json.JSONDecodeError:
76
+ data = yaml.safe_load(text)
77
+ if data is None:
78
+ return {}
79
+ if not isinstance(data, dict):
80
+ raise ValueError("Body must be a YAML/JSON object (mapping), not a scalar or list.")
81
+ return data
82
+
83
+
84
+ def prepare_body_payload(
85
+ op_id: str,
86
+ *,
87
+ address: str | None,
88
+ content: str | None,
89
+ extra: dict[str, Any] | None = None,
90
+ address_version: str | None = None,
91
+ ) -> dict[str, Any]:
92
+ """Build a dict suitable for SDK ``body=`` on BODY_PAYLOAD create/update ops."""
93
+ if op_id not in BODY_PAYLOADS:
94
+ return {}
95
+
96
+ payload: dict[str, Any] = {}
97
+ if content:
98
+ payload = parse_structured_content(content)
99
+
100
+ if address is not None and op_id not in _BODY_PAYLOAD_PATH_ARG_OPS:
101
+ address_key = address_keyword_for_op(op_id)
102
+ payload.setdefault(address_key, address)
103
+
104
+ if extra:
105
+ excluded = _BODY_PAYLOAD_EXCLUDED_EXTRA.get(op_id, frozenset())
106
+ for key, value in extra.items():
107
+ if key in excluded:
108
+ continue
109
+ if value is not None and value is not False:
110
+ payload.setdefault(key, value)
111
+
112
+ if op_id == "set_tag":
113
+ payload = merge_tag_address_version(payload, address_version)
114
+
115
+ return payload
116
+
117
+
118
+ def prepare_untag_body_payload(tag: str) -> dict[str, Any]:
119
+ """POST body to remove a tag (``set_tag`` with ``version: null``)."""
120
+ return {"tag": tag, "version": None}
121
+
122
+
123
+ def validate_tag_request(
124
+ op_id: str,
125
+ *,
126
+ action: str | None = None,
127
+ payload: dict[str, Any] | None = None,
128
+ extra: dict[str, Any] | None = None,
129
+ ) -> None:
130
+ """Fail fast when tag/untag commands omit required --tag."""
131
+ import sys
132
+
133
+ if action == "untag":
134
+ tag = (extra or {}).get("tag")
135
+ message = "untag item requires --tag with the version tag name to remove."
136
+ elif op_id == "set_tag":
137
+ tag = (payload or {}).get("tag")
138
+ message = "tag item requires --tag with a version tag name."
139
+ else:
140
+ return
141
+
142
+ if tag not in (None, ""):
143
+ return
144
+
145
+ print(f"Error: {message}", file=sys.stderr)
146
+ raise SystemExit(USAGE_ERROR)
147
+
148
+
149
+ def merge_tag_address_version(
150
+ payload: dict[str, Any],
151
+ version: str | None,
152
+ ) -> dict[str, Any]:
153
+ """Fold ADDRESS@version into TagPayload when not already set in the body."""
154
+ if not version or payload.get("version") not in (None, ""):
155
+ return payload
156
+ merged = dict(payload)
157
+ merged["version"] = int(version) if version.isdigit() else version
158
+ return merged
159
+
160
+
161
+ def validate_create_request(
162
+ op_id: str,
163
+ *,
164
+ address: str | None,
165
+ payload: dict[str, Any],
166
+ ) -> None:
167
+ """Fail fast with actionable CLI messages before calling the API."""
168
+ import sys
169
+
170
+ if op_id in _ADDRESS_REQUIRED_OPS and not address:
171
+ print(
172
+ "Error: create lock requires ADDRESS (tree path to lock).",
173
+ file=sys.stderr,
174
+ )
175
+ raise SystemExit(USAGE_ERROR)
176
+
177
+ spec = _CREATE_REQUIREMENTS.get(op_id)
178
+ if spec is None:
179
+ return
180
+
181
+ required_fields, message = spec
182
+ if all(payload.get(field) not in (None, "") for field in required_fields):
183
+ return
184
+
185
+ print(f"Error: {message}", file=sys.stderr)
186
+ raise SystemExit(USAGE_ERROR)
187
+
188
+
189
+ def format_usage_error(exc: ValueError) -> str:
190
+ return f"Invalid -f body: {exc}"
191
+
192
+
193
+ def sdk_path_for_body_payload(
194
+ op_id: str,
195
+ path: str | None,
196
+ body_payload: dict[str, Any] | None,
197
+ ) -> str | None:
198
+ """Return the path argument for ``build_sdk_call`` (omit when merged into body)."""
199
+ if body_payload is None:
200
+ return path
201
+ if op_id in _BODY_PAYLOAD_PATH_ARG_OPS:
202
+ return path
203
+ return None
@@ -0,0 +1,227 @@
1
+ """Click group helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from ._lazy_mixin import ClickLazyMixin
11
+
12
+
13
+ class DefaultCommandGroup(click.Group):
14
+ """Group that routes unknown first tokens to a default subcommand."""
15
+
16
+ def __init__(self, *args: Any, default_command: str, **kwargs: Any) -> None:
17
+ super().__init__(*args, **kwargs)
18
+ self.default_command = default_command
19
+
20
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
21
+ if args and args[0] not in self.commands and not args[0].startswith("-"):
22
+ args = [self.default_command, *args]
23
+ return super().parse_args(ctx, args)
24
+
25
+ def _default_command(self, ctx: click.Context) -> click.Command | None:
26
+ cmd = self.get_command(ctx, self.default_command)
27
+ if cmd is None:
28
+ return None
29
+ return cmd
30
+
31
+ def resolve_command(
32
+ self,
33
+ ctx: click.Context,
34
+ args: list[str],
35
+ ) -> tuple[str | None, click.Command | None, list[str]]:
36
+ if not args:
37
+ return None, None, args
38
+
39
+ first = args[0]
40
+ if first in self.commands:
41
+ return super().resolve_command(ctx, args)
42
+
43
+ if ctx.token_normalize_func is not None:
44
+ normalized = ctx.token_normalize_func(first)
45
+ if normalized in self.commands:
46
+ return super().resolve_command(ctx, args)
47
+
48
+ default_cmd = self._default_command(ctx)
49
+ if default_cmd is None:
50
+ return super().resolve_command(ctx, args)
51
+
52
+ if first.startswith("-") or first not in self.commands:
53
+ return self.default_command, default_cmd, args
54
+
55
+ return super().resolve_command(ctx, args)
56
+
57
+ def shell_complete(
58
+ self,
59
+ ctx: click.Context,
60
+ incomplete: str,
61
+ ) -> list[click.shell_completion.CompletionItem]:
62
+ results = super().shell_complete(ctx, incomplete)
63
+ default_cmd = self._default_command(ctx)
64
+ if default_cmd is None:
65
+ return results
66
+
67
+ seen = {item.value for item in results}
68
+ for item in default_cmd.shell_complete(ctx, incomplete):
69
+ if item.value not in seen:
70
+ results.append(item)
71
+ seen.add(item.value)
72
+ return results
73
+
74
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
75
+ """Include the default subcommand's options in group help."""
76
+ self.format_usage(ctx, formatter)
77
+ self.format_help_text(ctx, formatter)
78
+ default_cmd = self.get_command(ctx, self.default_command)
79
+ if default_cmd is not None:
80
+ with formatter.section("Options"):
81
+ opts: list[tuple[str, str]] = []
82
+ for param in default_cmd.get_params(ctx):
83
+ record = param.get_help_record(ctx)
84
+ if record is not None:
85
+ opts.append(record)
86
+ if opts:
87
+ formatter.write_dl(opts)
88
+ self.format_commands(ctx, formatter)
89
+ self.format_epilog(ctx, formatter)
90
+
91
+
92
+ class ResourceAliasGroup(click.Group):
93
+ """Group that lists resource aliases on one help line per canonical command."""
94
+
95
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
96
+ super().__init__(*args, **kwargs)
97
+ self._alias_to_canonical: dict[str, str] = {}
98
+ self._aliases_for_resource: dict[str, list[str]] = {}
99
+
100
+ def add_resource_command(
101
+ self,
102
+ cmd: click.Command,
103
+ *,
104
+ canonical: str,
105
+ aliases: list[str] | None = None,
106
+ ) -> None:
107
+ """Register a command under its canonical name and optional aliases."""
108
+ self.add_command(cmd, name=canonical)
109
+ alias_list = aliases or []
110
+ if alias_list:
111
+ self._aliases_for_resource[canonical] = alias_list
112
+ for alias in alias_list:
113
+ self._alias_to_canonical[alias] = canonical
114
+ self.add_command(cmd, name=alias)
115
+
116
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
117
+ commands: list[tuple[str, click.Command]] = []
118
+ for subcommand in self.list_commands(ctx):
119
+ if subcommand in self._alias_to_canonical:
120
+ continue
121
+ cmd = self.get_command(ctx, subcommand)
122
+ if cmd is None or cmd.hidden:
123
+ continue
124
+ commands.append((subcommand, cmd))
125
+
126
+ if not commands:
127
+ return
128
+
129
+ limit = formatter.width - 6 - max(len(name) for name, _ in commands)
130
+
131
+ rows: list[tuple[str, str]] = []
132
+ for subcommand, cmd in commands:
133
+ help_text = cmd.get_short_help_str(limit=limit)
134
+ aliases = self._aliases_for_resource.get(subcommand, [])
135
+ if aliases:
136
+ suffix = f" (aliases: {', '.join(aliases)})"
137
+ help_text = f"{help_text}{suffix}" if help_text else suffix.strip()
138
+ rows.append((subcommand, help_text))
139
+
140
+ with formatter.section("Commands"):
141
+ formatter.write_dl(rows)
142
+
143
+
144
+ def ensure_resource_alias_group(
145
+ root: click.Group,
146
+ name: str,
147
+ *,
148
+ help: str,
149
+ ) -> ResourceAliasGroup:
150
+ """Return an existing relocate group or create a ``ResourceAliasGroup`` stub."""
151
+ group = root.commands.get(name)
152
+ if isinstance(group, ResourceAliasGroup):
153
+ return group
154
+ if isinstance(group, click.Group):
155
+ return group # type: ignore[return-value]
156
+
157
+ @click.group(name, cls=ResourceAliasGroup, help=help)
158
+ @click.pass_context
159
+ def group_cmd(ctx: click.Context) -> None:
160
+ pass
161
+
162
+ root.add_command(group_cmd)
163
+ return group_cmd
164
+
165
+
166
+ def attach_resource_command(
167
+ group: click.Group,
168
+ cmd: click.Command,
169
+ *,
170
+ canonical: str,
171
+ aliases: list[str] | None = None,
172
+ ) -> None:
173
+ """Register a command on a group, using alias metadata when supported."""
174
+ if isinstance(group, ResourceAliasGroup):
175
+ group.add_resource_command(cmd, canonical=canonical, aliases=aliases)
176
+ elif isinstance(group, click.Group):
177
+ group.add_command(cmd, name=canonical)
178
+
179
+
180
+ class ConfigPathHelpGroup(click.Group):
181
+ """Group that appends the local CLI config file path to help."""
182
+
183
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
184
+ super().format_help(ctx, formatter)
185
+ from ._config import format_config_path_note
186
+
187
+ formatter.write_paragraph()
188
+ formatter.write_text(format_config_path_note())
189
+
190
+
191
+ class LazyGroup(ClickLazyMixin, click.Group):
192
+ """Group stub that builds the real group on first use."""
193
+
194
+ def __init__(
195
+ self,
196
+ name: str,
197
+ *,
198
+ help: str,
199
+ build_fn: Callable[[], click.Group],
200
+ ) -> None:
201
+ super().__init__(name, help=help)
202
+ self._build_fn = build_fn
203
+ self._real: click.Group | None = None
204
+
205
+ def _lazy_target(self) -> click.Group:
206
+ return self._ensure()
207
+
208
+ def _ensure(self) -> click.Group:
209
+ if self._real is None:
210
+ self._real = self._build_fn()
211
+ return self._real
212
+
213
+ def list_commands(self, ctx: click.Context) -> list[str]:
214
+ return self._ensure().list_commands(ctx)
215
+
216
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
217
+ return self._ensure().get_command(ctx, cmd_name)
218
+
219
+ def resolve_command(
220
+ self,
221
+ ctx: click.Context,
222
+ args: list[str],
223
+ ) -> tuple[str | None, click.Command | None, list[str]]:
224
+ return self._ensure().resolve_command(ctx, args)
225
+
226
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
227
+ self._ensure().format_help(ctx, formatter)