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:
+36
-38
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user