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
ocmo_cli/_output.py ADDED
@@ -0,0 +1,424 @@
1
+ """Output formatting utilities.
2
+
3
+ Diagnostics go to stderr; payload goes to stdout. This ensures
4
+ `ocmo resolve … | kubectl apply -f -` is safe.
5
+
6
+ Colour is enabled only when stdout is a TTY and NO_COLOR / --no-color is not set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ from typing import Any
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Format helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _OUTPUT_FORMATS = ("table", "wide", "json", "yaml", "name", "path", "raw")
23
+
24
+
25
+ def _color_enabled() -> bool:
26
+ if os.environ.get("NO_COLOR") or os.environ.get("OCMO_NO_COLOR"):
27
+ return False
28
+ return sys.stdout.isatty()
29
+
30
+
31
+ def _default_output_format() -> str:
32
+ env = os.environ.get("OCMO_OUTPUT")
33
+ if env:
34
+ if env not in _OUTPUT_FORMATS and not env.startswith("jsonpath="):
35
+ print(f"Warning: OCMO_OUTPUT={env!r} is not valid; ignored.", file=sys.stderr)
36
+ return "table" if sys.stdout.isatty() else "yaml"
37
+ return env
38
+ return "table" if sys.stdout.isatty() else "yaml"
39
+
40
+
41
+ def format_datetime(value: Any) -> str:
42
+ """Convert a datetime or ISO-8601 string to local time for CLI display.
43
+
44
+ Human-oriented format: ``May 22, 22:14:13`` in the current year, otherwise
45
+ ``May 22, 2025, 22:14:13``. Always uses the system local timezone.
46
+ """
47
+ if value is None:
48
+ return ""
49
+ if isinstance(value, str):
50
+ try:
51
+ from dateutil.parser import isoparse # deferred
52
+
53
+ value = isoparse(value)
54
+ except Exception:
55
+ return str(value)
56
+ if isinstance(value, datetime.datetime):
57
+ if value.tzinfo is not None:
58
+ local_dt = value.astimezone(tz=None)
59
+ now = datetime.datetime.now(tz=local_dt.tzinfo)
60
+ else:
61
+ local_dt = value
62
+ now = datetime.datetime.now()
63
+ time_part = local_dt.strftime("%H:%M:%S")
64
+ month_day = f"{local_dt.strftime('%b')} {local_dt.day},"
65
+ if local_dt.year == now.year:
66
+ return f"{month_day} {time_part}"
67
+ return f"{month_day} {local_dt.year}, {time_part}"
68
+ return str(value)
69
+
70
+
71
+ # Keys stripped from CLI output when they carry internal DB surrogate keys.
72
+ # Public identifiers (audit event UUIDs, global-permission rule ids, …) are kept.
73
+
74
+
75
+ def _should_hide_output_key(key: str, value: Any) -> bool:
76
+ if key.startswith("_"):
77
+ return True
78
+ if key == "namespace_id":
79
+ return True
80
+ if key == "id" and isinstance(value, int):
81
+ return True
82
+ return False
83
+
84
+
85
+ _ISO_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")
86
+
87
+
88
+ def as_dict(obj: Any, *, fallback_vars: bool = True) -> dict[str, Any]:
89
+ """Normalize SDK model / mapping to dict for CLI output."""
90
+ if isinstance(obj, dict):
91
+ return obj
92
+ if hasattr(obj, "to_dict"):
93
+ payload = obj.to_dict()
94
+ return payload if isinstance(payload, dict) else {}
95
+ if fallback_vars:
96
+ try:
97
+ data = vars(obj)
98
+ return data if isinstance(data, dict) else {}
99
+ except TypeError:
100
+ pass
101
+ return {}
102
+
103
+
104
+ def sanitize_for_output(obj: Any) -> Any:
105
+ """Recursively convert an object to a JSON-serializable structure."""
106
+ if isinstance(obj, datetime.datetime):
107
+ return format_datetime(obj)
108
+ if isinstance(obj, str | int | float | bool | type(None)):
109
+ return obj
110
+ if isinstance(obj, dict):
111
+ return {k: sanitize_for_output(v) for k, v in obj.items() if not _should_hide_output_key(k, v)}
112
+ if isinstance(obj, list | tuple):
113
+ return [sanitize_for_output(v) for v in obj]
114
+ if hasattr(obj, "to_dict"):
115
+ return sanitize_for_output(obj.to_dict())
116
+ try:
117
+ json.dumps(obj)
118
+ return obj
119
+ except TypeError:
120
+ return f"<{type(obj).__name__}>"
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # YAML dumper with multiline literal blocks
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _make_yaml_dumper() -> type:
129
+ import yaml # deferred
130
+
131
+ class _LiteralStr(str):
132
+ pass
133
+
134
+ def _literal_representer(dumper: yaml.Dumper, data: _LiteralStr) -> yaml.Node:
135
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
136
+
137
+ def _str_representer(dumper: yaml.Dumper, data: str) -> yaml.Node:
138
+ if "\n" in data:
139
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
140
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
141
+
142
+ class _Dumper(yaml.SafeDumper):
143
+ pass
144
+
145
+ _Dumper.add_representer(str, _str_representer) # type: ignore[arg-type]
146
+ _Dumper.add_representer(_LiteralStr, _literal_representer) # type: ignore[arg-type]
147
+ return _Dumper
148
+
149
+
150
+ def yaml_dumps(data: Any) -> str:
151
+ """Dump data to YAML with multiline strings as literal blocks (|)."""
152
+ import yaml # deferred
153
+
154
+ dumper = _make_yaml_dumper()
155
+ return yaml.dump(data, Dumper=dumper, default_flow_style=False, allow_unicode=True)
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Core emit
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def emit(data: Any, fmt: str | None = None, *, columns: list[str] | None = None) -> None:
164
+ """Write data to stdout in the requested format."""
165
+ from ._sdk_dispatch import unwrap_list_payload # deferred
166
+
167
+ fmt = fmt or _default_output_format()
168
+ data = sanitize_for_output(data)
169
+
170
+ if fmt == "json":
171
+ print(json.dumps(data, default=str, indent=2))
172
+ elif fmt == "yaml":
173
+ if isinstance(data, dict | list):
174
+ print(yaml_dumps(data), end="")
175
+ else:
176
+ print(data)
177
+ elif fmt in ("table", "wide"):
178
+ rows = unwrap_list_payload(data)
179
+ if rows is not None:
180
+ emit_table(rows, columns or _infer_table_columns(rows))
181
+ else:
182
+ _emit_table_data(data, columns=columns)
183
+ elif fmt == "name":
184
+ rows = unwrap_list_payload(data)
185
+ _emit_identifiers(rows if rows is not None else data, "name")
186
+ elif fmt == "path":
187
+ rows = unwrap_list_payload(data)
188
+ _emit_identifiers(rows if rows is not None else data, "path")
189
+ elif fmt == "raw":
190
+ if isinstance(data, bytes):
191
+ sys.stdout.buffer.write(data)
192
+ else:
193
+ print(data, end="")
194
+ elif fmt.startswith("jsonpath="):
195
+ _emit_jsonpath(data, fmt[9:])
196
+ else:
197
+ print(data)
198
+
199
+
200
+ def _emit_table_data(data: Any, *, columns: list[str] | None = None) -> None:
201
+ rows = _as_table_rows(data)
202
+ if rows:
203
+ emit_table(rows, columns or _infer_table_columns(rows))
204
+ elif isinstance(data, dict | list):
205
+ print(yaml_dumps(data), end="")
206
+ else:
207
+ print(data)
208
+
209
+
210
+ def _as_table_rows(data: Any) -> list[dict[str, Any]]:
211
+ if isinstance(data, list):
212
+ return [r for r in data if isinstance(r, dict)]
213
+ if isinstance(data, dict):
214
+ return [data]
215
+ return []
216
+
217
+
218
+ def _infer_table_columns(rows: list[dict[str, Any]]) -> list[str]:
219
+ """Columns that have at least one non-empty value across all rows."""
220
+ order: list[str] = []
221
+ seen: set[str] = set()
222
+ for row in rows:
223
+ for key in row:
224
+ if key not in seen:
225
+ seen.add(key)
226
+ order.append(key)
227
+ active = [k for k in order if any(_cell_nonempty(row.get(k)) for row in rows)]
228
+ return active or order
229
+
230
+
231
+ def _cell_nonempty(value: Any) -> bool:
232
+ if value is None:
233
+ return False
234
+ if isinstance(value, str):
235
+ return bool(value)
236
+ if isinstance(value, list | dict):
237
+ return bool(value)
238
+ return True
239
+
240
+
241
+ def _emit_identifiers(data: Any, field: str) -> None:
242
+ """Print one identifier per line (name or path only — no fallback)."""
243
+ items = data if isinstance(data, list) else [data]
244
+ for item in items:
245
+ if isinstance(item, dict):
246
+ value = item.get(field)
247
+ if value is not None and value != "":
248
+ print(value)
249
+ else:
250
+ value = getattr(item, field, None)
251
+ if value is not None and value != "":
252
+ print(value)
253
+
254
+
255
+ def _emit_jsonpath(data: Any, expr: str) -> None:
256
+ """Extract a dot-path from data; for lists, apply to every item."""
257
+ from ._sdk_dispatch import unwrap_list_payload # deferred
258
+
259
+ parts = _parse_path(expr)
260
+ rows = unwrap_list_payload(data)
261
+ if rows is not None:
262
+ for item in rows:
263
+ _print_jsonpath_value(_resolve_path(item, parts))
264
+ return
265
+ if isinstance(data, list):
266
+ for item in data:
267
+ _print_jsonpath_value(_resolve_path(item, parts))
268
+ else:
269
+ _print_jsonpath_value(_resolve_path(data, parts))
270
+
271
+
272
+ def _parse_path(expr: str) -> list[str]:
273
+ return [p for p in re.split(r"[.\[\]]", expr.lstrip("$.")) if p]
274
+
275
+
276
+ def _resolve_path(data: Any, parts: list[str]) -> Any:
277
+ if not parts:
278
+ return data
279
+ part = parts[0]
280
+ rest = parts[1:]
281
+
282
+ if part == "*":
283
+ if not isinstance(data, list):
284
+ return None
285
+ return [_resolve_path(item, rest) for item in data]
286
+
287
+ if isinstance(data, dict):
288
+ return _resolve_path(data.get(part), rest)
289
+
290
+ if isinstance(data, list):
291
+ if part.isdigit():
292
+ try:
293
+ return _resolve_path(data[int(part)], rest)
294
+ except IndexError:
295
+ return None
296
+ return [_resolve_path(item, parts) for item in data]
297
+
298
+ return None
299
+
300
+
301
+ def _print_jsonpath_value(value: Any) -> None:
302
+ if value is None:
303
+ return
304
+ if isinstance(value, list):
305
+ for item in value:
306
+ _print_jsonpath_value(item)
307
+ return
308
+ if isinstance(value, dict | list):
309
+ print(json.dumps(value, default=str, indent=2))
310
+ else:
311
+ print(value)
312
+
313
+
314
+ def extract_field(data: Any, field: str) -> None:
315
+ """Extract a dot-path field; for lists, print one value per item."""
316
+ from ._sdk_dispatch import unwrap_list_payload # deferred
317
+
318
+ parts = _parse_path(field)
319
+ rows = unwrap_list_payload(data)
320
+ target = rows if rows is not None else (data if isinstance(data, list) else [data])
321
+ if not isinstance(target, list):
322
+ target = [target]
323
+ for item in target:
324
+ _print_jsonpath_value(_resolve_path(item, parts))
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Table rendering (kubectl-style: borderless, aligned columns)
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ def emit_table(
333
+ rows: list[dict[str, Any]],
334
+ columns: list[str] | None = None,
335
+ ) -> None:
336
+ """Render a borderless kubectl-style table."""
337
+ if not rows:
338
+ return
339
+
340
+ cols = columns or _infer_table_columns(rows)
341
+
342
+ if sys.stdout.isatty() and _color_enabled():
343
+ _emit_rich_table(rows, cols)
344
+ else:
345
+ _emit_plain_table(rows, cols)
346
+
347
+
348
+ def _emit_rich_table(rows: list[dict[str, Any]], cols: list[str]) -> None:
349
+ from rich.console import Console
350
+ from rich.table import Table
351
+
352
+ table = Table(
353
+ box=None,
354
+ show_header=True,
355
+ header_style="bold",
356
+ show_edge=False,
357
+ pad_edge=False,
358
+ padding=(0, 2),
359
+ )
360
+ for col in cols:
361
+ table.add_column(col.upper())
362
+ for row in rows:
363
+ table.add_row(*[_fmt_cell(row.get(c, "")) for c in cols])
364
+ Console().print(table)
365
+
366
+
367
+ def _emit_plain_table(rows: list[dict[str, Any]], cols: list[str]) -> None:
368
+ widths = {c: len(c) for c in cols}
369
+ str_rows: list[dict[str, str]] = []
370
+ for row in rows:
371
+ sr = {c: _fmt_cell(row.get(c, "")) for c in cols}
372
+ str_rows.append(sr)
373
+ for c in cols:
374
+ widths[c] = max(widths[c], len(sr[c]))
375
+
376
+ header = " ".join(c.upper().ljust(widths[c]) for c in cols)
377
+ print(header)
378
+ for sr in str_rows:
379
+ print(" ".join(sr[c].ljust(widths[c]) for c in cols))
380
+
381
+
382
+ def _fmt_cell(value: Any) -> str:
383
+ if value is None:
384
+ return ""
385
+ if isinstance(value, datetime.datetime):
386
+ return format_datetime(value)
387
+ if isinstance(value, str) and _ISO_DATETIME_RE.match(value):
388
+ return format_datetime(value)
389
+ if isinstance(value, list):
390
+ if not value:
391
+ return ""
392
+ if all(isinstance(item, str) for item in value):
393
+ return ", ".join(value)
394
+ if isinstance(value, dict | list):
395
+ return yaml_dumps(value).rstrip("\n")
396
+ return str(value)
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Utility
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ def err(msg: str) -> None:
405
+ print(msg, file=sys.stderr)
406
+
407
+
408
+ def status(msg: str) -> None:
409
+ """Print a human-readable status line to stderr."""
410
+ print(msg, file=sys.stderr)
411
+
412
+
413
+ def warn(msg: str) -> None:
414
+ print(f"Warning: {msg}", file=sys.stderr)
415
+
416
+
417
+ def confirm(msg: str, *, yes: bool, default: bool = False) -> bool:
418
+ if yes:
419
+ return True
420
+ if not sys.stdin.isatty():
421
+ err(f"Non-interactive mode: use --yes to confirm '{msg}'")
422
+ return False
423
+ answer = input(f"{msg} [y/N] ").strip().lower()
424
+ return answer in ("y", "yes")