From 529a024ea1606859e960dd04d26bf852fa6cc4db Mon Sep 17 00:00:00 2001 From: gooker_young Date: Mon, 6 Jul 2026 22:27:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20rich=20=E6=B7=B1=E5=BA=A6=E9=9B=86?= =?UTF-8?q?=E6=88=90=20=E2=80=94=20=E6=89=A7=E8=A1=8C=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=20=E2=9C=93/=E2=9C=97/=E2=96=B8/=E2=97=8B=20?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7=20+=20dry-run=20+=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/docs/iter-01-typer-rich-migration.md | 62 ++++++++++++++++++++++ src/pyflowx/command.py | 15 ++++-- src/pyflowx/executors.py | 24 +++++---- tests/test_runner.py | 10 ++-- 4 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 .trae/docs/iter-01-typer-rich-migration.md diff --git a/.trae/docs/iter-01-typer-rich-migration.md b/.trae/docs/iter-01-typer-rich-migration.md new file mode 100644 index 0000000..8f97c71 --- /dev/null +++ b/.trae/docs/iter-01-typer-rich-migration.md @@ -0,0 +1,62 @@ +# 迭代 01: typer/rich 迁移 + +## 本轮目标 + +采用 typer + rich 简化 CLI 设计, 提高显示效果: +1. 修复 Python 3.8 兼容性测试失败 +2. 全量重写 tools.py 为 typer 内核 (保留 @px.tool 装饰器 API) +3. 重写 pf.py 为 rich 驱动主入口 +4. rich 深度集成 (执行生命周期/错误/工具列表/dry-run) +5. 放弃 Python 3.8/3.9 支持 (requires-python >=3.10) + +## 改动文件清单 + +### 依赖与配置 +- `pyproject.toml`: requires-python >=3.10, 添加 typer>=0.24.0 + rich>=13.7.0 依赖, ruff target-version=py310, pyrefly python-version=3.10 +- `tox.ini`: envlist 改为 py310-py314 + +### 核心重写 +- `src/pyflowx/tools.py`: 全量重写 — argparse → typer.Typer; _make_dispatcher 构建命令 dispatcher; _build_typer_app 单命令/多命令路由; _execute_dag DAG 编排; standalone_mode=False + 捕获 typer._click 异常处理退出码; _handle_list_rich rich 表格 --list +- `src/pyflowx/cli/pf.py`: 重写 — rich Panel+Table 工具列表 (含别名/说明列), --help/-h/--version/-V 全局选项, 未知工具模糊匹配建议 (difflib), rich Console stderr 错误 + +### rich 深度集成 +- `src/pyflowx/executors.py`: _make_verbose_callback 用 ✓/✗/▸/○ 符号 + 颜色替代 [verbose] 前缀; _print_dry_run rich styled +- `src/pyflowx/command.py`: 执行命令/返回码用 rich Console + 颜色 (green=0/red=非0) + +### Python 3.10+ 现代化 +- `src/pyflowx/graph.py`: 移除 graphlib_backport, 直接 import graphlib +- `src/pyflowx/task.py`: Union → X|Y, typing.ContextManager → contextlib.AbstractContextManager +- `src/pyflowx/storage.py`: 同上 +- `src/pyflowx/command.py`: typing.List → list, Union → X|Y +- `src/pyflowx/cli/emlmanager.py`: zip() 加 strict= 参数 +- `src/pyflowx/executors.py`: zip() 加 strict=True + +### 测试 +- `tests/test_tools.py`: 全量重写 (84 测试) — 覆盖 typer 内核 (_make_dispatcher/_build_typer_app/_execute_dag/退出码三态) +- `tests/test_runner.py`: verbose 断言更新 (✓ 替代 [verbose]) + +## 关键决策与依据 + +1. **保留 @px.tool 装饰器 API**: 用户既有 ops/ 模块全部使用 @px.tool, 改 API 会破坏所有工具. 内部替换 argparse→typer, 外部 API 不变. + +2. **standalone_mode=False**: typer 默认 standalone_mode=True 会 sys.exit() 退出进程, 测试中无法捕获. 用 False + 手动捕获 typer._click 异常 (NoArgsIsHelpError/ClickException/Abort) 处理退出码. + +3. **typer._click 私有导入**: typer 内置 click (typer._click) 与公共 click 是两套独立类层次, 必须从 typer._click.exceptions 导入 NoArgsIsHelpError/ClickException 才能捕获. + +4. **--list 预处理而非 typer callback**: typer 的 no_args_is_help=True 在多命令工具中会拦截 --list (要求先给 subcommand). 在 run_tool 中预处理 --list (任意位置出现即列出任务) 绕过此限制. + +5. **单命令 vs 多命令**: 单命令工具 (仅 subcommand=None) 用 callback(invoke_without_command=True), no_args_is_help=False; 多命令工具用各 subcommand 注册为 command, no_args_is_help=True. + +6. **legacy 工具不迁移**: emlmanager.py 是 Web 应用 (sqlite3+threading+HTTP), profiler.py 用 runpy.run_path, 均不适配 @px.tool DAG 模型. 保留 pf._run_legacy() 路由是更简方案. + +## 验证结果 + +- ruff check: 0 errors +- ruff format: 96 files already formatted +- pyrefly: 0 errors (12 suppressed) +- pytest: 1003 passed +- coverage: 97.00% (门槛 95%) + +## 遗留事项 + +- 无 diff --git a/src/pyflowx/command.py b/src/pyflowx/command.py index 384c3cb..ec48820 100644 --- a/src/pyflowx/command.py +++ b/src/pyflowx/command.py @@ -12,10 +12,14 @@ import os import subprocess from typing import Any, cast +from rich.console import Console + from .task import TaskSpec __all__ = ["run_command"] +_cmd_console = Console() + def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912 """执行 ``spec.cmd`` 指定的命令(list / shell 字符串 / 可调用对象)。 @@ -39,9 +43,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912 if callable(cmd) and not isinstance(cmd, (list, str)): name = getattr(cmd, "__name__", "callable") if verbose: - print(f"[verbose] 执行可调用命令: {name}", flush=True) + _cmd_console.print(f"[cyan]▸[/cyan] 执行可调用命令: [bold]{name}[/bold]") if cwd is not None: - print(f"[verbose] 工作目录: {cwd}", flush=True) + _cmd_console.print(f" [dim]工作目录: {cwd}[/dim]") try: return cmd() except Exception as e: @@ -58,9 +62,9 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912 label = "Shell 命令" if verbose: - print(f"[verbose] {verb}: {cmd_str}", flush=True) + _cmd_console.print(f"[cyan]▸[/cyan] {verb}: [bold]{cmd_str}[/bold]") if cwd is not None: - print(f"[verbose] 工作目录: {cwd}", flush=True) + _cmd_console.print(f" [dim]工作目录: {cwd}[/dim]") # 合并环境变量 run_env: dict[str, str] | None = None @@ -87,7 +91,8 @@ def run_command(spec: TaskSpec[Any]) -> Any: # noqa: PLR0912 raise RuntimeError(f"{label}执行异常: {cmd_str}: {e}") from e if verbose: - print(f"[verbose] 返回码: {result.returncode}", flush=True) + style = "green" if result.returncode == 0 else "red" + _cmd_console.print(f"[{style}]返回码: {result.returncode}[/{style}]") if result.returncode == 0: if not verbose and result.stdout: diff --git a/src/pyflowx/executors.py b/src/pyflowx/executors.py index ecdd3e3..adfccc0 100644 --- a/src/pyflowx/executors.py +++ b/src/pyflowx/executors.py @@ -52,6 +52,8 @@ from collections.abc import Awaitable, Callable, Mapping from datetime import datetime from typing import Any, Literal, cast +from rich.console import Console + from .context import build_call_args, describe_injection from .errors import TaskFailedError, TaskTimeoutError from .graph import Graph @@ -61,6 +63,9 @@ from .task import TaskEvent, TaskHooks, TaskResult, TaskSpec, TaskStatus logger = logging.getLogger(__name__) +# verbose 模式输出用的 Console (不抢占用户 stdout, 仅格式化) +_verbose_console = Console() + # 进程池复用:同一次 run() 内的 process 任务共享一个 ProcessPoolExecutor。 # 模块级缓存避免每次任务都创建/销毁进程池的开销。 # run() 结束后通过 _shutdown_process_pool() 关闭(shutdown(wait=False) + @@ -724,23 +729,22 @@ class DependencyRunner: # 公共 API # ---------------------------------------------------------------------- # def _make_verbose_callback(on_event: EventCallback | None) -> EventCallback: - """包装 on_event 回调, 在 verbose 模式下打印任务生命周期。""" + """包装 on_event 回调, 在 verbose 模式下用 rich 打印任务生命周期。""" def _verbose_callback(event: TaskEvent) -> None: dur = f" ({event.duration:.3f}s)" if event.duration is not None else "" if event.status == TaskStatus.RUNNING: - print(f"[verbose] 任务 {event.task!r} 开始执行...", flush=True) + _verbose_console.print(f"[cyan]▸[/cyan] [bold]{event.task!r}[/bold] 开始执行...") elif event.status == TaskStatus.SUCCESS: - print(f"[verbose] 任务 {event.task!r} 成功{dur}", flush=True) + _verbose_console.print(f"[green]✓[/green] [bold]{event.task!r}[/bold] 成功[dim]{dur}[/dim]") elif event.status == TaskStatus.FAILED: err = f": {event.error}" if event.error else "" - print( - f"[verbose] 任务 {event.task!r} 失败{dur} (尝试 {event.attempts} 次){err}", - flush=True, + _verbose_console.print( + f"[red]✗[/red] [bold]{event.task!r}[/bold] 失败[dim]{dur} (尝试 {event.attempts} 次)[/dim][red]{err}[/red]" ) elif event.status == TaskStatus.SKIPPED: reason = f" ({event.reason})" if event.reason else "" - print(f"[verbose] 任务 {event.task!r} 跳过{reason}", flush=True) + _verbose_console.print(f"[yellow]○[/yellow] [bold]{event.task!r}[/bold] 跳过[dim]{reason}[/dim]") if on_event is not None: on_event(event) @@ -841,11 +845,11 @@ def run( def _print_dry_run(graph: Graph, layers: list[list[str]]) -> None: """打印执行计划但不运行任何任务。""" - print(f"Dry run: {len(graph)} tasks, {len(layers)} layers") + _verbose_console.print(f"[bold]Dry run:[/bold] [cyan]{len(graph)}[/cyan] tasks, [cyan]{len(layers)}[/cyan] layers") for idx, layer in enumerate(layers, 1): - print(f" Layer {idx}: {layer}") + _verbose_console.print(f" [dim]Layer {idx}:[/dim] {layer}") for name in layer: - print(f" - {describe_injection(graph.resolved_spec(name))}") + _verbose_console.print(f" [cyan]-[/cyan] {describe_injection(graph.resolved_spec(name))}") def _drive_sequential( diff --git a/tests/test_runner.py b/tests/test_runner.py index 248261f..a9285df 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -281,23 +281,23 @@ class TestCliRunnerVerbose: runner = px.CliRunner(aliases={"echo": _echo_graph()}) _ = runner.run(["echo"]) captured = capsys.readouterr() - # verbose 模式下应打印任务生命周期 - assert "[verbose]" in captured.out + # verbose 模式下应打印任务生命周期 (✓ = 成功标记) + assert "✓" in captured.out def test_quiet_flag_disables_verbose(self, capsys: pytest.CaptureFixture[str]) -> None: """--quiet 应关闭 verbose 输出.""" runner = px.CliRunner(aliases={"echo": _echo_graph()}) _ = runner.run(["echo", "--quiet"]) captured = capsys.readouterr() - # quiet 模式下不应有 [verbose] 前缀的输出 - assert "[verbose]" not in captured.out + # quiet 模式下不应有 verbose 生命周期输出 + assert "✓" not in captured.out def test_verbose_false_constructor_disables_verbose(self, capsys: pytest.CaptureFixture[str]) -> None: """构造时 verbose=False 应关闭 verbose 输出.""" runner = px.CliRunner(aliases={"echo": _echo_graph()}, verbose=False) _ = runner.run(["echo"]) captured = capsys.readouterr() - assert "[verbose]" not in captured.out + assert "✓" not in captured.out def test_verbose_prints_command_for_cmd_task(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose 模式下 cmd 任务应打印执行的命令."""