fastvex 0.0.1__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.
fastvex/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
fastvex/cli.py ADDED
@@ -0,0 +1,383 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Annotated
5
+
6
+ import click
7
+ import typer
8
+
9
+ from .display import print_execution_result, print_history, print_show, print_upload_plan
10
+ from .services import (
11
+ HistoryCleanReport,
12
+ UploadRequest,
13
+ clean_history,
14
+ get_history,
15
+ init_project,
16
+ plan_upload,
17
+ set_route,
18
+ show_project,
19
+ show_routes,
20
+ upload_slots,
21
+ validate_project,
22
+ )
23
+ from .storage import ValidationError
24
+ from .theme import FAIL, INFO, OK, WARN, confirm, console
25
+
26
+ CommonConfig = Annotated[str | None, typer.Option("--config", help="Config file path.")]
27
+ CommonState = Annotated[str | None, typer.Option("--state", help="State file path.")]
28
+
29
+ app = typer.Typer(
30
+ name="fastvex",
31
+ help="Fast VEX slot-oriented build/upload manager.",
32
+ invoke_without_command=True,
33
+ )
34
+ history_app = typer.Typer(help="Show or clean history.")
35
+ route_app = typer.Typer(help="Show or set active route by route set.")
36
+ app.add_typer(history_app, name="history")
37
+ app.add_typer(route_app, name="route")
38
+
39
+
40
+ def rprint(*args, **kwargs) -> None:
41
+ """Rich-aware print: uses console.print unless an explicit file= is given."""
42
+ file = kwargs.pop("file", None)
43
+ if file is not None:
44
+ kwargs.setdefault("sep", " ")
45
+ kwargs.setdefault("end", "\n")
46
+ kwargs.setdefault("flush", False)
47
+ print(*args, file=file, **kwargs)
48
+ else:
49
+ console.print(*args, **kwargs)
50
+
51
+
52
+ def _ctx_options(ctx: typer.Context, config: str | None, state: str | None) -> dict[str, str | None]:
53
+ obj = ctx.obj or {}
54
+ return {
55
+ "config": config if config is not None else obj.get("config"),
56
+ "state": state if state is not None else obj.get("state"),
57
+ }
58
+
59
+
60
+ def _print_legacy_warning(legacy_config: bool, config_path: object) -> None:
61
+ if legacy_config:
62
+ rprint(f" [yellow]{WARN} using legacy config name:[/yellow] {config_path}")
63
+
64
+
65
+ def _finish(code: int) -> None:
66
+ if code:
67
+ raise typer.Exit(code)
68
+
69
+
70
+ def _upload_request(
71
+ *,
72
+ slots: str | None = None,
73
+ group: str | None = None,
74
+ all_enabled: bool = False,
75
+ robot_name: str | None = None,
76
+ port: str | None = None,
77
+ clean: bool = False,
78
+ quiet: bool = False,
79
+ dry_run: bool = False,
80
+ yes: bool = False,
81
+ ) -> UploadRequest:
82
+ return UploadRequest(
83
+ slots=slots,
84
+ group=group,
85
+ all_enabled=all_enabled,
86
+ robot_name=robot_name,
87
+ port=port,
88
+ clean=clean,
89
+ quiet=quiet,
90
+ dry_run=dry_run,
91
+ yes=yes,
92
+ )
93
+
94
+
95
+ def run_default_interactive(config: str | None = None, state: str | None = None) -> int:
96
+ report = show_project(config=config, state=state)
97
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
98
+ print_show(report.config, report.state)
99
+
100
+ rprint(" [bold cyan]Select upload target:[/bold cyan]")
101
+ rprint(" [dim]slot list[/dim] e.g. [green]1,3,5[/green]")
102
+ rprint(" [dim]group:name[/dim] e.g. [green]group:comp-default[/green]")
103
+ rprint(" [dim]all[/dim] upload all enabled slots")
104
+ rprint(" [dim]q[/dim] quit")
105
+ rprint()
106
+
107
+ raw = console.input(" [cyan]target[/cyan]> ").strip()
108
+ if not raw or raw.lower() in {"q", "quit", "exit"}:
109
+ rprint("\n [blue]bye[/blue]\n")
110
+ return 0
111
+
112
+ request = _upload_request()
113
+ if raw.lower() == "all":
114
+ request = _upload_request(all_enabled=True)
115
+ elif raw.lower().startswith("group:"):
116
+ request = _upload_request(group=raw.split(":", 1)[1].strip())
117
+ else:
118
+ request = _upload_request(slots=raw)
119
+
120
+ planned = plan_upload(request, config=config, state=state)
121
+ paths, loaded_config, _, slots, _, _ = planned
122
+ _print_legacy_warning(paths.legacy_config, paths.config)
123
+ print_upload_plan(loaded_config, slots)
124
+ if not confirm(
125
+ " [yellow]Continue upload?[/yellow] [[green]Y[/green]/[red]n[/red]] (Enter for 'Y'): ",
126
+ default_yes=True,
127
+ ):
128
+ rprint(f"\n [yellow]{WARN} aborted[/yellow]\n")
129
+ return 0
130
+
131
+ report = upload_slots(request, config=config, state=state)
132
+ if report.execution:
133
+ print_execution_result(report.execution)
134
+ return 1 if report.failed_slots else 0
135
+
136
+
137
+ @app.callback()
138
+ def root(
139
+ ctx: typer.Context,
140
+ config: CommonConfig = None,
141
+ state: CommonState = None,
142
+ ) -> None:
143
+ ctx.obj = {"config": config, "state": state}
144
+ if ctx.invoked_subcommand is None:
145
+ _finish(run_default_interactive(config=config, state=state))
146
+
147
+
148
+ @app.command("init")
149
+ def init_command(
150
+ ctx: typer.Context,
151
+ config: CommonConfig = None,
152
+ state: CommonState = None,
153
+ ) -> None:
154
+ options = _ctx_options(ctx, config, state)
155
+ report = init_project(**options)
156
+
157
+ if report.config_exists:
158
+ rprint(f" [cyan]config exists:[/cyan] {report.paths.config}")
159
+ elif report.legacy_config_exists:
160
+ rprint(f" [yellow]{WARN} legacy config exists:[/yellow] {report.paths.root / 'vex_upload_config.yaml'}")
161
+ rprint(" [dim]fastvex init will not migrate or overwrite configs.[/dim]")
162
+ elif report.config_created:
163
+ rprint(f" [green]created config:[/green] {report.paths.config}")
164
+
165
+ if report.state_exists:
166
+ rprint(f" [cyan]state exists:[/cyan] {report.paths.state}")
167
+ elif report.state_created:
168
+ rprint(f" [green]created state:[/green] {report.paths.state}")
169
+
170
+ rprint(f"\n [bold green]{OK} init ok[/bold green]\n")
171
+
172
+
173
+ @app.command("show")
174
+ def show_command(
175
+ ctx: typer.Context,
176
+ config: CommonConfig = None,
177
+ state: CommonState = None,
178
+ ) -> None:
179
+ report = show_project(**_ctx_options(ctx, config, state))
180
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
181
+ print_show(report.config, report.state)
182
+
183
+
184
+ @app.command("validate")
185
+ def validate_command(
186
+ ctx: typer.Context,
187
+ config: CommonConfig = None,
188
+ state: CommonState = None,
189
+ ) -> None:
190
+ report = validate_project(**_ctx_options(ctx, config, state))
191
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
192
+ for warning in report.warnings:
193
+ rprint(f" {WARN} {warning}")
194
+ rprint(f"\n [bold green]{OK} validate ok[/bold green]\n")
195
+
196
+
197
+ @app.command("upload")
198
+ def upload_command(
199
+ ctx: typer.Context,
200
+ config: CommonConfig = None,
201
+ state: CommonState = None,
202
+ slots: Annotated[str | None, typer.Option("--slots", help="Slot list, e.g. '1,3,5'.")] = None,
203
+ group: Annotated[str | None, typer.Option("--group", help="Group name defined in config.")] = None,
204
+ all_enabled: Annotated[
205
+ bool,
206
+ typer.Option("--all-enabled", help="Target all configured slots."),
207
+ ] = False,
208
+ robot_name: Annotated[str | None, typer.Option("--robot-name", help="Override robot name.")] = None,
209
+ port: Annotated[str | None, typer.Option("--port", help="Override port. Empty means auto.")] = None,
210
+ clean: Annotated[bool, typer.Option("--clean", help="Run make clean before build.")] = False,
211
+ quiet: Annotated[bool, typer.Option("--quiet", help="Capture build/upload output.")] = False,
212
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Plan without build/upload.")] = False,
213
+ yes: Annotated[bool, typer.Option("-y", "--yes", help="Skip confirm prompt.")] = False,
214
+ ) -> None:
215
+ options = _ctx_options(ctx, config, state)
216
+ request = _upload_request(
217
+ slots=slots,
218
+ group=group,
219
+ all_enabled=all_enabled,
220
+ robot_name=robot_name,
221
+ port=port,
222
+ clean=clean,
223
+ quiet=quiet,
224
+ dry_run=dry_run,
225
+ yes=yes,
226
+ )
227
+ paths, loaded_config, _, selected_slots, _, _ = plan_upload(request, **options)
228
+ _print_legacy_warning(paths.legacy_config, paths.config)
229
+ print_upload_plan(loaded_config, selected_slots)
230
+
231
+ if not yes and not dry_run:
232
+ if not confirm(
233
+ " [yellow]Continue upload?[/yellow] [[green]Y[/green]/[red]n[/red]] (Enter for 'Y'): ",
234
+ default_yes=True,
235
+ ):
236
+ rprint(f"\n [yellow]{WARN} aborted[/yellow]\n")
237
+ return
238
+
239
+ report = upload_slots(request, **options)
240
+ if report.execution:
241
+ print_execution_result(report.execution)
242
+ _finish(1 if report.failed_slots else 0)
243
+
244
+
245
+ @history_app.callback()
246
+ def history_root(
247
+ ctx: typer.Context,
248
+ config: CommonConfig = None,
249
+ state: CommonState = None,
250
+ ) -> None:
251
+ parent = ctx.parent.obj if ctx.parent and ctx.parent.obj else {}
252
+ ctx.obj = {
253
+ "config": config if config is not None else parent.get("config"),
254
+ "state": state if state is not None else parent.get("state"),
255
+ }
256
+
257
+
258
+ @history_app.command("show")
259
+ def history_show_command(
260
+ ctx: typer.Context,
261
+ config: CommonConfig = None,
262
+ state: CommonState = None,
263
+ ) -> None:
264
+ report = get_history(**_ctx_options(ctx, config, state))
265
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
266
+ rprint()
267
+ if not report.state.history:
268
+ rprint(" [dim](empty)[/dim]")
269
+ else:
270
+ print_history(report.state.history)
271
+
272
+
273
+ @history_app.command("clean")
274
+ def history_clean_command(
275
+ ctx: typer.Context,
276
+ config: CommonConfig = None,
277
+ state: CommonState = None,
278
+ keep: Annotated[int, typer.Option("--keep", help="Number of entries to keep.")] = 10,
279
+ ) -> None:
280
+ report: HistoryCleanReport = clean_history(**_ctx_options(ctx, config, state), keep=keep)
281
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
282
+ if report.removed_count == 0:
283
+ rprint(f" [dim]history has {report.kept_count} entries, no cleanup needed (keep={keep})[/dim]")
284
+ else:
285
+ rprint(
286
+ f" [green]{OK} cleaned[/green] {report.removed_count} "
287
+ f"[dim]old entries, kept last[/dim] {report.kept_count}"
288
+ )
289
+
290
+
291
+ @route_app.callback()
292
+ def route_root(
293
+ ctx: typer.Context,
294
+ config: CommonConfig = None,
295
+ state: CommonState = None,
296
+ ) -> None:
297
+ parent = ctx.parent.obj if ctx.parent and ctx.parent.obj else {}
298
+ ctx.obj = {
299
+ "config": config if config is not None else parent.get("config"),
300
+ "state": state if state is not None else parent.get("state"),
301
+ }
302
+
303
+
304
+ @route_app.command("show")
305
+ def route_show_command(
306
+ ctx: typer.Context,
307
+ config: CommonConfig = None,
308
+ state: CommonState = None,
309
+ ) -> None:
310
+ from .theme import role_tone
311
+
312
+ report = show_routes(**_ctx_options(ctx, config, state))
313
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
314
+ loaded_config = report.config
315
+
316
+ rprint()
317
+ route_items = []
318
+ for route_set in sorted(loaded_config.active_route.keys()):
319
+ color, _ = role_tone(route_set, "COMP")
320
+ key = loaded_config.active_route[route_set]
321
+ route_items.append(f"[bold {color}]{route_set}[/bold {color}]:[{color}]{key}[/{color}]")
322
+
323
+ rprint(" [bold cyan]Active Routes[/bold cyan]")
324
+ rprint(f" {' '.join(route_items)}")
325
+ rprint()
326
+
327
+ rprint(" [bold cyan]Available Routes[/bold cyan]")
328
+ for route_set in sorted(loaded_config.routes.keys()):
329
+ color, _ = role_tone(route_set, "COMP")
330
+ active_key = loaded_config.active_route.get(route_set)
331
+
332
+ rprint(f"\n [bold {color}]{route_set}[/bold {color}]")
333
+ for key, opt in loaded_config.routes[route_set].items():
334
+ is_active = key == active_key
335
+ marker = f"[green]{OK}[/green] " if is_active else " "
336
+ active_tag = " [green](active)[/green]" if is_active else ""
337
+ rprint(
338
+ f" {marker}[cyan]{key}[/cyan] "
339
+ f"route={opt.route} routeName={opt.route_name}{active_tag}"
340
+ )
341
+ rprint()
342
+
343
+
344
+ @route_app.command("set")
345
+ def route_set_command(
346
+ ctx: typer.Context,
347
+ route_set: str,
348
+ route_key: str,
349
+ config: CommonConfig = None,
350
+ state: CommonState = None,
351
+ ) -> None:
352
+ from .theme import role_tone
353
+
354
+ report = set_route(route_set, route_key, **_ctx_options(ctx, config, state))
355
+ _print_legacy_warning(report.paths.legacy_config, report.paths.config)
356
+ if not report.changed:
357
+ rprint(f"\n [dim]{INFO} route unchanged:[/dim] {report.route_set}={report.new_key}\n")
358
+ return
359
+
360
+ color, _ = role_tone(report.route_set, "COMP")
361
+ rprint(
362
+ f"\n [bold green]{OK} updated active route:[/bold green] "
363
+ f"[bold {color}]{report.route_set}[/bold {color}] [dim]{report.old_key}[/dim] "
364
+ f"[cyan]→[/cyan] [bold {color}]{report.new_key}[/bold {color}]\n"
365
+ )
366
+
367
+
368
+ def main(argv: list[str] | None = None) -> int:
369
+ try:
370
+ app(args=argv, prog_name="fastvex", standalone_mode=False)
371
+ return 0
372
+ except click.exceptions.Exit as exc:
373
+ return int(exc.exit_code)
374
+ except ValidationError as exc:
375
+ print(f"\n [bold red]{FAIL} validation error:[/bold red] {exc}\n", file=sys.stderr)
376
+ return 2
377
+ except KeyboardInterrupt:
378
+ print(f"\n [yellow]{WARN} interrupted[/yellow]\n", file=sys.stderr)
379
+ return 130
380
+
381
+
382
+ if __name__ == "__main__":
383
+ raise SystemExit(main())
fastvex/config_edit.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from .storage import ValidationError
4
+
5
+
6
+ def replace_active_route_in_text(text: str, route_set: str, route_key: str) -> str:
7
+ """Text-based replacement to keep YAML comments/formatting intact."""
8
+ lines = text.splitlines()
9
+ in_active = False
10
+ found = False
11
+ for idx, line in enumerate(lines):
12
+ stripped = line.strip()
13
+ if stripped == "activeRoute:":
14
+ in_active = True
15
+ continue
16
+ if in_active:
17
+ if stripped and not line.startswith(" "):
18
+ break
19
+ key_prefix = f" {route_set}:"
20
+ if line.startswith(key_prefix):
21
+ lines[idx] = f" {route_set}: {route_key}"
22
+ found = True
23
+ break
24
+ if not found:
25
+ raise ValidationError(f"failed to update activeRoute.{route_set} in config file")
26
+ return "\n".join(lines) + "\n"
fastvex/display.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+
8
+ from .models import mode_to_camel, resolve_profile
9
+ from .state_model import ExecutionRecord, State, StateSlotEntry
10
+ from .theme import FAIL, OK, ROCKET, WARN, console, role_tone
11
+
12
+
13
+ def _role_style(route_set: str, mode: str) -> str:
14
+ color, is_dim = role_tone(route_set, mode)
15
+ return f"bold {color}" if not is_dim else color
16
+
17
+
18
+ def _render_program_name(config: Any, profile: Any) -> str:
19
+ """Render the same program name used by upload."""
20
+ mode_camel = mode_to_camel(profile.mode)
21
+ robot_name = config.defaults.robot_name
22
+ route_suffix = f"-{profile.route_name}" if profile.route > 0 and profile.route_name else ""
23
+ return config.defaults.name_template.format(
24
+ modeCamel=mode_camel,
25
+ routeSuffix=route_suffix,
26
+ robotName=robot_name,
27
+ )
28
+
29
+
30
+ def print_show(config: Any, state: State) -> None:
31
+ """Display the full show output: routes, slot mapping, current slots, history."""
32
+ from .theme import role_tone as _rt
33
+
34
+ route_items = []
35
+ for route_set in sorted(config.active_route.keys()):
36
+ color, _ = _rt(route_set, "COMP")
37
+ key = config.active_route[route_set]
38
+ route_items.append(Text(f"{route_set}:{key}", style=f"bold {color}"))
39
+
40
+ print()
41
+ console.print(" [bold cyan]Active Routes[/bold cyan]")
42
+ for item in route_items:
43
+ console.print(f" {item}", end=" ")
44
+ console.print()
45
+ print()
46
+
47
+ console.print(" [bold cyan]Slot Mapping[/bold cyan]")
48
+ _print_slot_table(config)
49
+ print()
50
+
51
+ console.print(" [bold cyan]Last Known Slots[/bold cyan]")
52
+ if not state.current_slots:
53
+ console.print(" [dim](empty)[/dim]")
54
+ else:
55
+ _print_current_slots(state.current_slots)
56
+ print()
57
+
58
+ console.print(" [bold cyan]Recent History[/bold cyan]")
59
+ if not state.history:
60
+ console.print(" [dim](empty)[/dim]")
61
+ else:
62
+ for i, item in enumerate(reversed(state.history), 1):
63
+ _print_history_compact(item, i)
64
+ print()
65
+
66
+
67
+ def _print_slot_table(config: Any) -> None:
68
+ """Print slot mapping: slot -> program name, route key, route name."""
69
+ for slot in range(1, 9):
70
+ resolved = resolve_profile(config, slot)
71
+ prog_name = _render_program_name(config, resolved)
72
+ route_display = f"[{resolved.route_key}] {resolved.route_name}"
73
+ color, _ = role_tone(resolved.route_set, resolved.mode)
74
+
75
+ console.print(
76
+ f" [white]Slot {slot}[/white] "
77
+ f"[{color}]{prog_name}[/{color}] "
78
+ f"[dim]{route_display}[/dim]"
79
+ )
80
+
81
+
82
+ def _print_current_slots(current: dict[int, StateSlotEntry]) -> None:
83
+ """Print current slots as formatted lines."""
84
+ for slot in range(1, 9):
85
+ entry = current.get(slot)
86
+ if not entry:
87
+ console.print(f" [dim]Slot {slot}: (unknown)[/dim]")
88
+ else:
89
+ style = _role_style(entry.route_set, entry.mode)
90
+ console.print(
91
+ f" [bold {style}]Slot {slot}[/bold {style}] "
92
+ f"[{style}]{entry.profile_id}[/{style}] "
93
+ f"[green]{ROCKET}[/green] "
94
+ f"[green]{entry.final_name}[/green]"
95
+ )
96
+
97
+
98
+ def _print_history_compact(item: ExecutionRecord, index: int) -> None:
99
+ """Print one history entry as a compact single line."""
100
+ line = Text()
101
+ line.append(f" #{index:<2} ")
102
+
103
+ if item.status == "success":
104
+ line.append(OK, style="green")
105
+ line.append(" success ")
106
+ elif item.status == "failed":
107
+ line.append(FAIL, style="bold red")
108
+ line.append(" failed ")
109
+ else:
110
+ line.append(WARN, style="yellow")
111
+ line.append(f" {item.status:<8}")
112
+
113
+ time_str = item.started_at[11:19] if len(item.started_at) > 19 else ""
114
+ line.append(f" {time_str:<8}", style="dim")
115
+ line.append(f" {item.username:<15}")
116
+
117
+ slot_list = ",".join(str(slot) for slot in item.requested_slots) if item.requested_slots else "-"
118
+ line.append(f" {slot_list:<5}", style="bold")
119
+ line.append(f" {item.duration_sec}s")
120
+
121
+ for result in item.results:
122
+ if not result.build.ok:
123
+ line.append(f" build={FAIL}", style="bold red")
124
+ if not result.upload.ok:
125
+ line.append(f" upload={FAIL}", style="bold red")
126
+
127
+ if item.dry_run:
128
+ line.append(" [dry]", style="dim")
129
+
130
+ console.print(line)
131
+
132
+
133
+ def print_history(hist: list[ExecutionRecord]) -> None:
134
+ """Print the full history list."""
135
+ if not hist:
136
+ console.print(" [dim](empty)[/dim]")
137
+ return
138
+ for i, item in enumerate(reversed(hist), 1):
139
+ _print_history_compact(item, i)
140
+
141
+
142
+ def print_upload_plan(config: Any, slots: list[int]) -> None:
143
+ """Print the upload plan as a simple list."""
144
+ console.print()
145
+ for slot in slots:
146
+ profile = resolve_profile(config, slot)
147
+ prog_name = _render_program_name(config, profile)
148
+ route_display = f"[{profile.route_key}] {profile.route_name}"
149
+ color, _ = role_tone(profile.route_set, profile.mode)
150
+ console.print(
151
+ f" [white]Slot {slot}[/white] "
152
+ f"[{color}]{prog_name}[/{color}] "
153
+ f"[dim]{route_display}[/dim]"
154
+ )
155
+ console.print()
156
+
157
+
158
+ def print_execution_result(execution: ExecutionRecord) -> None:
159
+ """Print a summary panel after upload completes."""
160
+ failed = [result for result in execution.results if not (result.build.ok and result.upload.ok)]
161
+ user_info = f"{execution.username}@{execution.hostname}"
162
+
163
+ if execution.status == "success":
164
+ status_display = Text(f"{OK} success", style="bold green")
165
+ elif execution.status == "failed":
166
+ status_display = Text(f"{FAIL} failed", style="bold red")
167
+ else:
168
+ status_display = Text(f"{WARN} {execution.status}", style="yellow")
169
+
170
+ if execution.dry_run:
171
+ status_display.append(" [dry-run]", style="dim")
172
+
173
+ lines: list[Text] = [
174
+ status_display,
175
+ Text(f"{ROCKET} [dim]{user_info}[/dim]"),
176
+ Text(f"Duration: {execution.duration_sec}s"),
177
+ Text(f"Slots: {len(execution.results)} total"),
178
+ ]
179
+
180
+ if failed:
181
+ lines.append(Text(""))
182
+ lines.append(Text(f"{FAIL} Failed slots:", style="bold red"))
183
+ for result in failed:
184
+ lines.append(Text(f" slot {result.slot}:"))
185
+ if result.build.error:
186
+ lines.append(Text(f" build: {result.build.error}", style="red"))
187
+ if result.upload.error:
188
+ lines.append(Text(f" upload: {result.upload.error}", style="red"))
189
+ else:
190
+ lines.append(Text(f"{OK} All slots OK", style="green"))
191
+
192
+ panel = Panel(
193
+ Text("\n").join(lines),
194
+ border_style="cyan",
195
+ padding=(0, 1),
196
+ )
197
+ console.print(panel)