fastvex 0.0.1__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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .fastvex/
7
+ dist/
8
+ build/
9
+ *.egg-info/
fastvex-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WyattYuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
fastvex-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastvex
3
+ Version: 0.0.1
4
+ Summary: Fast VEX V5 slot-oriented build and upload CLI
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pydantic>=2.11
9
+ Requires-Dist: pyyaml>=6.0.2
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: typer>=0.16.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # fastvex
15
+
16
+ `fastvex` 是面向 VEX V5 / PROS 机器人项目的槽位构建与上传工具。
17
+
18
+ 它会读取机器人项目根目录中的 `fastvex.yaml`,根据 slot、role、route 配置生成构建参数和程序名,调用 PROS 完成构建/上传,并把本机运行状态写入 `.fastvex/state.json`。
19
+
20
+ ## 安装与运行
21
+
22
+ 临时运行:
23
+
24
+ ```powershell
25
+ uvx fastvex validate
26
+ uvx fastvex upload --slots 1,3 -y
27
+ ```
28
+
29
+ 全局安装:
30
+
31
+ ```powershell
32
+ pipx install fastvex
33
+ fastvex show
34
+ ```
35
+
36
+ 本仓库本地开发时:
37
+
38
+ ```powershell
39
+ uv sync
40
+ uv run pytest
41
+ uv run ruff check .
42
+ ```
43
+
44
+ ## 在机器人项目中使用
45
+
46
+ 在包含 `fastvex.yaml` 的机器人仓库根目录运行:
47
+
48
+ ```powershell
49
+ fastvex validate
50
+ fastvex show
51
+ fastvex upload --slots 1,3 -y
52
+ fastvex route show
53
+ fastvex route set red r1
54
+ ```
55
+
56
+ 也可以在机器人项目子目录中运行,`fastvex` 会向上查找 `fastvex.yaml`。
57
+
58
+ 如果需要显式指定配置:
59
+
60
+ ```powershell
61
+ fastvex validate --config D:\path\to\robot\fastvex.yaml
62
+ fastvex --config D:\path\to\robot\fastvex.yaml validate
63
+ ```
64
+
65
+ ## 配置与状态
66
+
67
+ - `fastvex.yaml`:机器人项目配置,建议提交到机器人代码仓库。
68
+ - `.fastvex/state.json`:本机状态与上传历史,建议加入机器人仓库的 `.gitignore`。
69
+ - `vex_upload_config.yaml`:旧配置文件名仍可读取,但新项目建议迁移到 `fastvex.yaml`。
70
+
71
+ ## 常用命令
72
+
73
+ ```powershell
74
+ # 初始化 fastvex.yaml 和 .fastvex/state.json,不覆盖已有文件
75
+ fastvex init
76
+
77
+ # 校验配置
78
+ fastvex validate
79
+
80
+ # 展示槽位映射、当前状态和历史
81
+ fastvex show
82
+
83
+ # 预览上传,不执行 PROS 构建/上传
84
+ fastvex upload --slots 3 --dry-run
85
+
86
+ # 上传指定槽位
87
+ fastvex upload --slots 1,3 -y
88
+
89
+ # 按配置中的分组上传
90
+ fastvex upload --group all-enabled -y
91
+
92
+ # 查看和切换当前路线
93
+ fastvex route show
94
+ fastvex route set red r1
95
+
96
+ # 查看和清理历史
97
+ fastvex history show
98
+ fastvex history clean --keep 10
99
+ ```
100
+
101
+ ## Python API
102
+
103
+ `fastvex` 也提供命令级 Python API,方便测试或脚本复用:
104
+
105
+ ```python
106
+ from fastvex.services import UploadRequest, upload_slots, validate_project
107
+
108
+ report = validate_project(config="D:/path/to/robot/fastvex.yaml")
109
+ assert report.warnings == []
110
+
111
+ upload_report = upload_slots(
112
+ UploadRequest(slots="3", dry_run=True),
113
+ config="D:/path/to/robot/fastvex.yaml",
114
+ )
115
+ assert upload_report.failed_slots == []
116
+ ```
@@ -0,0 +1,103 @@
1
+ # fastvex
2
+
3
+ `fastvex` 是面向 VEX V5 / PROS 机器人项目的槽位构建与上传工具。
4
+
5
+ 它会读取机器人项目根目录中的 `fastvex.yaml`,根据 slot、role、route 配置生成构建参数和程序名,调用 PROS 完成构建/上传,并把本机运行状态写入 `.fastvex/state.json`。
6
+
7
+ ## 安装与运行
8
+
9
+ 临时运行:
10
+
11
+ ```powershell
12
+ uvx fastvex validate
13
+ uvx fastvex upload --slots 1,3 -y
14
+ ```
15
+
16
+ 全局安装:
17
+
18
+ ```powershell
19
+ pipx install fastvex
20
+ fastvex show
21
+ ```
22
+
23
+ 本仓库本地开发时:
24
+
25
+ ```powershell
26
+ uv sync
27
+ uv run pytest
28
+ uv run ruff check .
29
+ ```
30
+
31
+ ## 在机器人项目中使用
32
+
33
+ 在包含 `fastvex.yaml` 的机器人仓库根目录运行:
34
+
35
+ ```powershell
36
+ fastvex validate
37
+ fastvex show
38
+ fastvex upload --slots 1,3 -y
39
+ fastvex route show
40
+ fastvex route set red r1
41
+ ```
42
+
43
+ 也可以在机器人项目子目录中运行,`fastvex` 会向上查找 `fastvex.yaml`。
44
+
45
+ 如果需要显式指定配置:
46
+
47
+ ```powershell
48
+ fastvex validate --config D:\path\to\robot\fastvex.yaml
49
+ fastvex --config D:\path\to\robot\fastvex.yaml validate
50
+ ```
51
+
52
+ ## 配置与状态
53
+
54
+ - `fastvex.yaml`:机器人项目配置,建议提交到机器人代码仓库。
55
+ - `.fastvex/state.json`:本机状态与上传历史,建议加入机器人仓库的 `.gitignore`。
56
+ - `vex_upload_config.yaml`:旧配置文件名仍可读取,但新项目建议迁移到 `fastvex.yaml`。
57
+
58
+ ## 常用命令
59
+
60
+ ```powershell
61
+ # 初始化 fastvex.yaml 和 .fastvex/state.json,不覆盖已有文件
62
+ fastvex init
63
+
64
+ # 校验配置
65
+ fastvex validate
66
+
67
+ # 展示槽位映射、当前状态和历史
68
+ fastvex show
69
+
70
+ # 预览上传,不执行 PROS 构建/上传
71
+ fastvex upload --slots 3 --dry-run
72
+
73
+ # 上传指定槽位
74
+ fastvex upload --slots 1,3 -y
75
+
76
+ # 按配置中的分组上传
77
+ fastvex upload --group all-enabled -y
78
+
79
+ # 查看和切换当前路线
80
+ fastvex route show
81
+ fastvex route set red r1
82
+
83
+ # 查看和清理历史
84
+ fastvex history show
85
+ fastvex history clean --keep 10
86
+ ```
87
+
88
+ ## Python API
89
+
90
+ `fastvex` 也提供命令级 Python API,方便测试或脚本复用:
91
+
92
+ ```python
93
+ from fastvex.services import UploadRequest, upload_slots, validate_project
94
+
95
+ report = validate_project(config="D:/path/to/robot/fastvex.yaml")
96
+ assert report.warnings == []
97
+
98
+ upload_report = upload_slots(
99
+ UploadRequest(slots="3", dry_run=True),
100
+ config="D:/path/to/robot/fastvex.yaml",
101
+ )
102
+ assert upload_report.failed_slots == []
103
+ ```
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "fastvex"
3
+ version = "0.0.1"
4
+ description = "Fast VEX V5 slot-oriented build and upload CLI"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "PyYAML>=6.0.2",
10
+ "pydantic>=2.11",
11
+ "rich>=13.0",
12
+ "typer>=0.16.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ fastvex = "fastvex.cli:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.3.5",
21
+ "ruff>=0.11.2",
22
+ ]
23
+
24
+ [tool.ruff]
25
+ line-length = 100
26
+
27
+ [build-system]
28
+ requires = ["hatchling>=1.27.0"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/fastvex"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = [
36
+ "src/fastvex",
37
+ "tests",
38
+ "LICENSE",
39
+ "README.md",
40
+ ]
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
@@ -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())
@@ -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"