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
|
@@ -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
|
+
}
|