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 +2 -0
- fastvex/cli.py +383 -0
- fastvex/config_edit.py +26 -0
- fastvex/display.py +197 -0
- fastvex/executor.py +291 -0
- fastvex/models.py +265 -0
- fastvex/project.py +74 -0
- fastvex/services.py +285 -0
- fastvex/state_model.py +106 -0
- fastvex/storage.py +82 -0
- fastvex/templates.py +79 -0
- fastvex/theme.py +97 -0
- fastvex-0.0.1.dist-info/METADATA +116 -0
- fastvex-0.0.1.dist-info/RECORD +17 -0
- fastvex-0.0.1.dist-info/WHEEL +4 -0
- fastvex-0.0.1.dist-info/entry_points.txt +2 -0
- fastvex-0.0.1.dist-info/licenses/LICENSE +21 -0
fastvex/__init__.py
ADDED
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)
|