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,310 @@
1
+ """Map CLI addresses to SDK method calls for generated commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ # First positional argument name when address is provided (by op_id).
9
+ _POSITIONAL_ADDRESS: dict[str, str] = {
10
+ "show_namespace": "namespace",
11
+ "delete_namespace": "namespace",
12
+ "update_namespace": "namespace",
13
+ "get_global_audit_event": "event_id",
14
+ "get_namespace_audit_event": "event_id",
15
+ "get_global_permission": "rule_id",
16
+ "delete_global_permission": "rule_id",
17
+ "update_global_permission": "rule_id",
18
+ "move_global_permission": "rule_id",
19
+ "get_lock": "path",
20
+ "create_lock": "path",
21
+ "replace_lock": "path",
22
+ "delete_lock": "path",
23
+ "set_tag": "path",
24
+ }
25
+
26
+ # Keyword name for address when not using default ``path``.
27
+ _KEYWORD_ADDRESS: dict[str, str] = {
28
+ "create_namespace": "name",
29
+ "create_global_permission": "id",
30
+ }
31
+
32
+ # Create/update ops whose ADDRESS is not a tree path (skip segment validation).
33
+ NON_TREE_ADDRESS_OPS = frozenset(
34
+ {
35
+ "create_namespace",
36
+ "create_global_permission",
37
+ }
38
+ )
39
+
40
+ # Operations with multiple positional arguments after kwargs are assembled.
41
+ _MULTI_POSITIONAL: dict[str, list[str]] = {}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SdkExtraParam:
46
+ """Extra CLI flag mapped to an SDK keyword argument."""
47
+
48
+ sdk_name: str
49
+ click_name: str
50
+ help: str
51
+ type_: type | None = str
52
+ is_flag: bool = False
53
+
54
+
55
+ _SDK_PARAM_CATALOG: dict[str, SdkExtraParam] = {
56
+ "q": SdkExtraParam("q", "q", "Search query string."),
57
+ "types": SdkExtraParam("types", "types", "Filter by node types (comma-separated)."),
58
+ "limit": SdkExtraParam("limit", "limit", "Maximum number of results.", int),
59
+ "offset": SdkExtraParam("offset", "offset", "Result offset for pagination.", int),
60
+ "object_id": SdkExtraParam("object_id", "object-id", "Audit object ID."),
61
+ "object_type": SdkExtraParam("object_type", "object-type", "Audit object type."),
62
+ "search": SdkExtraParam("search", "search", "Free-text search filter."),
63
+ "from_": SdkExtraParam("from_", "from", "Range start (ISO timestamp)."),
64
+ "to": SdkExtraParam("to", "to", "Range end (ISO timestamp)."),
65
+ "bucket_seconds": SdkExtraParam("bucket_seconds", "bucket-seconds", "Bucket size in seconds.", int),
66
+ "tag": SdkExtraParam("tag", "tag", "Version tag name."),
67
+ "no_creds": SdkExtraParam("no_creds", "no-creds", "Omit credential-backed parameters.", is_flag=True),
68
+ "auth_id": SdkExtraParam("auth_id", "auth-id", "Filter by author ID."),
69
+ "auth_email": SdkExtraParam("auth_email", "auth-email", "Filter by author email."),
70
+ "event_id": SdkExtraParam("event_id", "event-id", "Filter by event ID."),
71
+ "event_kind": SdkExtraParam("event_kind", "event-kind", "Filter by event kind."),
72
+ "token_number": SdkExtraParam("token_number", "token-number", "Resolver token number.", int),
73
+ "reason": SdkExtraParam("reason", "reason", "Lock rationale."),
74
+ "expires_at": SdkExtraParam("expires_at", "expires-at", "Lock expiry time (ISO-8601 UTC)."),
75
+ "position": SdkExtraParam(
76
+ "position",
77
+ "position",
78
+ "Sort position for the new rule (applied via move after create).",
79
+ float,
80
+ ),
81
+ "description": SdkExtraParam(
82
+ "description",
83
+ "description",
84
+ "Human-readable namespace description.",
85
+ ),
86
+ "tagged_only": SdkExtraParam(
87
+ "tagged_only",
88
+ "tagged-only",
89
+ "Show only versions that have tags.",
90
+ is_flag=True,
91
+ ),
92
+ "preview": SdkExtraParam(
93
+ "preview",
94
+ "preview",
95
+ "Show what would be deleted without removing anything.",
96
+ is_flag=True,
97
+ ),
98
+ }
99
+
100
+ # Per-operation extra SDK kwargs exposed as CLI flags.
101
+ _OP_EXTRA_PARAMS: dict[str, list[str]] = {
102
+ "search_root": ["q", "types", "limit", "offset"],
103
+ "search_path": ["q", "types", "limit", "offset"],
104
+ "namespace_audit_timeline": ["object_id", "object_type", "search", "limit", "offset"],
105
+ "namespace_audit_resolve_series": ["object_id", "object_type", "from_", "to", "bucket_seconds"],
106
+ "list_namespace_audit": [
107
+ "auth_id",
108
+ "auth_email",
109
+ "object_id",
110
+ "object_type",
111
+ "event_id",
112
+ "event_kind",
113
+ "search",
114
+ "from_",
115
+ "to",
116
+ "limit",
117
+ "offset",
118
+ ],
119
+ "list_global_audit": [
120
+ "auth_id",
121
+ "auth_email",
122
+ "object_id",
123
+ "object_type",
124
+ "event_id",
125
+ "event_kind",
126
+ "search",
127
+ "from_",
128
+ "to",
129
+ "limit",
130
+ "offset",
131
+ ],
132
+ "set_tag": ["tag"],
133
+ "rotate_resolver_token": ["token_number"],
134
+ "create_lock": ["reason", "expires_at"],
135
+ "replace_lock": ["reason", "expires_at"],
136
+ "create_namespace": ["description"],
137
+ "create_global_permission": ["position"],
138
+ "list_item_versions": ["limit", "offset", "tagged_only"],
139
+ "delete_item": ["preview"],
140
+ }
141
+
142
+ # Operations that accept version/tag via address@VER or --version.
143
+ VERSION_ADDRESS_OPS = frozenset(
144
+ {
145
+ "get_item",
146
+ "delete_item",
147
+ "propagate_config",
148
+ "get_config_data_schema",
149
+ "set_tag",
150
+ }
151
+ )
152
+
153
+ # ADDRESS@VER is folded into the request body (not an SDK ``version`` kwarg).
154
+ VERSION_BODY_OPS = frozenset(
155
+ {
156
+ "set_tag",
157
+ }
158
+ )
159
+
160
+ # ADDRESS@VER is a filter/query for these ops, not an SDK ``version`` argument.
161
+ VERSION_FILTER_OPS = frozenset(
162
+ {
163
+ "list_item_versions",
164
+ }
165
+ )
166
+
167
+ # Operations that do not accept a tree path / address argument.
168
+ NO_ADDRESS_OPS = frozenset(
169
+ {
170
+ "namespace_audit_timeline",
171
+ "namespace_audit_resolve_series",
172
+ "search_root",
173
+ "list_namespaces",
174
+ "list_global_audit",
175
+ "list_namespace_audit",
176
+ "list_global_permissions",
177
+ "list_locks",
178
+ "list_cast_formats",
179
+ "health",
180
+ }
181
+ )
182
+
183
+
184
+ def address_optional_for_command(
185
+ op_ids: list[str],
186
+ *,
187
+ action: str,
188
+ resource: str,
189
+ ) -> bool:
190
+ """True when ADDRESS may be omitted (list-without-path or scoped list ops)."""
191
+ if action == "create" and resource == "globalpermission":
192
+ return True
193
+ if action == "get" and resource in ("item", "cast"):
194
+ return True
195
+ return any(op_id in NO_ADDRESS_OPS for op_id in op_ids)
196
+
197
+
198
+ def address_required_for_op(op_id: str, *, action: str, resource: str) -> bool:
199
+ """True when the resolved SDK operation needs a CLI ADDRESS."""
200
+ if action == "create" and resource == "globalpermission":
201
+ return False
202
+ return op_id not in NO_ADDRESS_OPS
203
+
204
+
205
+ def extra_params_for_ops(op_ids: list[str]) -> list[SdkExtraParam]:
206
+ """Return sorted unique extra parameter specs for a generated command."""
207
+ seen: dict[str, SdkExtraParam] = {}
208
+ for op_id in op_ids:
209
+ for name in _OP_EXTRA_PARAMS.get(op_id, []):
210
+ seen[name] = _SDK_PARAM_CATALOG[name]
211
+ return [seen[k] for k in sorted(seen)]
212
+
213
+
214
+ def pick_op_id(
215
+ op_ids: list[str],
216
+ *,
217
+ address: str | None,
218
+ namespace: str | None,
219
+ ops_meta: dict[str, dict[str, Any]],
220
+ ) -> str:
221
+ """Choose the SDK operation when several map to the same CLI command."""
222
+ if len(op_ids) == 1:
223
+ return op_ids[0]
224
+
225
+ candidates = list(op_ids)
226
+
227
+ # Mixed client/namespace audit & permission ops: prefer scope matching -n.
228
+ if namespace:
229
+ scoped = [o for o in candidates if ops_meta.get(o, {}).get("scope") == "namespace"]
230
+ if scoped:
231
+ candidates = scoped
232
+ else:
233
+ scoped = [o for o in candidates if ops_meta.get(o, {}).get("scope") == "client"]
234
+ if scoped:
235
+ candidates = scoped
236
+
237
+ if address:
238
+ singles = [
239
+ o
240
+ for o in candidates
241
+ if o.startswith(("show_", "get_", "search_path", "navigate_path")) or o.endswith("_path")
242
+ ]
243
+ if singles:
244
+ return singles[0]
245
+ non_list = [o for o in candidates if not o.startswith("list_") and not o.endswith("_root")]
246
+ return non_list[0] if non_list else candidates[0]
247
+
248
+ lists = [o for o in candidates if o.startswith("list_") or o.endswith("_root")]
249
+ return lists[0] if lists else candidates[0]
250
+
251
+
252
+ def address_keyword_for_op(op_id: str) -> str:
253
+ """SDK keyword that receives the CLI ADDRESS for ``op_id``."""
254
+ return _KEYWORD_ADDRESS.get(op_id, "path")
255
+
256
+
257
+ def build_sdk_call(
258
+ op_id: str,
259
+ *,
260
+ path: str | None,
261
+ version: str | None,
262
+ content: str | None,
263
+ extra: dict[str, Any] | None = None,
264
+ ) -> tuple[list[Any], dict[str, Any]]:
265
+ """Return (positional_args, keyword_args) for an SDK facade method."""
266
+ from ocmo._facade_meta import DOCUMENT_BODY_OPS
267
+
268
+ args: list[Any] = []
269
+ kwargs: dict[str, Any] = {}
270
+
271
+ if path is not None:
272
+ if op_id in _POSITIONAL_ADDRESS:
273
+ args.append(path)
274
+ else:
275
+ kw = address_keyword_for_op(op_id)
276
+ kwargs[kw] = path
277
+
278
+ if version is not None and op_id not in VERSION_FILTER_OPS and op_id not in VERSION_BODY_OPS:
279
+ kwargs["version"] = version
280
+
281
+ if content is not None:
282
+ if op_id in DOCUMENT_BODY_OPS:
283
+ kwargs["content"] = content
284
+ else:
285
+ kwargs["body"] = content
286
+
287
+ if extra:
288
+ for key, value in extra.items():
289
+ if value is not None and value is not False:
290
+ kwargs[key] = value
291
+
292
+ multi = _MULTI_POSITIONAL.get(op_id)
293
+ if multi:
294
+ for name in multi:
295
+ if name in kwargs:
296
+ args.append(kwargs.pop(name))
297
+
298
+ return args, kwargs
299
+
300
+
301
+ def unwrap_list_payload(data: Any) -> list[dict[str, Any]] | None:
302
+ """Extract a list of row dicts from common paginated API responses."""
303
+ if isinstance(data, list):
304
+ return [r for r in data if isinstance(r, dict)]
305
+ if isinstance(data, dict):
306
+ for key in ("items", "children", "versions", "locks", "rules", "formats"):
307
+ value = data.get(key)
308
+ if isinstance(value, list):
309
+ return [r for r in value if isinstance(r, dict)]
310
+ return None
ocmo_cli/_typing.py ADDED
@@ -0,0 +1,27 @@
1
+ """Shared typing helpers for Click decorators and deferred SDK imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, Protocol, TypeAlias, TypeVar
7
+
8
+ ClickDecorator: TypeAlias = Callable[[Callable[..., Any]], Callable[..., Any]]
9
+
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+
12
+
13
+ class ResolvedArtifact(Protocol):
14
+ name: str | None
15
+ version: int | None
16
+ format: str | None
17
+ checksum: str | None
18
+ trace: dict[str, Any]
19
+ url: str | None
20
+
21
+ @property
22
+ def bytes(self) -> bytes: ...
23
+
24
+ @property
25
+ def text(self) -> str: ...
26
+
27
+ def get(self, path: str, *, default: Any = ...) -> Any: ...
@@ -0,0 +1,39 @@
1
+ """Helpers for ``ocmo get version`` address and query handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def apply_version_address_query(extra: dict[str, Any], version: str | None) -> None:
9
+ """Map ADDRESS@VER suffix to list_item_versions query parameters.
10
+
11
+ ``@latest``, ``@stable``, and other tag names are passed as ``q`` (search),
12
+ not as implicit ``limit=1`` — a tag may point at an older version.
13
+ """
14
+ if not version or extra.get("q") is not None:
15
+ return
16
+ extra.setdefault("q", version)
17
+
18
+
19
+ def emit_item_versions_output(
20
+ view: Any,
21
+ path: str,
22
+ *,
23
+ version: str | None,
24
+ output_fmt: str | None,
25
+ ctx_fmt: str | None,
26
+ field: str | None = None,
27
+ ) -> None:
28
+ """Emit version history for *path* using ``get version`` output formatting."""
29
+ from ._output import as_dict, extract_field, sanitize_for_output
30
+ from ._output_manifest import emit_command_output
31
+
32
+ list_kwargs: dict[str, Any] = {}
33
+ apply_version_address_query(list_kwargs, version)
34
+ result = view.list_item_versions(path=path, **list_kwargs)
35
+ data = as_dict(result)
36
+ if field:
37
+ extract_field(sanitize_for_output(data), field)
38
+ return
39
+ emit_command_output("get version", data, output_fmt, ctx_fmt=ctx_fmt)
@@ -0,0 +1,43 @@
1
+ """Output helpers for ``ocmo whoami``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._output import as_dict
8
+
9
+
10
+ def whoami_table_row(data: Any) -> dict[str, Any]:
11
+ """Flatten whoami API payload for table output."""
12
+ payload = as_dict(data, fallback_vars=False)
13
+ if not payload:
14
+ return {}
15
+
16
+ row: dict[str, Any] = {
17
+ "auth_type": payload.get("auth_type"),
18
+ "identifier": payload.get("identifier"),
19
+ "display_name": payload.get("display_name"),
20
+ "access_scope": payload.get("access_scope"),
21
+ }
22
+
23
+ user_details = payload.get("user_details")
24
+ if isinstance(user_details, dict):
25
+ row["email"] = user_details.get("email")
26
+ row["is_global_admin"] = user_details.get("is_global_admin")
27
+ row["claims"] = user_details.get("claims")
28
+ return row
29
+
30
+ resolver_details = payload.get("resolver_details")
31
+ if isinstance(resolver_details, dict):
32
+ row["namespace"] = resolver_details.get("namespace")
33
+ row["name"] = resolver_details.get("name")
34
+ row["token_number"] = resolver_details.get("token_number")
35
+
36
+ return row
37
+
38
+
39
+ def whoami_table_rows(data: Any) -> list[dict[str, Any]]:
40
+ if isinstance(data, list):
41
+ return [whoami_table_row(item) for item in data]
42
+ row = whoami_table_row(data)
43
+ return [row] if row else []
@@ -0,0 +1 @@
1
+ # Commands package.
@@ -0,0 +1,125 @@
1
+ """ocmo api — escape hatch to invoke any SDK operation directly.
2
+
3
+ This is the guarantee that no API capability is ever unreachable from the CLI.
4
+ Prints raw JSON. Bypasses all ergonomic layers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Any, NoReturn
11
+
12
+ import click
13
+
14
+ from .._client import OcmoCtx
15
+ from .._errors import handle_sdk_error
16
+ from .._output import as_dict, emit
17
+
18
+
19
+ @click.command("api", hidden=True)
20
+ @click.argument("operation_id")
21
+ @click.option(
22
+ "--param", "-p", "params", multiple=True, metavar="KEY=VALUE", help="Operation parameters (may be repeated)."
23
+ )
24
+ @click.option(
25
+ "-f", "--file", "file_path", default=None, metavar="FILE|-", help="Request body from file ('-' for stdin)."
26
+ )
27
+ @click.option("-n", "--namespace", default=None)
28
+ @click.pass_obj
29
+ def api_cmd(
30
+ ctx: OcmoCtx,
31
+ operation_id: str,
32
+ params: tuple[str, ...],
33
+ file_path: str | None,
34
+ namespace: str | None,
35
+ ) -> None:
36
+ """Invoke any API operation directly by operation_id.
37
+
38
+ This is the escape hatch for operations marked `skip` in commands.yaml,
39
+ and for advanced usage. Output is always raw JSON.
40
+
41
+ \b
42
+ Examples:
43
+ ocmo api whoami
44
+ ocmo api get_item --param path=app/web -n prod
45
+ ocmo api create_config --param path=app/new -f body.yaml -n prod
46
+ """
47
+ kwargs: dict[str, Any] = {}
48
+
49
+ # Parse --param
50
+ for p in params:
51
+ if "=" not in p:
52
+ print(f"Error: --param must be KEY=VALUE, got {p!r}", file=sys.stderr)
53
+ raise SystemExit(2)
54
+ k, v = p.split("=", 1)
55
+ kwargs[k] = v
56
+
57
+ # Read body
58
+ if file_path:
59
+ if file_path == "-":
60
+ kwargs["content"] = sys.stdin.read()
61
+ else:
62
+ with open(file_path) as f:
63
+ kwargs["content"] = f.read()
64
+
65
+ try:
66
+ result = _dispatch(ctx, operation_id, namespace, kwargs)
67
+ if result is not None:
68
+ if isinstance(result, list):
69
+ emit([as_dict(item) for item in result], "json")
70
+ else:
71
+ payload = as_dict(result, fallback_vars=False) or result
72
+ emit(payload, "json")
73
+ except KeyboardInterrupt:
74
+ raise SystemExit(130)
75
+ except SystemExit:
76
+ raise
77
+ except Exception as exc:
78
+ handle_sdk_error(exc)
79
+
80
+
81
+ def _dispatch(ctx: OcmoCtx, op_id: str, namespace: str | None, kwargs: dict[str, Any]) -> Any:
82
+ """Find the SDK method and call it."""
83
+ from pathlib import Path
84
+
85
+ import yaml # deferred
86
+
87
+ ops_path = Path(__file__).parent.parent.parent.parent / "sdk" / "operations.yaml"
88
+ scope = "namespace"
89
+ if ops_path.exists():
90
+ with ops_path.open() as f:
91
+ ops = yaml.safe_load(f) or {}
92
+ op_cfg = ops.get("operations", {}).get(op_id, {})
93
+ scope = op_cfg.get("scope", "namespace")
94
+
95
+ if scope == "client":
96
+ client = ctx.client()
97
+ method = getattr(client, op_id, None)
98
+ if method is None:
99
+ _no_such_op(op_id)
100
+ assert method is not None
101
+ return method(**kwargs)
102
+ else:
103
+ ns = namespace or ctx.namespace
104
+ if ns:
105
+ view = ctx.ns(ns)
106
+ else:
107
+ # Try client first, then ask for namespace
108
+ client = ctx.client()
109
+ method = getattr(client, op_id, None)
110
+ if method:
111
+ return method(**kwargs)
112
+ ctx.require_namespace(namespace) # will abort with error
113
+ raise SystemExit(2) # unreachable but satisfies type checker
114
+
115
+ method = getattr(view, op_id, None)
116
+ if method is None:
117
+ _no_such_op(op_id)
118
+ assert method is not None
119
+ return method(**kwargs)
120
+
121
+
122
+ def _no_such_op(op_id: str) -> NoReturn:
123
+ print(f"Error: unknown operation {op_id!r}.", file=sys.stderr)
124
+ print("Run `ocmo api --help` or check the SDK documentation for valid operation IDs.", file=sys.stderr)
125
+ raise SystemExit(2)
@@ -0,0 +1,12 @@
1
+ """ocmo api-health — check API dependency health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._client_commands import client_command
6
+
7
+ api_health_cmd = client_command(
8
+ "api-health",
9
+ help="Check API dependency health (database, cache, and related backends).",
10
+ manifest_key="api-health",
11
+ method="health",
12
+ )