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.
- LICENSE +201 -0
- NOTICE +16 -0
- ocmo_cli/__init__.py +3 -0
- ocmo_cli/__main__.py +4 -0
- ocmo_cli/_address.py +182 -0
- ocmo_cli/_audit_item.py +33 -0
- ocmo_cli/_auth_resolve.py +66 -0
- ocmo_cli/_body_payload.py +203 -0
- ocmo_cli/_click_groups.py +227 -0
- ocmo_cli/_client.py +351 -0
- ocmo_cli/_client_commands.py +35 -0
- ocmo_cli/_command_registry.py +29 -0
- ocmo_cli/_commands_map.py +65 -0
- ocmo_cli/_config.py +183 -0
- ocmo_cli/_config_content.py +43 -0
- ocmo_cli/_delete_item_output.py +129 -0
- ocmo_cli/_diff_input.py +144 -0
- ocmo_cli/_dry_run.py +292 -0
- ocmo_cli/_errors.py +151 -0
- ocmo_cli/_exit.py +13 -0
- ocmo_cli/_export_metadata.py +47 -0
- ocmo_cli/_generated_registry.py +45 -0
- ocmo_cli/_get_cast_output.py +52 -0
- ocmo_cli/_get_item_list.py +23 -0
- ocmo_cli/_globalpermission_output.py +98 -0
- ocmo_cli/_import_metadata.py +140 -0
- ocmo_cli/_import_plan.py +221 -0
- ocmo_cli/_item_output.py +223 -0
- ocmo_cli/_lazy_mixin.py +29 -0
- ocmo_cli/_ls_wide.py +286 -0
- ocmo_cli/_mutating.py +39 -0
- ocmo_cli/_notice.py +17 -0
- ocmo_cli/_options.py +77 -0
- ocmo_cli/_output.py +424 -0
- ocmo_cli/_output_manifest.py +332 -0
- ocmo_cli/_resolve_draft.py +50 -0
- ocmo_cli/_resolve_options.py +143 -0
- ocmo_cli/_resolve_output.py +228 -0
- ocmo_cli/_resolve_parameters_output.py +72 -0
- ocmo_cli/_resolve_series.py +89 -0
- ocmo_cli/_resolve_series_chart.py +409 -0
- ocmo_cli/_resolve_write.py +185 -0
- ocmo_cli/_resolver_output.py +34 -0
- ocmo_cli/_resource_aliases.py +22 -0
- ocmo_cli/_sdk_dispatch.py +310 -0
- ocmo_cli/_typing.py +27 -0
- ocmo_cli/_version_output.py +39 -0
- ocmo_cli/_whoami_output.py +43 -0
- ocmo_cli/commands/__init__.py +1 -0
- ocmo_cli/commands/api.py +125 -0
- ocmo_cli/commands/api_health.py +12 -0
- ocmo_cli/commands/apply.py +153 -0
- ocmo_cli/commands/auth.py +786 -0
- ocmo_cli/commands/can_i.py +51 -0
- ocmo_cli/commands/completion.py +59 -0
- ocmo_cli/commands/config_cmd.py +235 -0
- ocmo_cli/commands/describe.py +109 -0
- ocmo_cli/commands/diff.py +86 -0
- ocmo_cli/commands/edit.py +200 -0
- ocmo_cli/commands/export.py +242 -0
- ocmo_cli/commands/generated.py +907 -0
- ocmo_cli/commands/gp_move.py +108 -0
- ocmo_cli/commands/import_.py +595 -0
- ocmo_cli/commands/item_relocate.py +187 -0
- ocmo_cli/commands/ls.py +481 -0
- ocmo_cli/commands/resolve.py +658 -0
- ocmo_cli/commands/resolve_draft.py +102 -0
- ocmo_cli/commands/resolve_parameters.py +65 -0
- ocmo_cli/commands/resolve_series.py +109 -0
- ocmo_cli/commands/schema.py +112 -0
- ocmo_cli/commands/timeline.py +135 -0
- ocmo_cli/commands/version_cmd.py +102 -0
- ocmo_cli/commands/whoami.py +12 -0
- ocmo_cli/main.py +241 -0
- ocmo_cli/output_formats.yaml +295 -0
- ocmo_cli-0.8.19.dist-info/METADATA +169 -0
- ocmo_cli-0.8.19.dist-info/RECORD +81 -0
- ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
- ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
- 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")
|