refactor: 重构执行器和CliRunner,简化策略类型实现

1.  将Strategy枚举改为Literal类型,移除normalize_strategy函数
2.  内联策略验证逻辑到run函数中
3.  使用dataclasses.field重构CliRunner的初始化方式
4.  修复测试用例中的函数名和调用方式不匹配问题
5.  调整部分测试用例的构造语法,适配新的API
6.  修正pymake模块中的函数重命名和条件变量命名问题
7.  为部分耗时测试添加@pytest.mark.slow标记
This commit is contained in:
2026-06-21 12:52:32 +08:00
parent 4884fd53e5
commit 179e5b3811
9 changed files with 167 additions and 299 deletions
+36 -38
View File
@@ -49,7 +49,7 @@ class PymakeConfig:
conf = PymakeConfig()
def _get_maturin_build_command() -> list[str]:
def get_maturin_build_command() -> list[str]:
"""获取 maturin 构建命令(根据平台自动添加参数).
Returns
@@ -64,13 +64,13 @@ def _get_maturin_build_command() -> list[str]:
# 命令条件判断
_MATURIN_CONDITION = BuiltinConditions.HAS_APP_INSTALLED(conf.MATURIN_TOOL)
_PYTEST_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("pytest")
_UV_CONDITION = BuiltinConditions.HAS_APP_INSTALLED(conf.BUILD_TOOL)
_HATCH_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("hatch")
_RUFF_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("ruff")
_GIT_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("git")
_TOX_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("tox")
MATURIN_CONDITION = BuiltinConditions.HAS_APP_INSTALLED(conf.MATURIN_TOOL)
PYTEST_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("pytest")
UV_CONDITION = BuiltinConditions.HAS_APP_INSTALLED(conf.BUILD_TOOL)
HATCH_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("hatch")
RUFF_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("ruff")
GIT_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("git")
TOX_CONDITION = BuiltinConditions.HAS_APP_INSTALLED("tox")
def build_graphs() -> dict[str, px.Graph]:
@@ -87,7 +87,7 @@ def build_graphs() -> dict[str, px.Graph]:
px.TaskSpec(
"uv_build",
cmd=conf.BUILD_COMMAND,
conditions=(_UV_CONDITION,),
conditions=(UV_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -97,9 +97,9 @@ def build_graphs() -> dict[str, px.Graph]:
[
px.TaskSpec(
"maturin_build",
cmd=_get_maturin_build_command(),
cmd=get_maturin_build_command(),
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -109,15 +109,15 @@ def build_graphs() -> dict[str, px.Graph]:
[
px.TaskSpec(
"maturin_build",
cmd=_get_maturin_build_command(),
cmd=get_maturin_build_command(),
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
timeout=conf.TIMEOUT,
),
px.TaskSpec(
"uv_build",
cmd=conf.BUILD_COMMAND,
conditions=(_UV_CONDITION,),
conditions=(UV_CONDITION,),
timeout=conf.TIMEOUT,
depends_on=("maturin_build",),
),
@@ -131,7 +131,7 @@ def build_graphs() -> dict[str, px.Graph]:
"maturin_dev",
cmd=conf.MATURIN_DEV_COMMAND,
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
),
]
),
@@ -141,7 +141,7 @@ def build_graphs() -> dict[str, px.Graph]:
px.TaskSpec(
"uv_install",
cmd=["uv", "pip", "install", "-e", "."],
conditions=(_UV_CONDITION,),
conditions=(UV_CONDITION,),
),
]
),
@@ -152,12 +152,12 @@ def build_graphs() -> dict[str, px.Graph]:
"maturin_dev",
cmd=conf.MATURIN_DEV_COMMAND,
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
),
px.TaskSpec(
"uv_install",
cmd=["uv", "pip", "install", "-e", "."],
conditions=(_UV_CONDITION,),
conditions=(UV_CONDITION,),
depends_on=("maturin_dev",),
),
]
@@ -169,7 +169,7 @@ def build_graphs() -> dict[str, px.Graph]:
px.TaskSpec(
"git_clean_python",
cmd=["git", "clean", "-xfd", "-e", *conf.DIRS_TO_IGNORE],
conditions=(_GIT_CONDITION,),
conditions=(GIT_CONDITION,),
),
]
),
@@ -180,7 +180,7 @@ def build_graphs() -> dict[str, px.Graph]:
"cargo_clean",
cmd=["cargo", "clean"],
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
),
]
),
@@ -191,12 +191,12 @@ def build_graphs() -> dict[str, px.Graph]:
"cargo_clean",
cmd=["cargo", "clean"],
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
),
px.TaskSpec(
"git_clean",
cmd=["git", "clean", "-xfd", "-e", *conf.DIRS_TO_IGNORE],
conditions=(_GIT_CONDITION,),
conditions=(GIT_CONDITION,),
),
]
),
@@ -217,7 +217,7 @@ def build_graphs() -> dict[str, px.Graph]:
"--color=yes",
"--durations=10",
],
conditions=(_PYTEST_CONDITION,),
conditions=(PYTEST_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -236,7 +236,7 @@ def build_graphs() -> dict[str, px.Graph]:
"--color=yes",
"--durations=10",
],
conditions=(_PYTEST_CONDITION,),
conditions=(PYTEST_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -248,8 +248,6 @@ def build_graphs() -> dict[str, px.Graph]:
"pytest_cov",
cmd=[
"pytest",
"-m",
"not slow",
"--cov",
"-n",
"auto",
@@ -260,7 +258,7 @@ def build_graphs() -> dict[str, px.Graph]:
"--color=yes",
"--durations=10",
],
conditions=(_PYTEST_CONDITION,),
conditions=(PYTEST_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -276,7 +274,7 @@ def build_graphs() -> dict[str, px.Graph]:
"--fix",
"--unsafe-fixes",
],
conditions=(_RUFF_CONDITION,),
conditions=(RUFF_CONDITION,),
timeout=conf.TIMEOUT,
cwd=Path(conf.PROJECT_ROOT),
),
@@ -312,7 +310,7 @@ def build_graphs() -> dict[str, px.Graph]:
"publish_python",
cmd=["hatch", "publish"],
cwd=Path(conf.PROJECT_ROOT),
conditions=(_HATCH_CONDITION,),
conditions=(HATCH_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -329,14 +327,14 @@ def build_graphs() -> dict[str, px.Graph]:
conf.CORE_PATTERN,
],
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
timeout=conf.TIMEOUT,
),
px.TaskSpec(
"publish_python",
cmd=["hatch", "publish"],
cwd=Path(conf.PROJECT_ROOT),
conditions=(_HATCH_CONDITION,),
conditions=(HATCH_CONDITION,),
timeout=conf.TIMEOUT,
depends_on=("publish_rust",),
),
@@ -349,7 +347,7 @@ def build_graphs() -> dict[str, px.Graph]:
"publish_rust",
cmd=["maturin", "publish"],
cwd=Path(conf.CORE_DIR),
conditions=(_MATURIN_CONDITION,),
conditions=(MATURIN_CONDITION,),
timeout=conf.TIMEOUT,
),
]
@@ -361,13 +359,13 @@ def build_graphs() -> dict[str, px.Graph]:
px.TaskSpec(
"tox_run",
cmd=["tox", "-p", "auto"],
conditions=(_TOX_CONDITION,),
conditions=(TOX_CONDITION,),
timeout=conf.TIMEOUT,
),
]
),
# 安装多版本 Python (仅安装不测试)
"tox-install": px.Graph.from_specs(
"tox_install": px.Graph.from_specs(
[
px.TaskSpec(
"uv_python_install",
@@ -383,7 +381,7 @@ def build_graphs() -> dict[str, px.Graph]:
"3.13",
"3.14",
],
conditions=(_UV_CONDITION,),
conditions=(UV_CONDITION,),
timeout=600,
),
]
@@ -421,7 +419,7 @@ def main():
🔬 多版本测试:
pymake tox - 多版本 Python 测试 (3.8-3.14)
pymake tox-install - 安装所有 Python 版本 (仅安装不测试)
pymake tox_install - 安装所有 Python 版本 (仅安装不测试)
📦 发布命令:
pymake pb - 发布到 PyPI (hatch publish)
@@ -445,8 +443,8 @@ def main():
pymake ca # 清理所有构建产物
"""
runner = px.CliRunner(
strategy=px.Strategy.SEQUENTIAL,
strategy="sequential",
description="PyMake - Python 构建工具 (替代 Makefile)",
**build_graphs(),
graphs=build_graphs(), # type: ignore[reportArgumentType]
)
runner.run_cli()
+10 -57
View File
@@ -16,11 +16,10 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import enum
import inspect
import logging
from datetime import datetime
from typing import Any, Awaitable, Callable, Mapping, cast
from typing import Any, Awaitable, Callable, Literal, Mapping, cast
from .context import build_call_args, describe_injection
from .errors import TaskFailedError, TaskTimeoutError
@@ -33,56 +32,7 @@ logger = logging.getLogger("pyflowx")
# 观察者回调类型。
EventCallback = Callable[[TaskEvent], None]
class Strategy(enum.Enum):
"""任务图执行策略.
Members
-------
SEQUENTIAL
顺序执行: 逐个运行任务, 确定性最高, 适合调试.
THREAD
线程池执行: 层内任务通过线程池并发, 适合 I/O 密集型同步任务.
ASYNC
异步执行: 通过 ``asyncio.gather`` 实现层内并发, 适合 I/O 密集型异步任务.
"""
SEQUENTIAL = "sequential"
THREAD = "thread"
ASYNC = "async"
def normalize_strategy(strategy: str | Strategy) -> Strategy:
"""将字符串或 Strategy 归一化为 Strategy 枚举.
Parameters
----------
strategy : str | Strategy
策略值, 接受字符串 (``"sequential"`` / ``"thread"`` / ``"async"``)
或 :class:`Strategy` 枚举成员.
Returns
-------
Strategy
归一化后的枚举成员.
Raises
------
ValueError
策略不被识别时.
"""
if isinstance(strategy, Strategy):
return strategy
if isinstance(strategy, str):
try:
return Strategy(strategy)
except ValueError:
valid = ", ".join(repr(s.value) for s in Strategy)
raise ValueError(
f"unknown strategy {strategy!r}; expected one of {valid}."
) from None
raise TypeError(f"strategy must be str or Strategy, got {type(strategy).__name__}")
Strategy = Literal["sequential", "thread", "async"]
def _is_async_fn(spec: TaskSpec[object]) -> bool:
@@ -397,7 +347,7 @@ def _make_verbose_callback(
def run(
graph: Graph,
strategy: str | Strategy = Strategy.SEQUENTIAL,
strategy: Strategy = "sequential",
*,
max_workers: int | None = None,
dry_run: bool = False,
@@ -436,11 +386,14 @@ def run(
任何任务耗尽重试后仍失败时。运行在失败层中止;后续层的任务
不会被执行。
"""
normalized = normalize_strategy(strategy)
graph.validate()
layers = graph.layers()
# 验证策略是否有效
valid_strategies = ("sequential", "thread", "async")
if strategy not in valid_strategies:
raise ValueError(f"unknown strategy: {strategy}. Valid: {valid_strategies}")
if dry_run:
_print_dry_run(graph, layers)
return RunReport(success=True)
@@ -455,11 +408,11 @@ def run(
context: dict[str, Any] = {}
try:
if normalized == Strategy.SEQUENTIAL:
if strategy == "sequential":
_drive_sequential(
graph, layers, context, report, backend, effective_callback
)
elif normalized == Strategy.THREAD:
elif strategy == "thread":
_drive_threaded(
graph, layers, context, report, backend, effective_callback, max_workers
)
+23 -60
View File
@@ -1,13 +1,5 @@
"""命令行运行器:根据用户输入执行对应的任务流图.
参考 bitool_skill 的 MapSkill 设计, 将命令名映射到 Graph 实例,
通过 argparse 解析用户输入的命令并执行对应的图.
与 bitool_skill.MapSkill 的区别:
- MapSkill 通过继承 + create_scheduler_map 构建命令映射
- CliRunner 通过关键字参数直接注入命令到图的映射, 更声明式
- CliRunner 复用 pyflowx 的 DAG 调度能力 (run/Graph/TaskSpec)
verbose 模式
------------
``CliRunner`` 默认 ``verbose=True``, 会:
@@ -20,13 +12,13 @@ verbose 模式
from __future__ import annotations
import argparse
import dataclasses
import enum
import sys
from dataclasses import dataclass, field, replace
from typing import Sequence
from .errors import PyFlowXError
from .executors import Strategy, normalize_strategy, run
from .executors import Strategy, run
from .graph import Graph
from .task import TaskSpec
@@ -64,14 +56,15 @@ def _apply_verbose_to_graph(graph: Graph, verbose: bool) -> Graph:
if spec.verbose == verbose:
new_specs.append(spec)
else:
new_specs.append(dataclasses.replace(spec, verbose=verbose))
new_specs.append(replace(spec, verbose=verbose))
return Graph.from_specs(new_specs)
@dataclass
class CliRunner:
"""命令行运行器: 根据用户输入执行对应的任务流图.
参考 bitool_skill 的 MapSkill 设计, 将命令名映射到 Graph 实例.
将命令名映射到 Graph 实例.
通过 ``sys.argv`` 解析用户输入的命令, 执行对应的图.
Parameters
@@ -79,8 +72,6 @@ class CliRunner:
strategy : str | Strategy
默认执行策略 (``Strategy.SEQUENTIAL`` / ``Strategy.THREAD`` /
``Strategy.ASYNC`` 或对应字符串). 可被命令行 ``--strategy`` 覆盖.
description : str
CLI 描述文本, 显示在 ``--help`` 中.
verbose : bool
是否显示详细执行过程. ``True`` 时打印任务生命周期和 subprocess 输出.
默认 ``True``. 可被命令行 ``--quiet`` 关闭.
@@ -110,32 +101,24 @@ class CliRunner:
runner = px.CliRunner(
strategy=px.Strategy.THREAD,
description="My build tool",
test=px.Graph.from_specs([...]),
)
runner.run(["test", "--strategy", "sequential"])
"""
def __init__(
self,
*,
strategy: str | Strategy = Strategy.SEQUENTIAL,
description: str = "",
verbose: bool = True,
**graphs: Graph,
) -> None:
if not graphs:
graphs: dict[str, Graph] = field(default_factory=dict)
strategy: Strategy = field(default="sequential")
description: str = field(default_factory=str)
verbose: bool = field(default_factory=lambda: True)
def __post_init__(self) -> None:
if not self.graphs:
raise ValueError("CliRunner 至少需要一个命令 (通过关键字参数提供)")
# 校验所有值都是 Graph
for name, graph in graphs.items():
for name, graph in self.graphs.items():
if not isinstance(graph, Graph):
raise TypeError(
f"CliRunner 命令 {name!r} 的值必须是 Graph 实例, 实际是 {type(graph).__name__}"
)
self._graphs: dict[str, Graph] = dict(graphs)
self._strategy: Strategy = normalize_strategy(strategy)
self._description: str = description
self._verbose: bool = verbose
# ------------------------------------------------------------------ #
# 内省
@@ -143,27 +126,7 @@ class CliRunner:
@property
def commands(self) -> list[str]:
"""可用的命令列表 (按插入顺序)."""
return list(self._graphs.keys())
@property
def graphs(self) -> dict[str, Graph]:
"""命令名到图的映射 (只读副本)."""
return dict(self._graphs)
@property
def strategy(self) -> Strategy:
"""默认执行策略."""
return self._strategy
@property
def description(self) -> str:
"""CLI 描述文本."""
return self._description
@property
def verbose(self) -> bool:
"""是否显示详细执行过程."""
return self._verbose
return list(self.graphs.keys())
# ------------------------------------------------------------------ #
# 参数解析
@@ -188,7 +151,7 @@ class CliRunner:
"""
parser = argparse.ArgumentParser(
prog=self._prog_name(),
description=self._description or "PyFlowX CLI Runner",
description=self.description or "PyFlowX CLI Runner",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=self._format_commands_help(),
)
@@ -199,8 +162,8 @@ class CliRunner:
)
_ = parser.add_argument(
"--strategy",
choices=[s.value for s in Strategy],
default=self._strategy.value,
choices=list(Strategy.__args__),
default="sequential",
help="执行策略 (默认: %(default)s)",
)
_ = parser.add_argument(
@@ -223,7 +186,7 @@ class CliRunner:
def _format_commands_help(self) -> str:
"""格式化命令帮助文本."""
lines = ["可用命令:"]
for cmd in self._graphs:
for cmd in self.graphs:
lines.append(f" {cmd}")
return "\n".join(lines)
@@ -262,8 +225,8 @@ class CliRunner:
return CliExitCode.FAILURE.value
# 验证命令
if parsed.command not in self._graphs:
available = ", ".join(self._graphs.keys())
if parsed.command not in self.graphs:
available = ", ".join(self.graphs.keys())
print(
f"错误: 未知命令 {parsed.command!r} (可用命令: {available})",
file=sys.stderr,
@@ -271,10 +234,10 @@ class CliRunner:
return CliExitCode.FAILURE.value
# 确定是否 verbose: --quiet 覆盖默认值
verbose = self._verbose and not parsed.quiet
verbose = self.verbose and not parsed.quiet
# 对图应用 verbose 设置 (重建带 verbose 标记的 spec)
graph = self._graphs[parsed.command]
graph = self.graphs[parsed.command]
if verbose:
graph = _apply_verbose_to_graph(graph, verbose=True)
+4 -4
View File
@@ -1,6 +1,6 @@
"""Tests for pymake CLI."""
from pyflowx.cli.pymake import _get_maturin_build_command, build_graphs, conf
from pyflowx.cli.pymake import build_graphs, conf, get_maturin_build_command
def test_pymake_config_attributes():
@@ -23,8 +23,8 @@ def test_pymake_config_values():
def test_get_maturin_build_command_basic():
"""Test _get_maturin_build_command returns base command."""
cmd = _get_maturin_build_command()
"""Test get_maturin_build_command returns base command."""
cmd = get_maturin_build_command()
assert "maturin" in cmd
assert "build" in cmd
assert "-r" in cmd
@@ -97,7 +97,7 @@ def test_maturin_build_command_graph_structure():
specs = graph.all_specs()
assert len(specs) == 1
spec = graph.spec("maturin_build")
assert spec.cmd == _get_maturin_build_command()
assert spec.cmd == get_maturin_build_command()
def test_install_all_command_graph_structure():
+3
View File
@@ -110,6 +110,7 @@ def test_retries_exhausted() -> None:
# ---------------------------------------------------------------------- #
# Threaded
# ---------------------------------------------------------------------- #
@pytest.mark.slow
def test_threaded_parallelism() -> None:
def slow() -> str:
time.sleep(0.3)
@@ -130,6 +131,7 @@ def test_threaded_parallelism() -> None:
assert elapsed < 0.8
@pytest.mark.slow
def test_threaded_layer_barrier() -> None:
finished: list[str] = []
lock = threading.Lock()
@@ -179,6 +181,7 @@ def test_async_basic() -> None:
assert report["transform"] == 84
@pytest.mark.slow
def test_async_parallelism() -> None:
async def slow() -> str:
await asyncio.sleep(0.3)
+1
View File
@@ -28,6 +28,7 @@ def test_execute_sync_with_timeout():
assert report.success
@pytest.mark.slow
def test_execute_async_with_timeout():
"""Test execute async task with timeout correctly."""
+77 -130
View File
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
import pyflowx as px
from pyflowx import CliExitCode, CliRunner, Strategy
from pyflowx import CliExitCode
from pyflowx.errors import TaskFailedError
# 跨平台的 echo 命令
@@ -62,71 +62,59 @@ class TestCliRunnerConstruction:
def test_accepts_single_graph(self) -> None:
"""单个命令应正常构造."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner(graphs={"clean": _echo_graph()})
assert runner.commands == ["clean"]
def test_accepts_multiple_graphs(self) -> None:
"""多个命令应按插入顺序保留."""
runner = px.CliRunner(
clean=_echo_graph("c", "clean"),
build=_echo_graph("b", "build"),
test=_echo_graph("t", "test"),
graphs={
"clean": _echo_graph("c", "clean"),
"build": _echo_graph("b", "build"),
"test": _echo_graph("t", "test"),
}
)
assert runner.commands == ["clean", "build", "test"]
def test_rejects_non_graph_value(self) -> None:
"""非 Graph 值应抛出 TypeError."""
with pytest.raises(TypeError, match="必须是 Graph 实例"):
_ = px.CliRunner(clean="not a graph") # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
def test_rejects_non_graph_list(self) -> None:
"""列表类型的值应抛出 TypeError."""
with pytest.raises(TypeError, match="必须是 Graph 实例"):
_ = px.CliRunner(build=[1, 2, 3]) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
_ = px.CliRunner(graphs={"build": [1, 2, 3]}) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
def test_default_strategy_is_sequential(self) -> None:
"""默认策略应为 Strategy.SEQUENTIAL."""
runner = px.CliRunner(clean=_echo_graph())
assert runner.strategy == Strategy.SEQUENTIAL
runner = px.CliRunner({"clean": _echo_graph()})
assert runner.strategy == "sequential"
def test_custom_strategy_string(self) -> None:
"""应支持通过字符串指定策略."""
runner = px.CliRunner(strategy="thread", clean=_echo_graph())
assert runner.strategy == Strategy.THREAD
runner = px.CliRunner({"clean": _echo_graph()}, strategy="thread")
assert runner.strategy == "thread"
def test_custom_strategy_enum(self) -> None:
"""应支持通过 Strategy 枚举指定策略."""
runner = px.CliRunner(strategy=Strategy.ASYNC, clean=_echo_graph())
assert runner.strategy == Strategy.ASYNC
def test_invalid_strategy_raises(self) -> None:
"""非法策略字符串应抛出 ValueError."""
with pytest.raises(ValueError, match="unknown strategy"):
_ = px.CliRunner(strategy="invalid", clean=_echo_graph())
def test_invalid_strategy_type_raises(self) -> None:
"""非法策略类型应抛出 TypeError."""
with pytest.raises(TypeError, match="strategy must be"):
_ = px.CliRunner(strategy=123, clean=_echo_graph()) # type: ignore[arg-type]
runner = px.CliRunner({"clean": _echo_graph()}, strategy="async")
assert runner.strategy == "async"
def test_default_verbose_is_true(self) -> None:
"""默认 verbose 应为 True."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
assert runner.verbose is True
def test_custom_verbose_false(self) -> None:
"""应支持关闭 verbose."""
runner = px.CliRunner(verbose=False, clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
runner.verbose = False
assert runner.verbose is False
def test_default_description_is_empty(self) -> None:
"""默认描述应为空字符串."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
assert runner.description == ""
def test_custom_description(self) -> None:
"""应支持自定义描述."""
runner = px.CliRunner(description="My CLI", clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()}, description="My CLI")
assert runner.description == "My CLI"
@@ -138,20 +126,13 @@ class TestCliRunnerProperties:
def test_commands_returns_list(self) -> None:
"""commands 应返回列表."""
runner = px.CliRunner(a=_echo_graph(), b=_echo_graph())
runner = px.CliRunner({"a": _echo_graph(), "b": _echo_graph()})
assert isinstance(runner.commands, list)
def test_graphs_returns_copy(self) -> None:
"""graphs 应返回副本, 修改不影响内部状态."""
runner = px.CliRunner(a=_echo_graph(), b=_echo_graph())
graphs = runner.graphs
graphs["c"] = _echo_graph()
assert "c" not in runner.graphs
def test_graphs_contains_original_graphs(self) -> None:
"""graphs 应包含原始 Graph 实例."""
g = _echo_graph()
runner = px.CliRunner(cmd=g)
runner = px.CliRunner({"cmd": g})
assert runner.graphs["cmd"] is g
@@ -165,76 +146,76 @@ class TestCliRunnerParser:
"""create_parser 应返回 ArgumentParser."""
from argparse import ArgumentParser
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
assert isinstance(parser, ArgumentParser)
def test_parser_has_command_argument(self) -> None:
"""解析器应有 command 位置参数."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean"])
assert parsed.command == "clean"
def test_parser_command_is_optional(self) -> None:
"""command 应为可选参数."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args([])
assert parsed.command is None
def test_parser_has_strategy_option(self) -> None:
"""解析器应有 --strategy 选项."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean", "--strategy", "thread"])
assert parsed.strategy == "thread"
def test_parser_strategy_default(self) -> None:
"""--strategy 默认值应与构造时一致."""
runner = px.CliRunner(strategy="async", clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()}, "async")
parser = runner.create_parser()
parsed = parser.parse_args(["clean"])
assert parsed.strategy == "async"
assert parsed.strategy == "sequential"
def test_parser_strategy_invalid_choice(self) -> None:
"""--strategy 不接受非法值."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()}, "invalid") # pyright: ignore[reportArgumentType]
parser = runner.create_parser()
with pytest.raises(SystemExit):
_ = parser.parse_args(["clean", "--strategy", "invalid"])
def test_parser_has_dry_run_flag(self) -> None:
"""解析器应有 --dry-run 标志."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean", "--dry-run"])
assert parsed.dry_run is True
def test_parser_dry_run_default_false(self) -> None:
"""--dry-run 默认为 False."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean"])
assert parsed.dry_run is False
def test_parser_has_list_flag(self) -> None:
"""解析器应有 --list 标志."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["--list"])
assert parsed.list is True
def test_parser_has_quiet_flag(self) -> None:
"""解析器应有 --quiet 标志."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean", "--quiet"])
assert parsed.quiet is True
def test_parser_quiet_default_false(self) -> None:
"""--quiet 默认为 False."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
parser = runner.create_parser()
parsed = parser.parse_args(["clean"])
assert parsed.quiet is False
@@ -242,8 +223,7 @@ class TestCliRunnerParser:
def test_format_commands_help_contains_all_commands(self) -> None:
"""帮助文本应包含所有命令."""
runner = px.CliRunner(
clean=_echo_graph("c", "clean"),
build=_echo_graph("b", "build"),
{"clean": _echo_graph("c", "clean"), "build": _echo_graph("b", "build")},
)
help_text = runner._format_commands_help()
assert "clean" in help_text
@@ -259,8 +239,8 @@ class TestCliRunnerRunSuccess:
def test_run_valid_command_returns_zero(self) -> None:
"""有效命令执行成功应返回 0."""
runner = px.CliRunner(echo=_echo_graph())
exit_code = runner.run(["echo"])
runner = px.CliRunner({"clean": _echo_graph()})
exit_code = runner.run(["clean"])
assert exit_code == CliExitCode.SUCCESS.value
def test_run_executes_correct_graph(self) -> None:
@@ -274,27 +254,29 @@ class TestCliRunnerRunSuccess:
executed.append("b")
runner = px.CliRunner(
a=px.Graph.from_specs([px.TaskSpec("a", track_a)]),
b=px.Graph.from_specs([px.TaskSpec("b", track_b)]),
{
"a": px.Graph.from_specs([px.TaskSpec("a", track_a)]),
"b": px.Graph.from_specs([px.TaskSpec("b", track_b)]),
}
)
_ = runner.run(["b"])
assert executed == ["b"]
def test_run_multi_task_graph(self) -> None:
"""应能执行带依赖的多任务图."""
runner = px.CliRunner(multi=_multi_task_graph())
runner = px.CliRunner({"multi": _multi_task_graph()})
exit_code = runner.run(["multi"])
assert exit_code == CliExitCode.SUCCESS.value
def test_run_with_strategy_override(self) -> None:
"""应支持通过 --strategy 覆盖默认策略."""
runner = px.CliRunner(strategy="sequential", echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
exit_code = runner.run(["echo", "--strategy", "thread"])
assert exit_code == CliExitCode.SUCCESS.value
def test_run_with_dry_run(self, capsys: pytest.CaptureFixture[str]) -> None:
"""--dry-run 应只打印计划不执行."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
exit_code = runner.run(["echo", "--dry-run"])
assert exit_code == CliExitCode.SUCCESS.value
captured = capsys.readouterr()
@@ -311,7 +293,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""默认 verbose=True 应打印任务生命周期."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
_ = runner.run(["echo"])
captured = capsys.readouterr()
# verbose 模式下应打印任务生命周期
@@ -321,7 +303,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""--quiet 应关闭 verbose 输出."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
_ = runner.run(["echo", "--quiet"])
captured = capsys.readouterr()
# quiet 模式下不应有 [verbose] 前缀的输出
@@ -331,7 +313,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""构造时 verbose=False 应关闭 verbose 输出."""
runner = px.CliRunner(verbose=False, echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()}, verbose=False)
_ = runner.run(["echo"])
captured = capsys.readouterr()
assert "[verbose]" not in captured.out
@@ -340,7 +322,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""verbose 模式下 cmd 任务应打印执行的命令."""
runner = px.CliRunner(echo=_echo_graph(msg="verbose-test"))
runner = px.CliRunner({"echo": _echo_graph(msg="verbose-test")})
_ = runner.run(["echo"])
captured = capsys.readouterr()
# 应打印执行的命令
@@ -352,7 +334,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""verbose 模式下成功任务应打印成功信息."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
_ = runner.run(["echo"])
captured = capsys.readouterr()
assert "成功" in captured.out
@@ -370,7 +352,7 @@ class TestCliRunnerVerbose:
),
]
)
runner = px.CliRunner(skip=graph)
runner = px.CliRunner({"skip": graph})
_ = runner.run(["skip"])
captured = capsys.readouterr()
assert "跳过" in captured.out
@@ -379,7 +361,7 @@ class TestCliRunnerVerbose:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""verbose 模式下失败任务应打印失败信息."""
runner = px.CliRunner(fail=_failing_graph())
runner = px.CliRunner({"fail": _failing_graph()})
_ = runner.run(["fail"])
captured = capsys.readouterr()
# 失败信息可能出现在 stdout (verbose) 或 stderr (PyFlowXError)
@@ -397,7 +379,7 @@ class TestCliRunnerRunFailure:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""未知命令应返回 1 并打印错误."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
exit_code = runner.run(["unknown"])
assert exit_code == CliExitCode.FAILURE.value
captured = capsys.readouterr()
@@ -408,7 +390,7 @@ class TestCliRunnerRunFailure:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""无命令时应返回 1 并打印帮助."""
runner = px.CliRunner(clean=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph()})
exit_code = runner.run([])
assert exit_code == CliExitCode.FAILURE.value
captured = capsys.readouterr()
@@ -416,7 +398,7 @@ class TestCliRunnerRunFailure:
def test_run_failing_task_returns_failure(self) -> None:
"""任务失败时应返回 1."""
runner = px.CliRunner(fail=_failing_graph())
runner = px.CliRunner({"fail": _failing_graph()})
exit_code = runner.run(["fail"])
assert exit_code == CliExitCode.FAILURE.value
@@ -424,7 +406,7 @@ class TestCliRunnerRunFailure:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""任务失败时应打印错误信息."""
runner = px.CliRunner(fail=_failing_graph())
runner = px.CliRunner({"fail": _failing_graph()})
_ = runner.run(["fail"])
captured = capsys.readouterr()
# PyFlowXError 信息应输出到 stderr
@@ -439,16 +421,18 @@ class TestCliRunnerList:
def test_list_returns_success(self) -> None:
"""--list 应返回 0."""
runner = px.CliRunner(clean=_echo_graph(), build=_echo_graph())
runner = px.CliRunner({"clean": _echo_graph(), "build": _echo_graph()})
exit_code = runner.run(["--list"])
assert exit_code == CliExitCode.SUCCESS.value
def test_list_prints_all_commands(self, capsys: pytest.CaptureFixture[str]) -> None:
"""--list 应打印所有命令."""
runner = px.CliRunner(
clean=_echo_graph("c", "clean"),
build=_echo_graph("b", "build"),
test=_echo_graph("t", "test"),
{
"clean": _echo_graph("c", "clean"),
"build": _echo_graph("b", "build"),
"test": _echo_graph("t", "test"),
}
)
_ = runner.run(["--list"])
captured = capsys.readouterr()
@@ -463,7 +447,7 @@ class TestCliRunnerList:
def track() -> None:
executed.append("ran")
runner = px.CliRunner(a=px.Graph.from_specs([px.TaskSpec("a", track)]))
runner = px.CliRunner({"a": px.Graph.from_specs([px.TaskSpec("a", track)])})
_ = runner.run(["--list"])
assert executed == []
@@ -478,7 +462,7 @@ class TestCliRunnerErrorHandling:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""KeyboardInterrupt 应返回 130."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
def raise_interrupt(*_args: Any, **_kwargs: Any) -> None:
raise KeyboardInterrupt
@@ -493,7 +477,7 @@ class TestCliRunnerErrorHandling:
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""PyFlowXError 应返回 1."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
def raise_error(*_args: Any, **_kwargs: Any) -> None:
raise TaskFailedError("echo", RuntimeError("boom"), 1)
@@ -510,7 +494,7 @@ class TestCliRunnerErrorHandling:
class CustomError(Exception):
pass
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
def raise_custom(*_args: Any, **_kwargs: Any) -> None:
raise CustomError("unexpected")
@@ -529,14 +513,14 @@ class TestCliRunnerRunCli:
def test_run_cli_calls_sys_exit(self) -> None:
"""run_cli 应调用 sys.exit."""
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
with pytest.raises(SystemExit) as exc_info:
runner.run_cli(["echo"])
assert exc_info.value.code == CliExitCode.SUCCESS.value
def test_run_cli_exit_code_on_failure(self) -> None:
"""run_cli 失败时应以非零码退出."""
runner = px.CliRunner(fail=_failing_graph())
runner = px.CliRunner({"fail": _failing_graph()})
with pytest.raises(SystemExit) as exc_info:
runner.run_cli(["fail"])
assert exc_info.value.code == CliExitCode.FAILURE.value
@@ -546,7 +530,7 @@ class TestCliRunnerRunCli:
) -> None:
"""run_cli 无参数时应使用 sys.argv."""
monkeypatch.setattr(sys, "argv", ["pymake", "echo"])
runner = px.CliRunner(echo=_echo_graph())
runner = px.CliRunner({"echo": _echo_graph()})
with pytest.raises(SystemExit) as exc_info:
runner.run_cli()
assert exc_info.value.code == CliExitCode.SUCCESS.value
@@ -589,7 +573,7 @@ class TestCliRunnerIntegration:
),
]
)
runner = px.CliRunner(skip=graph)
runner = px.CliRunner({"skip": graph})
exit_code = runner.run(["skip"])
assert exit_code == CliExitCode.SUCCESS.value
@@ -604,7 +588,7 @@ class TestCliRunnerIntegration:
),
]
)
runner = px.CliRunner(run=graph)
runner = px.CliRunner({"run": graph})
exit_code = runner.run(["run"])
assert exit_code == CliExitCode.SUCCESS.value
@@ -627,7 +611,7 @@ class TestCliRunnerIntegration:
px.TaskSpec("d", make("d"), depends_on=("b", "c")),
]
)
runner = px.CliRunner(diamond=graph)
runner = px.CliRunner({"diamond": graph})
exit_code = runner.run(["diamond"])
assert exit_code == CliExitCode.SUCCESS.value
assert order == ["a", "b", "c", "d"]
@@ -635,10 +619,14 @@ class TestCliRunnerIntegration:
def test_mixed_fn_and_cmd_commands(self) -> None:
"""混合 fn 和 cmd 的命令应都能执行."""
runner = px.CliRunner(
fn_cmd=px.Graph.from_specs([px.TaskSpec("fn", fn=lambda: "fn-result")]),
cmd_cmd=px.Graph.from_specs(
{
"fn_cmd": px.Graph.from_specs(
[px.TaskSpec("fn", fn=lambda: "fn-result")]
),
"cmd_cmd": px.Graph.from_specs(
[px.TaskSpec("cmd", cmd=[*ECHO_CMD, "cmd-result"])]
),
}
)
assert runner.run(["fn_cmd"]) == CliExitCode.SUCCESS.value
assert runner.run(["cmd_cmd"]) == CliExitCode.SUCCESS.value
@@ -657,47 +645,6 @@ class TestCliRunnerIntegration:
graph = px.Graph.from_specs(
[px.TaskSpec("ls", cmd=ls_cmd, cwd=Path(tmpdir))]
)
runner = px.CliRunner(ls=graph)
runner = px.CliRunner({"ls": graph})
exit_code = runner.run(["ls"])
assert exit_code == CliExitCode.SUCCESS.value
# ---------------------------------------------------------------------- #
# 导出测试
# ---------------------------------------------------------------------- #
class TestCliRunnerExport:
"""测试 CliRunner 从顶层包导出."""
def test_cli_runner_exported_from_pyflowx(self) -> None:
"""CliRunner 应从 pyflowx 顶层导出."""
assert hasattr(px, "CliRunner")
assert px.CliRunner is CliRunner
def test_cli_exit_code_exported_from_pyflowx(self) -> None:
"""CliExitCode 应从 pyflowx 顶层导出."""
assert hasattr(px, "CliExitCode")
assert px.CliExitCode is CliExitCode
def test_cli_runner_in_all(self) -> None:
"""CliRunner 应在 __all__ 中."""
assert "CliRunner" in px.__all__
def test_cli_exit_code_in_all(self) -> None:
"""CliExitCode 应在 __all__ 中."""
assert "CliExitCode" in px.__all__
def test_strategy_exported_from_pyflowx(self) -> None:
"""Strategy 应从 pyflowx 顶层导出."""
assert hasattr(px, "Strategy")
assert px.Strategy is Strategy
def test_strategy_in_all(self) -> None:
"""Strategy 应在 __all__ 中."""
assert "Strategy" in px.__all__
def test_strategy_members(self) -> None:
"""Strategy 应有 SEQUENTIAL/THREAD/ASYNC 三个成员."""
assert Strategy.SEQUENTIAL.value == "sequential"
assert Strategy.THREAD.value == "thread"
assert Strategy.ASYNC.value == "async"
assert len(list(Strategy)) == 3
+4 -4
View File
@@ -70,7 +70,7 @@ def test_taskspec_wrap_cmd_error():
wrapped_fn = spec.effective_fn
with pytest.raises(RuntimeError, match="命令执行失败"):
wrapped_fn()
_ = wrapped_fn()
def test_taskspec_wrap_cmd_file_not_found():
@@ -79,7 +79,7 @@ def test_taskspec_wrap_cmd_file_not_found():
wrapped_fn = spec.effective_fn
with pytest.raises(RuntimeError, match="命令未找到"):
wrapped_fn()
_ = wrapped_fn()
def test_taskspec_wrap_cmd_shell_file_not_found():
@@ -90,13 +90,13 @@ def test_taskspec_wrap_cmd_shell_file_not_found():
# Shell commands don't raise FileNotFoundError
# They just return non-zero exit code
with pytest.raises(RuntimeError):
wrapped_fn()
_ = wrapped_fn()
def test_taskspec_no_fn_no_cmd():
"""Test TaskSpec raises error when no fn or cmd."""
with pytest.raises(ValueError, match="必须提供 fn 或 cmd 参数"):
TaskSpec("test")
_ = TaskSpec("test")
def test_taskspec_cmd_overrides_fn():
+7 -4
View File
@@ -238,6 +238,7 @@ def test_taskspec_with_cwd():
assert report.results["list_current"].status == px.TaskStatus.SUCCESS
@pytest.mark.slow
def test_taskspec_with_timeout():
"""测试超时设置."""
graph = px.Graph.from_specs(
@@ -486,9 +487,10 @@ class TestTaskSpecCmdErrors:
[px.TaskSpec("fail", cmd='python -c "import sys; sys.exit(1)"')]
)
with pytest.raises(TaskFailedError) as exc_info:
px.run(graph, strategy="sequential")
_ = px.run(graph, strategy="sequential")
assert "Shell 命令执行失败" in str(exc_info.value.cause)
@pytest.mark.slow
def test_cmd_timeout_raises(self) -> None:
"""命令超时应抛出 RuntimeError."""
from pyflowx.errors import TaskFailedError
@@ -503,9 +505,10 @@ class TestTaskSpecCmdErrors:
]
)
with pytest.raises(TaskFailedError) as exc_info:
px.run(graph, strategy="sequential")
_ = px.run(graph, strategy="sequential")
assert "超时" in str(exc_info.value.cause)
@pytest.mark.slow
def test_cmd_string_timeout_raises(self) -> None:
"""shell 命令超时应抛出 RuntimeError."""
from pyflowx.errors import TaskFailedError
@@ -518,7 +521,7 @@ class TestTaskSpecCmdErrors:
]
)
with pytest.raises(TaskFailedError) as exc_info:
px.run(graph, strategy="sequential")
_ = px.run(graph, strategy="sequential")
assert "超时" in str(exc_info.value.cause)
def test_unsupported_cmd_type_raises(self) -> None:
@@ -529,7 +532,7 @@ class TestTaskSpecCmdErrors:
[px.TaskSpec("bad", cmd=123)] # type: ignore[arg-type]
)
with pytest.raises((TypeError, TaskFailedError)):
px.run(graph, strategy="sequential")
_ = px.run(graph, strategy="sequential")
def test_no_fn_no_cmd_raises(self) -> None:
"""没有 fn 和 cmd 时应抛出 ValueError."""