salt-api-cli 1.2.0__tar.gz → 1.3.0__tar.gz
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.
- {salt_api_cli-1.2.0/salt_api_cli.egg-info → salt_api_cli-1.3.0}/PKG-INFO +1 -1
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli/cli.py +3 -3
- salt_api_cli-1.3.0/salt_api_cli/highlevel.py +538 -0
- salt_api_cli-1.3.0/salt_api_cli/version.py +1 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0/salt_api_cli.egg-info}/PKG-INFO +1 -1
- salt_api_cli-1.2.0/salt_api_cli/highlevel.py +0 -255
- salt_api_cli-1.2.0/salt_api_cli/version.py +0 -1
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/MANIFEST.in +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/README.md +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/pyproject.toml +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli/lowlevel.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli.egg-info/SOURCES.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli.egg-info/requires.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.3.0}/setup.cfg +0 -0
|
@@ -62,10 +62,10 @@ def _run_client(cfg: Config, client: str, args: argparse.Namespace) -> None:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def _run_state(cfg: Config, args: argparse.Namespace) -> None:
|
|
65
|
-
def
|
|
66
|
-
return call(cfg,
|
|
65
|
+
def client(name: str, **kw: Any) -> dict[str, Any]:
|
|
66
|
+
return call(cfg, name, **kw)
|
|
67
67
|
|
|
68
|
-
highlevel.run_state(args,
|
|
68
|
+
highlevel.run_state(args, client)
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def _run_keys(cfg: Config, args: argparse.Namespace) -> None:
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""High-level, human-readable commands for salt-api-cli.
|
|
2
|
+
|
|
3
|
+
The low-level commands (``local`` / ``runner`` / ``wheel``) are thin
|
|
4
|
+
passthroughs that dump raw salt-api JSON. The commands here are the
|
|
5
|
+
opposite: each knows the *shape* of a specific salt workflow and renders it
|
|
6
|
+
with :mod:`rich` for a human at a terminal, layered over the low-level
|
|
7
|
+
client in :mod:`salt_api_cli.lowlevel`.
|
|
8
|
+
|
|
9
|
+
* ``run_state`` — the ``salt state`` command (``highstate`` / ``apply`` /
|
|
10
|
+
``test``). It fires the ``state.*`` job through the ``local_async`` client
|
|
11
|
+
(which returns a job id immediately, dodging the proxy/gateway connection
|
|
12
|
+
cap that kills a long synchronous highstate) and then polls the ``runner``
|
|
13
|
+
``jobs.lookup_jid`` for results, showing a progress bar as minions report
|
|
14
|
+
back and rendering the coloured per-minion tables once the run completes —
|
|
15
|
+
instead of the wall of JSON the raw ``local`` command would emit.
|
|
16
|
+
* ``run_keys`` — the ``salt keys`` command, layered over ``wheel key.*``.
|
|
17
|
+
``keys list`` shows one coloured panel per acceptance status (Accepted /
|
|
18
|
+
Pending / Denied / Rejected).
|
|
19
|
+
|
|
20
|
+
Each command receives an injected ``call`` callable (bound to the right
|
|
21
|
+
client in cli.py), so this module never owns transport details. Colour and
|
|
22
|
+
box-drawing are handled by ``rich.Console``, which auto-disables them when
|
|
23
|
+
output is piped to a file or pager.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from typing import Any, Callable, cast
|
|
33
|
+
|
|
34
|
+
from rich.columns import Columns
|
|
35
|
+
from rich.console import Console, Group
|
|
36
|
+
from rich.live import Live
|
|
37
|
+
from rich.padding import Padding
|
|
38
|
+
from rich.panel import Panel
|
|
39
|
+
from rich.spinner import Spinner
|
|
40
|
+
from rich.table import Table
|
|
41
|
+
from rich.text import Text
|
|
42
|
+
|
|
43
|
+
from salt_api_cli.lowlevel import SaltApiError, split_args
|
|
44
|
+
|
|
45
|
+
console = Console()
|
|
46
|
+
|
|
47
|
+
# (ASCII marker, rich style) for each per-state status. ASCII markers stay
|
|
48
|
+
# legible on any console; rich supplies the colour.
|
|
49
|
+
_STATUS_STYLE = {
|
|
50
|
+
"ok": ("+", "green"), # ran, no changes
|
|
51
|
+
"change": ("*", "green"), # ran, made changes
|
|
52
|
+
"diff": ("~", "yellow"), # test=True: would change
|
|
53
|
+
"fail": ("X", "bold red"), # failed
|
|
54
|
+
"skip": (".", "dim"), # requisites unmet, not run
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# wheel key.list_all groups minion IDs under these keys; each renders as a
|
|
58
|
+
# panel whose border colour signals the acceptance status.
|
|
59
|
+
_KEY_PANELS = {
|
|
60
|
+
"minions": ("Accepted", "green"),
|
|
61
|
+
"minions_pre": ("Pending", "yellow"),
|
|
62
|
+
"minions_denied": ("Denied", "red"),
|
|
63
|
+
"minions_rejected": ("Rejected", "red"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# state rendering
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_state_return(val: Any) -> bool:
|
|
73
|
+
"""True if ``val`` is a state return: a non-empty dict whose every value
|
|
74
|
+
is itself a dict carrying a ``result`` key (the per-state record shape)."""
|
|
75
|
+
if not isinstance(val, dict) or not val:
|
|
76
|
+
return False
|
|
77
|
+
records = cast("dict[str, Any]", val)
|
|
78
|
+
return all(isinstance(v, dict) and "result" in v for v in records.values())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _state_status(state: dict[str, Any]) -> str:
|
|
82
|
+
"""Classify one state record into an _STATUS_STYLE key."""
|
|
83
|
+
if state.get("__state_ran__") is False:
|
|
84
|
+
return "skip"
|
|
85
|
+
result = state.get("result")
|
|
86
|
+
if result is False:
|
|
87
|
+
return "fail"
|
|
88
|
+
if result is None:
|
|
89
|
+
return "diff"
|
|
90
|
+
return "change" if state.get("changes") else "ok"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _state_function(key: str) -> str:
|
|
94
|
+
"""Recover ``module.func`` from a state key like
|
|
95
|
+
``cmd_|-veyon-installed_|-<name>_|-run`` -> ``cmd.run``."""
|
|
96
|
+
parts = key.split("_|-")
|
|
97
|
+
if len(parts) >= 2 and parts[-1]:
|
|
98
|
+
return f"{parts[0]}.{parts[-1]}"
|
|
99
|
+
return parts[0]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _short(text: str, limit: int = 100) -> str:
|
|
103
|
+
"""Collapse whitespace and truncate a comment to one tidy line."""
|
|
104
|
+
flat = " ".join(str(text).split())
|
|
105
|
+
return flat if len(flat) <= limit else flat[: limit - 3] + "..."
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fmt_duration(ms: float) -> str:
|
|
109
|
+
return f"{ms / 1000:.2f}s" if ms >= 1000 else f"{ms:.0f}ms"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _count_states(states: dict[str, Any]) -> tuple[dict[str, int], float]:
|
|
113
|
+
"""Tally per-status counts and summed duration (ms) for one minion's run.
|
|
114
|
+
|
|
115
|
+
Shared by the per-minion summary and the fleet-wide grand total."""
|
|
116
|
+
counts = {k: 0 for k in _STATUS_STYLE}
|
|
117
|
+
total_ms = 0.0
|
|
118
|
+
for state in states.values():
|
|
119
|
+
counts[_state_status(state)] += 1
|
|
120
|
+
try:
|
|
121
|
+
total_ms += float(state.get("duration", 0) or 0)
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
pass
|
|
124
|
+
return counts, total_ms
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _counts_str(counts: dict[str, int]) -> str:
|
|
128
|
+
"""The status tally as markup: ``N ok N changed N would-change
|
|
129
|
+
N skipped N failed``. ``ok`` and ``failed`` always show; the rest only
|
|
130
|
+
when non-zero."""
|
|
131
|
+
parts = [f"[green]{counts['ok']} ok[/]"]
|
|
132
|
+
if counts["change"]:
|
|
133
|
+
parts.append(f"[green]{counts['change']} changed[/]")
|
|
134
|
+
if counts["diff"]:
|
|
135
|
+
parts.append(f"[yellow]{counts['diff']} would-change[/]")
|
|
136
|
+
if counts["skip"]:
|
|
137
|
+
parts.append(f"[dim]{counts['skip']} skipped[/]")
|
|
138
|
+
parts.append(
|
|
139
|
+
f"[red]{counts['fail']} failed[/]"
|
|
140
|
+
if counts["fail"]
|
|
141
|
+
else f"{counts['fail']} failed"
|
|
142
|
+
)
|
|
143
|
+
return " ".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _summary_line(counts: dict[str, int], took: str) -> str:
|
|
147
|
+
""":func:`_counts_str` with a trailing ``took Xs`` (a preformatted
|
|
148
|
+
duration)."""
|
|
149
|
+
return f"{_counts_str(counts)} [dim]took {took}[/]"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _grand_totals(returns: dict[str, Any]) -> tuple[dict[str, int], int]:
|
|
153
|
+
"""Sum state counts across every minion that produced a state return,
|
|
154
|
+
plus the number of such minions."""
|
|
155
|
+
totals = {k: 0 for k in _STATUS_STYLE}
|
|
156
|
+
n = 0
|
|
157
|
+
for val in returns.values():
|
|
158
|
+
if not _is_state_return(val):
|
|
159
|
+
continue
|
|
160
|
+
n += 1
|
|
161
|
+
counts, _ = _count_states(val)
|
|
162
|
+
for k in totals:
|
|
163
|
+
totals[k] += counts[k]
|
|
164
|
+
return totals, n
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _print_state_return(minion: str, states: dict[str, Any]) -> None:
|
|
168
|
+
"""Render one minion's state run: header, a table of states, summary."""
|
|
169
|
+
ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
|
|
170
|
+
|
|
171
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
172
|
+
table.add_column("marker", no_wrap=True)
|
|
173
|
+
table.add_column("function", style="cyan", no_wrap=True)
|
|
174
|
+
table.add_column("ref", style="dim", no_wrap=True)
|
|
175
|
+
table.add_column("detail", no_wrap=True, overflow="ellipsis")
|
|
176
|
+
|
|
177
|
+
for key, state in ordered:
|
|
178
|
+
status = _state_status(state)
|
|
179
|
+
marker, style = _STATUS_STYLE[status]
|
|
180
|
+
ref = f"{state.get('__sls__', '?')}:{state.get('__id__', key)}"
|
|
181
|
+
if status == "ok":
|
|
182
|
+
detail: str | Text = ""
|
|
183
|
+
elif status == "change":
|
|
184
|
+
changed = ", ".join(state.get("changes", {})) or "(changes)"
|
|
185
|
+
detail = f"changed: {_short(changed)}"
|
|
186
|
+
elif status == "fail":
|
|
187
|
+
detail = Text(_short(state.get("comment", ""), 240), style="red")
|
|
188
|
+
else: # diff / skip
|
|
189
|
+
detail = _short(state.get("comment", ""))
|
|
190
|
+
table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
|
|
191
|
+
|
|
192
|
+
counts, total_ms = _count_states(states)
|
|
193
|
+
console.print(Text(minion, style="bold"))
|
|
194
|
+
console.print(Padding(table, (0, 0, 0, 2)))
|
|
195
|
+
console.print(" [dim]---[/]")
|
|
196
|
+
console.print(f" {_summary_line(counts, _fmt_duration(total_ms))}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _print_one_minion(minion: str, val: Any) -> None:
|
|
200
|
+
"""Render a single minion's return block.
|
|
201
|
+
|
|
202
|
+
A state return gets the coloured table; anything else (a render/compile
|
|
203
|
+
error, where salt answers with a list of message lines, or some other
|
|
204
|
+
shape) falls back to its lines or indented JSON."""
|
|
205
|
+
if _is_state_return(val):
|
|
206
|
+
_print_state_return(minion, val)
|
|
207
|
+
return
|
|
208
|
+
console.print(Text(minion, style="bold"))
|
|
209
|
+
if isinstance(val, list):
|
|
210
|
+
for item in cast("list[Any]", val):
|
|
211
|
+
console.print(Padding(Text(str(item)), (0, 0, 0, 2)))
|
|
212
|
+
else:
|
|
213
|
+
console.print(Padding(json.dumps(val, indent=2), (0, 0, 0, 2)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_state_result(result: dict[str, Any]) -> None:
|
|
217
|
+
"""Render a state return, one block per minion (all at once).
|
|
218
|
+
|
|
219
|
+
Falls back to indented JSON for anything that isn't a state return."""
|
|
220
|
+
ret_list: Any = result.get("return")
|
|
221
|
+
if not ret_list:
|
|
222
|
+
console.print_json(json.dumps(result))
|
|
223
|
+
return
|
|
224
|
+
ret: dict[str, Any] = ret_list[0]
|
|
225
|
+
if not ret:
|
|
226
|
+
console.print("(no minions responded)")
|
|
227
|
+
return
|
|
228
|
+
for minion in sorted(ret):
|
|
229
|
+
_print_one_minion(minion, ret[minion])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# How often to poll jobs.lookup_jid, and how long to keep waiting overall
|
|
233
|
+
# before giving up on minions that never reported. Each poll is a fast,
|
|
234
|
+
# self-contained request, so the proxy/gateway connection cap never bites.
|
|
235
|
+
_POLL_INTERVAL = 3.0
|
|
236
|
+
_POLL_DEADLINE = 1800.0 # 30 minutes (hard backstop)
|
|
237
|
+
|
|
238
|
+
# Once this many seconds pass with no new minion reporting, probe the
|
|
239
|
+
# still-outstanding minions with saltutil.find_job to tell "still running"
|
|
240
|
+
# apart from "down / lost the job" — the latter are dropped so we stop
|
|
241
|
+
# waiting on them.
|
|
242
|
+
#
|
|
243
|
+
# The probe passes BOTH a short publish ``timeout`` and a short
|
|
244
|
+
# ``gather_job_timeout``. The latter matters most: when a call targets an
|
|
245
|
+
# offline minion, the master runs its own internal find_job and waits
|
|
246
|
+
# gather_job_timeout (default ~10s on the master) for a reply that never
|
|
247
|
+
# comes — so without overriding it, flagging an offline minion costs ~10s+
|
|
248
|
+
# no matter how small ``timeout`` is. With both set low the cost drops to a
|
|
249
|
+
# few seconds (verified against this master: offline minion flagged in ~3s).
|
|
250
|
+
# find_job reports whether a minion is *running the job*, so an online minion
|
|
251
|
+
# answers within ``timeout`` and is never wrongly dropped. Instant detection
|
|
252
|
+
# would need presence_events on the master (manage.present/alived are empty).
|
|
253
|
+
_GATHER_TIMEOUT = 5.0
|
|
254
|
+
_FIND_JOB_TIMEOUT = 2.0
|
|
255
|
+
_FIND_JOB_GATHER = 2.0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _first_return(resp: dict[str, Any]) -> Any:
|
|
259
|
+
"""The first element of a salt-api ``return`` list, or ``{}`` if absent."""
|
|
260
|
+
ret = resp.get("return")
|
|
261
|
+
if isinstance(ret, list) and ret:
|
|
262
|
+
return cast("Any", ret[0])
|
|
263
|
+
return {}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _find_dead(
|
|
267
|
+
call: Callable[..., dict[str, Any]], jid: str, candidates: set[str]
|
|
268
|
+
) -> set[str]:
|
|
269
|
+
"""Return the candidates that are NOT running ``jid`` (down or lost it).
|
|
270
|
+
|
|
271
|
+
Probes only ``candidates`` via the local client + ``saltutil.find_job``
|
|
272
|
+
with a short timeout. A minion actively running the job answers with a
|
|
273
|
+
non-empty dict naming the jid; one that's down never answers, and one
|
|
274
|
+
that's up but no longer running it answers empty — both mean it won't
|
|
275
|
+
return, so it's reported dead. A failed probe reports nobody dead (we'd
|
|
276
|
+
rather wait than wrongly drop a live minion)."""
|
|
277
|
+
if not candidates:
|
|
278
|
+
return set()
|
|
279
|
+
try:
|
|
280
|
+
resp = call(
|
|
281
|
+
"local",
|
|
282
|
+
tgt=sorted(candidates),
|
|
283
|
+
tgt_type="list",
|
|
284
|
+
fun="saltutil.find_job",
|
|
285
|
+
arg=[jid],
|
|
286
|
+
timeout=_FIND_JOB_TIMEOUT,
|
|
287
|
+
gather_job_timeout=_FIND_JOB_GATHER,
|
|
288
|
+
)
|
|
289
|
+
except SaltApiError:
|
|
290
|
+
return set()
|
|
291
|
+
ret = _first_return(resp)
|
|
292
|
+
if not isinstance(ret, dict):
|
|
293
|
+
return set()
|
|
294
|
+
running = cast("dict[str, Any]", ret)
|
|
295
|
+
return {m for m in candidates if not running.get(m)}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _lookup_returns(raw: Any) -> dict[str, Any]:
|
|
299
|
+
"""Pull the ``{minion: state_return}`` map out of a jobs.lookup_jid reply.
|
|
300
|
+
|
|
301
|
+
Over salt-api the runner wraps results in a display envelope —
|
|
302
|
+
``{"outputter": "highstate", "data": {minion: ...}}`` — unlike the bare
|
|
303
|
+
``{minion: ...}`` the local client returns. Unwrap ``data`` when present,
|
|
304
|
+
and tolerate either shape (or junk) without raising."""
|
|
305
|
+
if not isinstance(raw, dict):
|
|
306
|
+
return {}
|
|
307
|
+
data = cast("dict[str, Any]", raw)
|
|
308
|
+
inner = data.get("data")
|
|
309
|
+
return cast("dict[str, Any]", inner) if isinstance(inner, dict) else data
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _count_cells(counts: dict[str, int]) -> list[Text]:
|
|
313
|
+
"""One right-padded cell per status category, for column alignment in the
|
|
314
|
+
live view. ``ok``/``failed`` always render; the rest blank when zero so
|
|
315
|
+
the column still reserves its width and rows stay aligned."""
|
|
316
|
+
blank = Text("")
|
|
317
|
+
return [
|
|
318
|
+
Text.from_markup(f"[green]{counts['ok']:>2} ok[/]"),
|
|
319
|
+
Text.from_markup(f"[green]{counts['change']:>2} changed[/]")
|
|
320
|
+
if counts["change"]
|
|
321
|
+
else blank,
|
|
322
|
+
Text.from_markup(f"[yellow]{counts['diff']:>2} would-change[/]")
|
|
323
|
+
if counts["diff"]
|
|
324
|
+
else blank,
|
|
325
|
+
Text.from_markup(f"[dim]{counts['skip']:>2} skipped[/]")
|
|
326
|
+
if counts["skip"]
|
|
327
|
+
else blank,
|
|
328
|
+
Text.from_markup(
|
|
329
|
+
f"[red]{counts['fail']:>2} failed[/]"
|
|
330
|
+
if counts["fail"]
|
|
331
|
+
else f"[dim]{counts['fail']:>2} failed[/]"
|
|
332
|
+
),
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _live_view(
|
|
337
|
+
targeted: list[str],
|
|
338
|
+
returns: dict[str, Any],
|
|
339
|
+
done: set[str],
|
|
340
|
+
dead: set[str],
|
|
341
|
+
spinner: Spinner,
|
|
342
|
+
) -> Group:
|
|
343
|
+
"""A live checklist: a tick for finished minions (with their per-state
|
|
344
|
+
tally in aligned columns), a spinner for the ones still running, an x for
|
|
345
|
+
the unreachable, under a one-line status header."""
|
|
346
|
+
blanks = [Text("")] * 5 # the five count columns, empty
|
|
347
|
+
grid = Table.grid(padding=(0, 1))
|
|
348
|
+
grid.add_column(no_wrap=True) # marker
|
|
349
|
+
grid.add_column(no_wrap=True) # minion id
|
|
350
|
+
for _ in range(5): # ok / changed / would-change / skipped / failed
|
|
351
|
+
grid.add_column(no_wrap=True, justify="left")
|
|
352
|
+
for minion in targeted:
|
|
353
|
+
if minion in dead:
|
|
354
|
+
grid.add_row(Text("✗", style="red"), Text(minion, style="dim"), *blanks)
|
|
355
|
+
elif minion in done:
|
|
356
|
+
val = returns.get(minion)
|
|
357
|
+
if _is_state_return(val):
|
|
358
|
+
counts, _ = _count_states(cast("dict[str, Any]", val))
|
|
359
|
+
cells = _count_cells(counts)
|
|
360
|
+
else:
|
|
361
|
+
cells = [Text("(no state output)", style="dim"), *blanks[1:]]
|
|
362
|
+
grid.add_row(Text("✓", style="green"), Text(minion), *cells)
|
|
363
|
+
else:
|
|
364
|
+
grid.add_row(spinner, Text(minion, style="dim"), *blanks)
|
|
365
|
+
|
|
366
|
+
pending = len(targeted) - len(done) - len(dead)
|
|
367
|
+
bits = [f"{len(done)}/{len(targeted)} done"]
|
|
368
|
+
if pending:
|
|
369
|
+
bits.append(f"{pending} running")
|
|
370
|
+
if dead:
|
|
371
|
+
bits.append(f"[red]{len(dead)} unreachable[/]")
|
|
372
|
+
header = Text.from_markup(f"[dim]{' '.join(bits)}[/]")
|
|
373
|
+
return Group(header, grid)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
|
|
377
|
+
"""Fire a state job async, show a live checklist, then render the results.
|
|
378
|
+
|
|
379
|
+
Submits via the ``local_async`` client (returns a job id at once), then
|
|
380
|
+
polls ``runner jobs.lookup_jid`` until every targeted minion has returned
|
|
381
|
+
or the deadline trips. While polling it shows a live per-minion checklist
|
|
382
|
+
(spinner -> tick). Once the run is done the live view is cleared and the
|
|
383
|
+
coloured per-minion tables print together, followed by a fleet-wide
|
|
384
|
+
summary. ``call(name, **kw)`` invokes the named salt-api client."""
|
|
385
|
+
submit = call("local_async", **payload)
|
|
386
|
+
info: Any = _first_return(submit)
|
|
387
|
+
jid = info.get("jid")
|
|
388
|
+
if not jid:
|
|
389
|
+
# No job id: nothing matched, or salt-api answered with an error body.
|
|
390
|
+
console.print_json(json.dumps(submit))
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
targeted = sorted(info.get("minions") or [])
|
|
394
|
+
if not targeted:
|
|
395
|
+
console.print("(no minions matched the target)")
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
expected = set(targeted) # shrinks as unreachable minions are dropped
|
|
399
|
+
console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
|
|
400
|
+
start = time.monotonic()
|
|
401
|
+
returns: dict[str, Any] = {}
|
|
402
|
+
dead: set[str] = set() # probed and confirmed not running the job
|
|
403
|
+
spinner = Spinner("dots", style="cyan")
|
|
404
|
+
|
|
405
|
+
# transient=False keeps the finished checklist on screen above the
|
|
406
|
+
# rendered tables, as a persistent at-a-glance record of the run.
|
|
407
|
+
with Live(console=console, refresh_per_second=12, transient=False) as live:
|
|
408
|
+
prev_done = -1
|
|
409
|
+
last_change = start
|
|
410
|
+
while True:
|
|
411
|
+
# lookup_jid is cumulative: each poll returns every minion that has
|
|
412
|
+
# reported so far, so we just keep the latest snapshot.
|
|
413
|
+
returns = _lookup_returns(
|
|
414
|
+
_first_return(call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid}))
|
|
415
|
+
)
|
|
416
|
+
done = expected & set(returns)
|
|
417
|
+
now = time.monotonic()
|
|
418
|
+
if len(done) != prev_done:
|
|
419
|
+
prev_done, last_change = len(done), now
|
|
420
|
+
live.update(_live_view(targeted, returns, done, dead, spinner))
|
|
421
|
+
|
|
422
|
+
if not expected - done:
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
# Stalled? Ask the stragglers whether they're still running the
|
|
426
|
+
# job; drop the ones that aren't (down or lost it) so we stop
|
|
427
|
+
# waiting on them instead of blocking to the deadline.
|
|
428
|
+
if now - last_change > _GATHER_TIMEOUT:
|
|
429
|
+
gone = _find_dead(call, jid, expected - done)
|
|
430
|
+
if gone:
|
|
431
|
+
dead |= gone
|
|
432
|
+
expected -= gone
|
|
433
|
+
last_change = now # don't re-probe every single poll
|
|
434
|
+
live.update(_live_view(targeted, returns, done, dead, spinner))
|
|
435
|
+
if not expected - done:
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
if now - start > _POLL_DEADLINE:
|
|
439
|
+
break
|
|
440
|
+
time.sleep(_POLL_INTERVAL)
|
|
441
|
+
|
|
442
|
+
# Live view cleared — render the coloured tables, one block per minion.
|
|
443
|
+
_print_state_result({"return": [returns]})
|
|
444
|
+
if dead:
|
|
445
|
+
console.print(
|
|
446
|
+
f"[yellow]no response from: {', '.join(sorted(dead))} "
|
|
447
|
+
f"(down, or no longer running the job)[/]"
|
|
448
|
+
)
|
|
449
|
+
stalled = sorted(expected - set(returns) - dead)
|
|
450
|
+
if stalled:
|
|
451
|
+
console.print(
|
|
452
|
+
f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
|
|
453
|
+
f"{', '.join(stalled)}[/]"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Fleet-wide summary: totals across all minions + wall-clock elapsed.
|
|
457
|
+
totals, n = _grand_totals(returns)
|
|
458
|
+
if n:
|
|
459
|
+
wall = _fmt_duration((time.monotonic() - start) * 1000.0)
|
|
460
|
+
console.print("[dim]===[/]")
|
|
461
|
+
console.print(f"[bold]{n} minion(s)[/] {_summary_line(totals, wall)}")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
|
|
465
|
+
"""The ``salt state`` command, layered over ``local_async`` + ``state.*``.
|
|
466
|
+
|
|
467
|
+
``call(name, **kw)`` must invoke the named salt-api client and return its
|
|
468
|
+
JSON (cli.py binds it to the configured connection). The job is fired
|
|
469
|
+
async and its results streamed minion-by-minion via the runner. Any
|
|
470
|
+
trailing ``key=value`` args are forwarded as kwargs to the state function
|
|
471
|
+
(e.g. ``test=True``)."""
|
|
472
|
+
pos, kw = split_args(list(getattr(args, "args", None) or []))
|
|
473
|
+
if args.action == "highstate":
|
|
474
|
+
fun, arg = "state.highstate", pos
|
|
475
|
+
elif args.action == "test":
|
|
476
|
+
fun, arg = "state.highstate", pos
|
|
477
|
+
kw["test"] = "True"
|
|
478
|
+
else: # apply <sls>
|
|
479
|
+
fun, arg = "state.apply", [args.sls, *pos]
|
|
480
|
+
|
|
481
|
+
payload: dict[str, Any] = {"tgt": args.target, "fun": fun, "arg": arg}
|
|
482
|
+
if kw:
|
|
483
|
+
payload["kwarg"] = kw
|
|
484
|
+
_stream_state(call, payload)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --------------------------------------------------------------------------
|
|
488
|
+
# key management
|
|
489
|
+
# --------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _print_key_panels(data: dict[str, Any]) -> None:
|
|
493
|
+
"""Render key.list_all as one panel per acceptance status."""
|
|
494
|
+
panels: list[Panel] = []
|
|
495
|
+
for status_key, (label, color) in _KEY_PANELS.items():
|
|
496
|
+
keys: list[str] = data.get(status_key, [])
|
|
497
|
+
body: Any = Text("\n".join(keys)) if keys else Text("(none)", style="dim")
|
|
498
|
+
panels.append(
|
|
499
|
+
Panel(
|
|
500
|
+
body,
|
|
501
|
+
title=f"{label} ({len(keys)})",
|
|
502
|
+
title_align="left",
|
|
503
|
+
border_style=color,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
console.print(Columns(panels, equal=True, expand=False))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
|
|
510
|
+
"""The ``salt keys`` command, layered over ``wheel key.*``.
|
|
511
|
+
|
|
512
|
+
``call(fun=..., **kw)`` must invoke the wheel client and return its JSON
|
|
513
|
+
(cli.py binds it to the wheel client)."""
|
|
514
|
+
action: str = args.action
|
|
515
|
+
if action == "list":
|
|
516
|
+
result = call(fun="key.list_all")
|
|
517
|
+
_print_key_panels(result["return"][0]["data"]["return"])
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
fun_map = {
|
|
521
|
+
"accept": "key.accept",
|
|
522
|
+
"accept-all": "key.accept",
|
|
523
|
+
"reject": "key.reject",
|
|
524
|
+
"delete": "key.delete",
|
|
525
|
+
}
|
|
526
|
+
match: str = "*" if action == "accept-all" else args.match
|
|
527
|
+
result = call(fun=fun_map[action], match=match)
|
|
528
|
+
data = result["return"][0]["data"]
|
|
529
|
+
if not data.get("success"):
|
|
530
|
+
sys.exit(f"failed: {data}")
|
|
531
|
+
changed: dict[str, list[str]] = data.get("return", {})
|
|
532
|
+
if not changed:
|
|
533
|
+
console.print("(no keys changed)")
|
|
534
|
+
return
|
|
535
|
+
for status_key, ids in changed.items():
|
|
536
|
+
label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
|
|
537
|
+
joined = ", ".join(ids) if ids else "[dim](none)[/]"
|
|
538
|
+
console.print(f"{label}: {joined}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.0"
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
"""High-level, human-readable commands for salt-api-cli.
|
|
2
|
-
|
|
3
|
-
The low-level commands (``local`` / ``runner`` / ``wheel``) are thin
|
|
4
|
-
passthroughs that dump raw salt-api JSON. The commands here are the
|
|
5
|
-
opposite: each knows the *shape* of a specific salt workflow and renders it
|
|
6
|
-
with :mod:`rich` for a human at a terminal, layered over the low-level
|
|
7
|
-
client in :mod:`salt_api_cli.lowlevel`.
|
|
8
|
-
|
|
9
|
-
* ``run_state`` — the ``salt state`` command (``highstate`` / ``apply`` /
|
|
10
|
-
``test``). It drives the ``local`` client with a ``state.*`` function and
|
|
11
|
-
renders a coloured table of states with a summary, instead of the wall of
|
|
12
|
-
JSON the raw ``local`` command would emit.
|
|
13
|
-
* ``run_keys`` — the ``salt keys`` command, layered over ``wheel key.*``.
|
|
14
|
-
``keys list`` shows one coloured panel per acceptance status (Accepted /
|
|
15
|
-
Pending / Denied / Rejected).
|
|
16
|
-
|
|
17
|
-
Each command receives an injected ``call`` callable (bound to the right
|
|
18
|
-
client in cli.py), so this module never owns transport details. Colour and
|
|
19
|
-
box-drawing are handled by ``rich.Console``, which auto-disables them when
|
|
20
|
-
output is piped to a file or pager.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import argparse
|
|
26
|
-
import json
|
|
27
|
-
import sys
|
|
28
|
-
from typing import Any, Callable, cast
|
|
29
|
-
|
|
30
|
-
from rich.columns import Columns
|
|
31
|
-
from rich.console import Console
|
|
32
|
-
from rich.padding import Padding
|
|
33
|
-
from rich.panel import Panel
|
|
34
|
-
from rich.table import Table
|
|
35
|
-
from rich.text import Text
|
|
36
|
-
|
|
37
|
-
from salt_api_cli.lowlevel import split_args
|
|
38
|
-
|
|
39
|
-
console = Console()
|
|
40
|
-
|
|
41
|
-
# (ASCII marker, rich style) for each per-state status. ASCII markers stay
|
|
42
|
-
# legible on any console; rich supplies the colour.
|
|
43
|
-
_STATUS_STYLE = {
|
|
44
|
-
"ok": ("+", "green"), # ran, no changes
|
|
45
|
-
"change": ("*", "green"), # ran, made changes
|
|
46
|
-
"diff": ("~", "yellow"), # test=True: would change
|
|
47
|
-
"fail": ("X", "bold red"), # failed
|
|
48
|
-
"skip": (".", "dim"), # requisites unmet, not run
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
# wheel key.list_all groups minion IDs under these keys; each renders as a
|
|
52
|
-
# panel whose border colour signals the acceptance status.
|
|
53
|
-
_KEY_PANELS = {
|
|
54
|
-
"minions": ("Accepted", "green"),
|
|
55
|
-
"minions_pre": ("Pending", "yellow"),
|
|
56
|
-
"minions_denied": ("Denied", "red"),
|
|
57
|
-
"minions_rejected": ("Rejected", "red"),
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# --------------------------------------------------------------------------
|
|
62
|
-
# state rendering
|
|
63
|
-
# --------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _is_state_return(val: Any) -> bool:
|
|
67
|
-
"""True if ``val`` is a state return: a non-empty dict whose every value
|
|
68
|
-
is itself a dict carrying a ``result`` key (the per-state record shape)."""
|
|
69
|
-
if not isinstance(val, dict) or not val:
|
|
70
|
-
return False
|
|
71
|
-
records = cast("dict[str, Any]", val)
|
|
72
|
-
return all(isinstance(v, dict) and "result" in v for v in records.values())
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _state_status(state: dict[str, Any]) -> str:
|
|
76
|
-
"""Classify one state record into an _STATUS_STYLE key."""
|
|
77
|
-
if state.get("__state_ran__") is False:
|
|
78
|
-
return "skip"
|
|
79
|
-
result = state.get("result")
|
|
80
|
-
if result is False:
|
|
81
|
-
return "fail"
|
|
82
|
-
if result is None:
|
|
83
|
-
return "diff"
|
|
84
|
-
return "change" if state.get("changes") else "ok"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _state_function(key: str) -> str:
|
|
88
|
-
"""Recover ``module.func`` from a state key like
|
|
89
|
-
``cmd_|-veyon-installed_|-<name>_|-run`` -> ``cmd.run``."""
|
|
90
|
-
parts = key.split("_|-")
|
|
91
|
-
if len(parts) >= 2 and parts[-1]:
|
|
92
|
-
return f"{parts[0]}.{parts[-1]}"
|
|
93
|
-
return parts[0]
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _short(text: str, limit: int = 100) -> str:
|
|
97
|
-
"""Collapse whitespace and truncate a comment to one tidy line."""
|
|
98
|
-
flat = " ".join(str(text).split())
|
|
99
|
-
return flat if len(flat) <= limit else flat[: limit - 3] + "..."
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _fmt_duration(ms: float) -> str:
|
|
103
|
-
return f"{ms / 1000:.2f}s" if ms >= 1000 else f"{ms:.0f}ms"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _print_state_return(minion: str, states: dict[str, Any]) -> None:
|
|
107
|
-
"""Render one minion's state run: header, a table of states, summary."""
|
|
108
|
-
ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
|
|
109
|
-
|
|
110
|
-
table = Table(box=None, show_header=False, pad_edge=False)
|
|
111
|
-
table.add_column("marker", no_wrap=True)
|
|
112
|
-
table.add_column("function", style="cyan", no_wrap=True)
|
|
113
|
-
table.add_column("ref", style="dim", no_wrap=True)
|
|
114
|
-
table.add_column("detail", no_wrap=True, overflow="ellipsis")
|
|
115
|
-
|
|
116
|
-
counts = {k: 0 for k in _STATUS_STYLE}
|
|
117
|
-
total_ms = 0.0
|
|
118
|
-
for key, state in ordered:
|
|
119
|
-
status = _state_status(state)
|
|
120
|
-
counts[status] += 1
|
|
121
|
-
try:
|
|
122
|
-
total_ms += float(state.get("duration", 0) or 0)
|
|
123
|
-
except (TypeError, ValueError):
|
|
124
|
-
pass
|
|
125
|
-
marker, style = _STATUS_STYLE[status]
|
|
126
|
-
ref = f"{state.get('__sls__', '?')}:{state.get('__id__', key)}"
|
|
127
|
-
if status == "ok":
|
|
128
|
-
detail: str | Text = ""
|
|
129
|
-
elif status == "change":
|
|
130
|
-
changed = ", ".join(state.get("changes", {})) or "(changes)"
|
|
131
|
-
detail = f"changed: {_short(changed)}"
|
|
132
|
-
elif status == "fail":
|
|
133
|
-
detail = Text(_short(state.get("comment", ""), 240), style="red")
|
|
134
|
-
else: # diff / skip
|
|
135
|
-
detail = _short(state.get("comment", ""))
|
|
136
|
-
table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
|
|
137
|
-
|
|
138
|
-
console.print(Text(minion, style="bold"))
|
|
139
|
-
console.print(Padding(table, (0, 0, 0, 2)))
|
|
140
|
-
|
|
141
|
-
parts = [f"[green]{counts['ok']} ok[/]"]
|
|
142
|
-
if counts["change"]:
|
|
143
|
-
parts.append(f"[green]{counts['change']} changed[/]")
|
|
144
|
-
if counts["diff"]:
|
|
145
|
-
parts.append(f"[yellow]{counts['diff']} would-change[/]")
|
|
146
|
-
if counts["skip"]:
|
|
147
|
-
parts.append(f"[dim]{counts['skip']} skipped[/]")
|
|
148
|
-
parts.append(
|
|
149
|
-
f"[red]{counts['fail']} failed[/]"
|
|
150
|
-
if counts["fail"]
|
|
151
|
-
else f"{counts['fail']} failed"
|
|
152
|
-
)
|
|
153
|
-
console.print(" [dim]---[/]")
|
|
154
|
-
console.print(f" {' '.join(parts)} [dim]took {_fmt_duration(total_ms)}[/]")
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _print_state_result(result: dict[str, Any]) -> None:
|
|
158
|
-
"""Render a state return from the local client, one block per minion.
|
|
159
|
-
|
|
160
|
-
Falls back to indented JSON for anything that isn't a state return — e.g.
|
|
161
|
-
a render/compile error, where salt answers with a list of message lines."""
|
|
162
|
-
ret_list: Any = result.get("return")
|
|
163
|
-
if not ret_list:
|
|
164
|
-
console.print_json(json.dumps(result))
|
|
165
|
-
return
|
|
166
|
-
ret: dict[str, Any] = ret_list[0]
|
|
167
|
-
if not ret:
|
|
168
|
-
console.print("(no minions responded)")
|
|
169
|
-
return
|
|
170
|
-
for minion in sorted(ret):
|
|
171
|
-
val = ret[minion]
|
|
172
|
-
if _is_state_return(val):
|
|
173
|
-
_print_state_return(minion, val)
|
|
174
|
-
continue
|
|
175
|
-
console.print(Text(minion, style="bold"))
|
|
176
|
-
if isinstance(val, list):
|
|
177
|
-
for item in cast("list[Any]", val):
|
|
178
|
-
console.print(Padding(Text(str(item)), (0, 0, 0, 2)))
|
|
179
|
-
else:
|
|
180
|
-
console.print(Padding(json.dumps(val, indent=2), (0, 0, 0, 2)))
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
|
|
184
|
-
"""The ``salt state`` command, layered over the local client + ``state.*``.
|
|
185
|
-
|
|
186
|
-
``call(tgt=..., fun=..., ...)`` must invoke the local client and return
|
|
187
|
-
its JSON (cli.py binds it to the local client). Any trailing ``key=value``
|
|
188
|
-
args are forwarded as kwargs to the state function (e.g. ``test=True``)."""
|
|
189
|
-
pos, kw = split_args(list(getattr(args, "args", None) or []))
|
|
190
|
-
if args.action == "highstate":
|
|
191
|
-
fun, arg = "state.highstate", pos
|
|
192
|
-
elif args.action == "test":
|
|
193
|
-
fun, arg = "state.highstate", pos
|
|
194
|
-
kw["test"] = "True"
|
|
195
|
-
else: # apply <sls>
|
|
196
|
-
fun, arg = "state.apply", [args.sls, *pos]
|
|
197
|
-
|
|
198
|
-
payload: dict[str, Any] = {"tgt": args.target, "fun": fun, "arg": arg}
|
|
199
|
-
if kw:
|
|
200
|
-
payload["kwarg"] = kw
|
|
201
|
-
_print_state_result(call(**payload))
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# --------------------------------------------------------------------------
|
|
205
|
-
# key management
|
|
206
|
-
# --------------------------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _print_key_panels(data: dict[str, Any]) -> None:
|
|
210
|
-
"""Render key.list_all as one panel per acceptance status."""
|
|
211
|
-
panels: list[Panel] = []
|
|
212
|
-
for status_key, (label, color) in _KEY_PANELS.items():
|
|
213
|
-
keys: list[str] = data.get(status_key, [])
|
|
214
|
-
body: Any = Text("\n".join(keys)) if keys else Text("(none)", style="dim")
|
|
215
|
-
panels.append(
|
|
216
|
-
Panel(
|
|
217
|
-
body,
|
|
218
|
-
title=f"{label} ({len(keys)})",
|
|
219
|
-
title_align="left",
|
|
220
|
-
border_style=color,
|
|
221
|
-
)
|
|
222
|
-
)
|
|
223
|
-
console.print(Columns(panels, equal=True, expand=False))
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
|
|
227
|
-
"""The ``salt keys`` command, layered over ``wheel key.*``.
|
|
228
|
-
|
|
229
|
-
``call(fun=..., **kw)`` must invoke the wheel client and return its JSON
|
|
230
|
-
(cli.py binds it to the wheel client)."""
|
|
231
|
-
action: str = args.action
|
|
232
|
-
if action == "list":
|
|
233
|
-
result = call(fun="key.list_all")
|
|
234
|
-
_print_key_panels(result["return"][0]["data"]["return"])
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
fun_map = {
|
|
238
|
-
"accept": "key.accept",
|
|
239
|
-
"accept-all": "key.accept",
|
|
240
|
-
"reject": "key.reject",
|
|
241
|
-
"delete": "key.delete",
|
|
242
|
-
}
|
|
243
|
-
match: str = "*" if action == "accept-all" else args.match
|
|
244
|
-
result = call(fun=fun_map[action], match=match)
|
|
245
|
-
data = result["return"][0]["data"]
|
|
246
|
-
if not data.get("success"):
|
|
247
|
-
sys.exit(f"failed: {data}")
|
|
248
|
-
changed: dict[str, list[str]] = data.get("return", {})
|
|
249
|
-
if not changed:
|
|
250
|
-
console.print("(no keys changed)")
|
|
251
|
-
return
|
|
252
|
-
for status_key, ids in changed.items():
|
|
253
|
-
label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
|
|
254
|
-
joined = ", ".join(ids) if ids else "[dim](none)[/]"
|
|
255
|
-
console.print(f"{label}: {joined}")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|