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,409 @@
1
+ """ASCII resolve-series chart output (simplified frontend ResolveStatsChart)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from typing import Any, cast
10
+
11
+ from ._output import format_datetime
12
+ from ._resolve_series import bucket_values
13
+
14
+ CHART_HEIGHT = 18
15
+ Y_AXIS_WIDTH = 5
16
+
17
+ _SERIES_MARKERS = {
18
+ "cyan": "●",
19
+ "yellow": "◆",
20
+ "red": "✕",
21
+ }
22
+ _COLLISION_MARKER = "⊕"
23
+ _COLLISION_STYLE = "bold white"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class _Cell:
28
+ char: str
29
+ style: str = ""
30
+
31
+
32
+ def _chart_width() -> int:
33
+ try:
34
+ columns = shutil.get_terminal_size(fallback=(80, 24)).columns
35
+ except OSError:
36
+ columns = 80
37
+ return max(24, min(columns - Y_AXIS_WIDTH - 2, 96))
38
+
39
+
40
+ def _resample(values: list[int], width: int) -> list[int]:
41
+ if width <= 0:
42
+ return []
43
+ if not values:
44
+ return [0] * width
45
+ if len(values) <= width:
46
+ return list(values)
47
+ result: list[int] = []
48
+ for index in range(width):
49
+ start = index * len(values) // width
50
+ end = (index + 1) * len(values) // width
51
+ chunk = values[start:end] or [0]
52
+ result.append(max(chunk))
53
+ return result
54
+
55
+
56
+ def _bucket_x(index: int, count: int, width: int) -> int:
57
+ """Map bucket index to plot column (spread across full chart width)."""
58
+ if count <= 1:
59
+ return width // 2
60
+ return int(round(index * (width - 1) / (count - 1)))
61
+
62
+
63
+ def _series_plot_points(values: list[int], width: int) -> list[tuple[int, int]]:
64
+ """Return (x, value) pairs covering the full plot width."""
65
+ if not values or width <= 0:
66
+ return []
67
+ if len(values) > width:
68
+ sampled = _resample(values, width)
69
+ return [(index, value) for index, value in enumerate(sampled)]
70
+ return [(_bucket_x(index, len(values), width), value) for index, value in enumerate(values)]
71
+
72
+
73
+ def _value_to_row(value: int, *, peak: int, height: int) -> int:
74
+ if peak <= 0 or value <= 0:
75
+ return height - 1
76
+ ratio = value / peak
77
+ return height - 1 - int(round(ratio * (height - 1)))
78
+
79
+
80
+ def _draw_line(
81
+ grid: list[list[_Cell]],
82
+ x0: int,
83
+ y0: int,
84
+ x1: int,
85
+ y1: int,
86
+ *,
87
+ style: str,
88
+ ) -> None:
89
+ width = len(grid[0])
90
+ height = len(grid)
91
+ dx = abs(x1 - x0)
92
+ dy = -abs(y1 - y0)
93
+ sx = 1 if x0 < x1 else -1
94
+ sy = 1 if y0 < y1 else -1
95
+ err = dx + dy
96
+ x, y = x0, y0
97
+ while True:
98
+ if 0 <= x < width and 0 <= y < height:
99
+ current = grid[y][x]
100
+ if current.char in (" ", "·"):
101
+ grid[y][x] = _Cell("·", style)
102
+ if x == x1 and y == y1:
103
+ break
104
+ e2 = 2 * err
105
+ if e2 >= dy:
106
+ err += dy
107
+ x += sx
108
+ if e2 <= dx:
109
+ err += dx
110
+ y += sy
111
+
112
+
113
+ def _place_marker(
114
+ grid: list[list[_Cell]],
115
+ x: int,
116
+ y: int,
117
+ *,
118
+ style: str,
119
+ marker: str,
120
+ ) -> None:
121
+ height = len(grid)
122
+ width = len(grid[0])
123
+ if not (0 <= x < width and 0 <= y < height):
124
+ return
125
+ current = grid[y][x]
126
+ if current.char in (" ", "·"):
127
+ grid[y][x] = _Cell(marker, style)
128
+ return
129
+ if current.char == marker and current.style == style:
130
+ return
131
+ grid[y][x] = _Cell(_COLLISION_MARKER, _COLLISION_STYLE)
132
+
133
+
134
+ def _plot_series(
135
+ grid: list[list[_Cell]],
136
+ values: list[int],
137
+ *,
138
+ width: int,
139
+ height: int,
140
+ peak: int,
141
+ style: str,
142
+ marker: str,
143
+ ) -> None:
144
+ plot_points = _series_plot_points(values, width)
145
+ points = [(x, _value_to_row(value, peak=peak, height=height)) for x, value in plot_points]
146
+ if not points:
147
+ return
148
+ for (x0, y0), (x1, y1) in zip(points, points[1:], strict=False):
149
+ _draw_line(grid, x0, y0, x1, y1, style=style)
150
+ for x, value in plot_points:
151
+ if value <= 0:
152
+ continue
153
+ y = _value_to_row(value, peak=peak, height=height)
154
+ _place_marker(grid, x, y, style=style, marker=marker)
155
+
156
+
157
+ def _empty_grid(width: int, height: int) -> list[list[_Cell]]:
158
+ return [[_Cell(" ") for _ in range(width)] for _ in range(height)]
159
+
160
+
161
+ def _y_axis_labels(peak: int, height: int) -> list[str]:
162
+ if peak <= 0:
163
+ return ["0"] * height
164
+ labels: list[str] = []
165
+ for row in range(height):
166
+ value = int(round(peak * (height - 1 - row) / max(height - 1, 1)))
167
+ labels.append(str(value))
168
+ return labels
169
+
170
+
171
+ def _build_chart_grid(
172
+ direct: list[int],
173
+ nested: list[int],
174
+ errors: list[int],
175
+ *,
176
+ width: int,
177
+ show_nested: bool,
178
+ ) -> tuple[list[list[_Cell]], int]:
179
+ peak = max(1, *direct, *nested, *errors)
180
+ grid = _empty_grid(width, CHART_HEIGHT)
181
+ _plot_series(
182
+ grid,
183
+ errors,
184
+ width=width,
185
+ height=CHART_HEIGHT,
186
+ peak=peak,
187
+ style="red",
188
+ marker=_SERIES_MARKERS["red"],
189
+ )
190
+ if show_nested:
191
+ _plot_series(
192
+ grid,
193
+ nested,
194
+ width=width,
195
+ height=CHART_HEIGHT,
196
+ peak=peak,
197
+ style="yellow",
198
+ marker=_SERIES_MARKERS["yellow"],
199
+ )
200
+ _plot_series(
201
+ grid,
202
+ direct,
203
+ width=width,
204
+ height=CHART_HEIGHT,
205
+ peak=peak,
206
+ style="cyan",
207
+ marker=_SERIES_MARKERS["cyan"],
208
+ )
209
+ return grid, peak
210
+
211
+
212
+ def _x_axis_tick_row(bucket_count: int, width: int) -> list[_Cell]:
213
+ """One tick per bucket column; duplicate x positions share a single tick."""
214
+ row = [_Cell("─", "dim") for _ in range(width)]
215
+ if bucket_count <= 0:
216
+ return row
217
+ for index in range(bucket_count):
218
+ x = _bucket_x(index, bucket_count, width)
219
+ if 0 <= x < width:
220
+ row[x] = _Cell("┴", "dim")
221
+ return row
222
+
223
+
224
+ def _bucket_axis_labels(buckets: list[dict[str, Any]]) -> tuple[str, str]:
225
+ if not buckets:
226
+ return "", ""
227
+ left = format_datetime(buckets[0].get("start"))
228
+ right = format_datetime(buckets[-1].get("start"))
229
+ return left, right
230
+
231
+
232
+ def _direct_label(node_type: str) -> str:
233
+ return "Resolves" if node_type == "resolver" else "Direct"
234
+
235
+
236
+ def _use_color() -> bool:
237
+ if os.environ.get("NO_COLOR") or os.environ.get("OCMO_NO_COLOR"):
238
+ return False
239
+ return sys.stdout.isatty()
240
+
241
+
242
+ def _format_bucket_rows(
243
+ buckets: list[dict[str, Any]],
244
+ ) -> list[tuple[str, str, str, str]]:
245
+ rows: list[tuple[str, str, str, str]] = []
246
+ for bucket in buckets:
247
+ rows.append(
248
+ (
249
+ format_datetime(bucket.get("start")),
250
+ str(int(bucket.get("direct") or 0)),
251
+ str(int(bucket.get("nested") or 0)),
252
+ str(int(bucket.get("errors") or 0)),
253
+ )
254
+ )
255
+ return rows
256
+
257
+
258
+ def _print_plain_bucket_table(
259
+ rows: list[tuple[str, str, str, str]],
260
+ *,
261
+ show_nested: bool,
262
+ direct_label: str,
263
+ ) -> None:
264
+ if show_nested:
265
+ headers: tuple[str, ...] = ("When", direct_label, "Nested", "Errors")
266
+ data_rows = cast(list[tuple[str, ...]], rows)
267
+ else:
268
+ headers = ("When", direct_label, "Errors")
269
+ data_rows = [(when, direct, errors) for when, direct, _nested, errors in rows]
270
+
271
+ widths = [len(header) for header in headers]
272
+ for row in data_rows:
273
+ for index, cell in enumerate(row):
274
+ widths[index] = max(widths[index], len(cell))
275
+
276
+ def _line(cells: tuple[str, ...]) -> str:
277
+ parts = [
278
+ cells[0].ljust(widths[0]),
279
+ *[cells[i].rjust(widths[i]) for i in range(1, len(cells))],
280
+ ]
281
+ return " ".join(parts)
282
+
283
+ print(_line(headers))
284
+ print(_line(tuple("-" * width for width in widths)))
285
+ for row in data_rows:
286
+ print(_line(row))
287
+
288
+
289
+ def print_resolve_series_chart(
290
+ data: dict[str, Any],
291
+ *,
292
+ node_type: str,
293
+ range_start: Any,
294
+ range_end: Any,
295
+ ) -> None:
296
+ """Print a coloured line chart and per-bucket value table to stdout."""
297
+ buckets, direct, nested, errors = bucket_values(data)
298
+ width = _chart_width()
299
+ show_nested = node_type != "resolver"
300
+ direct_label = _direct_label(node_type)
301
+
302
+ totals = {
303
+ "direct": sum(direct),
304
+ "nested": sum(nested),
305
+ "errors": sum(errors),
306
+ }
307
+ has_activity = any(value > 0 for series in (direct, nested, errors) for value in series)
308
+
309
+ start_label = format_datetime(range_start)
310
+ end_label = format_datetime(range_end)
311
+ grid, peak = _build_chart_grid(
312
+ direct,
313
+ nested,
314
+ errors,
315
+ width=width,
316
+ show_nested=show_nested,
317
+ )
318
+ y_labels = _y_axis_labels(peak, CHART_HEIGHT)
319
+ tick_row = _x_axis_tick_row(len(buckets), width)
320
+ axis_left, axis_right = _bucket_axis_labels(buckets)
321
+ bucket_rows = _format_bucket_rows(buckets)
322
+
323
+ if _use_color():
324
+ from rich.console import Console
325
+ from rich.table import Table
326
+ from rich.text import Text
327
+
328
+ console = Console()
329
+ header = Text()
330
+ header.append("Resolves ", style="bold")
331
+ header.append(f"{start_label} – {end_label}")
332
+ console.print(header)
333
+
334
+ legend = Text(" ")
335
+ legend.append("● ", style="bold cyan")
336
+ legend.append(f"{direct_label} {totals['direct']} ")
337
+ if show_nested:
338
+ legend.append("◆ ", style="bold yellow")
339
+ legend.append(f"Nested {totals['nested']} ")
340
+ legend.append("✕ ", style="bold red")
341
+ legend.append(f"Errors {totals['errors']} ")
342
+ legend.append("⊕ ", style="bold white")
343
+ legend.append("overlap")
344
+ console.print(legend)
345
+
346
+ for row_index in range(CHART_HEIGHT):
347
+ line = Text()
348
+ line.append(f"{y_labels[row_index]:>{Y_AXIS_WIDTH}} ", style="dim")
349
+ for cell in grid[row_index]:
350
+ line.append(cell.char, style=cell.style or None)
351
+ console.print(line)
352
+
353
+ tick_line = Text()
354
+ tick_line.append(" " * (Y_AXIS_WIDTH + 1))
355
+ for cell in tick_row:
356
+ tick_line.append(cell.char, style=cell.style or "dim")
357
+ console.print(tick_line)
358
+
359
+ axis = Text(" " * (Y_AXIS_WIDTH + 1))
360
+ axis.append(axis_left, style="dim")
361
+ if axis_right and axis_right != axis_left:
362
+ axis.append(" " * max(1, width - len(axis_left) - len(axis_right)), style="dim")
363
+ axis.append(axis_right, style="dim")
364
+ console.print(axis)
365
+
366
+ if buckets and not has_activity:
367
+ console.print("No resolve activity in this range", style="dim")
368
+ elif bucket_rows:
369
+ table = Table(show_header=True, header_style="bold", pad_edge=False)
370
+ table.add_column("When", style="dim", no_wrap=True)
371
+ table.add_column(direct_label, justify="right", style="cyan")
372
+ if show_nested:
373
+ table.add_column("Nested", justify="right", style="yellow")
374
+ table.add_column("Errors", justify="right", style="red")
375
+ for when, d_val, n_val, e_val in bucket_rows:
376
+ if show_nested:
377
+ table.add_row(when, d_val, n_val, e_val)
378
+ else:
379
+ table.add_row(when, d_val, e_val)
380
+ console.print()
381
+ console.print(table)
382
+ return
383
+
384
+ print(f"Resolves {start_label} – {end_label}")
385
+ legend_parts = [f"{direct_label} {totals['direct']}"]
386
+ if show_nested:
387
+ legend_parts.append(f"Nested {totals['nested']}")
388
+ legend_parts.append(f"Errors {totals['errors']}")
389
+ print(" " + " ".join(legend_parts) + " ⊕ overlap")
390
+
391
+ for row_index in range(CHART_HEIGHT):
392
+ body = "".join(cell.char for cell in grid[row_index])
393
+ print(f"{y_labels[row_index]:>{Y_AXIS_WIDTH}} {body}")
394
+ print(f"{' ' * Y_AXIS_WIDTH} {''.join(cell.char for cell in tick_row)}")
395
+ print(f"{' ' * (Y_AXIS_WIDTH + 1)}{axis_left}", end="")
396
+ if axis_right and axis_right != axis_left:
397
+ print(f"{' ' * max(1, width - len(axis_left) - len(axis_right))}{axis_right}")
398
+ else:
399
+ print()
400
+
401
+ if buckets and not has_activity:
402
+ print("No resolve activity in this range")
403
+ elif bucket_rows:
404
+ print()
405
+ _print_plain_bucket_table(
406
+ bucket_rows,
407
+ show_nested=show_nested,
408
+ direct_label=direct_label,
409
+ )
@@ -0,0 +1,185 @@
1
+ """Filesystem output for ``ocmo resolve``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from ._typing import ResolvedArtifact
13
+
14
+ WriteResult = Literal["created", "skipped", "rewritten", "failed"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ResolveWriteOutcome:
19
+ item: ResolvedArtifact
20
+ path: Path
21
+ result: WriteResult
22
+ detail: str | None = None
23
+
24
+
25
+ def _absolute_path(path: Path) -> Path:
26
+ return path.resolve()
27
+
28
+
29
+ def _outcome(
30
+ item: ResolvedArtifact,
31
+ dest: Path,
32
+ result: WriteResult,
33
+ detail: str | None = None,
34
+ ) -> ResolveWriteOutcome:
35
+ return ResolveWriteOutcome(item, _absolute_path(dest), result, detail)
36
+
37
+
38
+ def parse_checksum(checksum: str | None) -> tuple[str, str] | None:
39
+ if not checksum:
40
+ return None
41
+ if ":" in checksum:
42
+ algo, expected = checksum.split(":", 1)
43
+ return algo, expected
44
+ return "sha256", checksum
45
+
46
+
47
+ def digest_bytes(data: bytes, algo: str = "sha256") -> str:
48
+ return hashlib.new(algo, data).hexdigest()
49
+
50
+
51
+ def _file_digest(path: Path, algo: str) -> str:
52
+ return digest_bytes(path.read_bytes(), algo)
53
+
54
+
55
+ def file_matches_checksum(path: Path, checksum: str | None) -> bool:
56
+ parsed = parse_checksum(checksum)
57
+ if parsed is None or not path.is_file():
58
+ return False
59
+ algo, expected = parsed
60
+ return _file_digest(path, algo) == expected
61
+
62
+
63
+ def _safe_join(base: Path, name: str) -> Path:
64
+ if os.path.isabs(name):
65
+ raise ValueError(f"Absolute path in server response: {name!r}")
66
+ resolved = (base / name).resolve()
67
+ if not str(resolved).startswith(str(base.resolve())):
68
+ raise ValueError(f"Path traversal detected: {name!r} escapes {base}")
69
+ return base / name
70
+
71
+
72
+ def atomic_write(path: Path, data: bytes) -> None:
73
+ tmp = Path(str(path) + ".tmp")
74
+ try:
75
+ with tmp.open("wb") as f:
76
+ f.write(data)
77
+ tmp.replace(path)
78
+ except Exception:
79
+ tmp.unlink(missing_ok=True)
80
+ raise
81
+
82
+
83
+ def save_resolve_item(
84
+ item: ResolvedArtifact,
85
+ dest: Path,
86
+ *,
87
+ rewrite: bool,
88
+ skip_existing: bool,
89
+ data: bytes | None = None,
90
+ skip_checksum: bool = False,
91
+ ) -> ResolveWriteOutcome:
92
+ """Save one resolved artifact with checksum-aware idempotency."""
93
+ dest.parent.mkdir(parents=True, exist_ok=True)
94
+ checksum = None if skip_checksum else getattr(item, "checksum", None)
95
+
96
+ if not skip_checksum and dest.is_file() and file_matches_checksum(dest, checksum):
97
+ return _outcome(item, dest, "skipped", "unchanged")
98
+
99
+ payload = data if data is not None else item.bytes
100
+
101
+ if dest.is_file():
102
+ if payload == dest.read_bytes():
103
+ return _outcome(item, dest, "skipped", "unchanged")
104
+ if skip_existing:
105
+ return _outcome(item, dest, "skipped", "exists")
106
+ if not rewrite:
107
+ return _outcome(item, dest, "failed", "file exists and differs")
108
+ atomic_write(dest, payload)
109
+ return _outcome(item, dest, "rewritten")
110
+
111
+ atomic_write(dest, payload)
112
+ return _outcome(item, dest, "created")
113
+
114
+
115
+ def resolve_item_dest(
116
+ item: ResolvedArtifact,
117
+ *,
118
+ output_file: str | None,
119
+ output_dir: str,
120
+ ) -> Path:
121
+ if output_file:
122
+ return Path(output_file)
123
+ name = getattr(item, "name", None) or str(item)
124
+ return _safe_join(Path(output_dir), name)
125
+
126
+
127
+ def write_resolve_items(
128
+ items: Sequence[ResolvedArtifact],
129
+ *,
130
+ output_file: str | None = None,
131
+ output_dir: str | None = None,
132
+ rewrite: bool = False,
133
+ skip_existing: bool = False,
134
+ item_data: Callable[[ResolvedArtifact], tuple[bytes, bool]] | None = None,
135
+ ) -> list[ResolveWriteOutcome]:
136
+ if output_file and output_dir:
137
+ raise ValueError("Specify only one of output_file or output_dir")
138
+ if output_file:
139
+ if len(items) != 1:
140
+ raise ValueError("output_file requires exactly one resolved item")
141
+ item = items[0]
142
+ data, skip_checksum = _item_write_options(item, item_data)
143
+ return [
144
+ save_resolve_item(
145
+ item,
146
+ resolve_item_dest(item, output_file=output_file, output_dir="."),
147
+ rewrite=rewrite,
148
+ skip_existing=skip_existing,
149
+ data=data,
150
+ skip_checksum=skip_checksum,
151
+ )
152
+ ]
153
+
154
+ if not output_dir:
155
+ raise ValueError("output_dir is required when output_file is not set")
156
+
157
+ out = Path(output_dir)
158
+ out.mkdir(parents=True, exist_ok=True)
159
+ outcomes: list[ResolveWriteOutcome] = []
160
+ for item in items:
161
+ data, skip_checksum = _item_write_options(item, item_data)
162
+ dest = resolve_item_dest(item, output_file=None, output_dir=output_dir)
163
+ outcomes.append(
164
+ save_resolve_item(
165
+ item,
166
+ dest,
167
+ rewrite=rewrite,
168
+ skip_existing=skip_existing,
169
+ data=data,
170
+ skip_checksum=skip_checksum,
171
+ )
172
+ )
173
+ return outcomes
174
+
175
+
176
+ def _item_write_options(
177
+ item: ResolvedArtifact,
178
+ item_data: Callable[[ResolvedArtifact], tuple[bytes, bool]] | None,
179
+ ) -> tuple[bytes, bool]:
180
+ if item_data is None:
181
+ return item.bytes, False
182
+ if not callable(item_data):
183
+ raise TypeError("item_data must be callable")
184
+ data, skip_checksum = item_data(item)
185
+ return data, skip_checksum
@@ -0,0 +1,34 @@
1
+ """Print resolver tokens returned once on create or rotation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._output import as_dict
8
+
9
+ _TOKEN_FIELDS = ("token1", "token")
10
+
11
+
12
+ def resolver_token_from_result(result: Any) -> str | None:
13
+ """Return a full resolver token from a create/rotate API response."""
14
+ if result is None:
15
+ return None
16
+
17
+ data = as_dict(result, fallback_vars=False)
18
+ if not data:
19
+ return None
20
+
21
+ for key in _TOKEN_FIELDS:
22
+ value = data.get(key)
23
+ if isinstance(value, str) and value and not value.startswith("ocmo***"):
24
+ return value
25
+ return None
26
+
27
+
28
+ def print_resolver_token(result: Any) -> bool:
29
+ """Print a resolver token to stdout when present. Returns whether anything was printed."""
30
+ token = resolver_token_from_result(result)
31
+ if not token:
32
+ return False
33
+ print(token)
34
+ return True
@@ -0,0 +1,22 @@
1
+ """Canonical resource name aliases for CLI command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ RESOURCE_ALIASES: dict[str, list[str]] = {
6
+ "namespace": ["ns", "namespaces"],
7
+ "config": ["cfg", "configs"],
8
+ "template": ["tpl", "templates"],
9
+ "secret": ["sec", "secrets"],
10
+ "resolver": ["rsv", "resolvers"],
11
+ "folder": ["dir", "folders"],
12
+ "item": [],
13
+ "lock": ["locks"],
14
+ "version": ["ver", "versions"],
15
+ "audit": ["events"],
16
+ "globalpermission": ["gp", "globalpermissions"],
17
+ "cast": [],
18
+ "tree": [],
19
+ "parameters": [],
20
+ "draft": [],
21
+ "token": [],
22
+ }