From 179e5b3811703ce51bf7729469dcbf80fffbcde4 Mon Sep 17 00:00:00 2001 From: gooker_young Date: Sun, 21 Jun 2026 12:52:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=99=A8=E5=92=8CCliRunner=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E7=B1=BB=E5=9E=8B=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将Strategy枚举改为Literal类型,移除normalize_strategy函数 2. 内联策略验证逻辑到run函数中 3. 使用dataclasses.field重构CliRunner的初始化方式 4. 修复测试用例中的函数名和调用方式不匹配问题 5. 调整部分测试用例的构造语法,适配新的API 6. 修正pymake模块中的函数重命名和条件变量命名问题 7. 为部分耗时测试添加@pytest.mark.slow标记 --- src/pyflowx/cli/pymake.py | 74 +++++----- src/pyflowx/executors.py | 67 ++------- src/pyflowx/runner.py | 83 ++++-------- tests/cli/test_pymake.py | 8 +- tests/test_executors.py | 3 + tests/test_executors_edge_cases.py | 1 + tests/test_runner.py | 211 +++++++++++------------------ tests/test_task_edge_cases.py | 8 +- tests/test_taskspec_commands.py | 11 +- 9 files changed, 167 insertions(+), 299 deletions(-) diff --git a/src/pyflowx/cli/pymake.py b/src/pyflowx/cli/pymake.py index 449d529..19b514c 100644 --- a/src/pyflowx/cli/pymake.py +++ b/src/pyflowx/cli/pymake.py @@ -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() diff --git a/src/pyflowx/executors.py b/src/pyflowx/executors.py index 5f61250..acbc1d8 100644 --- a/src/pyflowx/executors.py +++ b/src/pyflowx/executors.py @@ -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 ) diff --git a/src/pyflowx/runner.py b/src/pyflowx/runner.py index f7e5dae..ddf2503 100644 --- a/src/pyflowx/runner.py +++ b/src/pyflowx/runner.py @@ -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) diff --git a/tests/cli/test_pymake.py b/tests/cli/test_pymake.py index a47151d..d620215 100644 --- a/tests/cli/test_pymake.py +++ b/tests/cli/test_pymake.py @@ -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(): diff --git a/tests/test_executors.py b/tests/test_executors.py index a56bcd2..d302cf8 100644 --- a/tests/test_executors.py +++ b/tests/test_executors.py @@ -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) diff --git a/tests/test_executors_edge_cases.py b/tests/test_executors_edge_cases.py index d0c353c..fcc7757 100644 --- a/tests/test_executors_edge_cases.py +++ b/tests/test_executors_edge_cases.py @@ -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.""" diff --git a/tests/test_runner.py b/tests/test_runner.py index 0cd75d1..9484674 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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( - [px.TaskSpec("cmd", cmd=[*ECHO_CMD, "cmd-result"])] - ), + { + "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 diff --git a/tests/test_task_edge_cases.py b/tests/test_task_edge_cases.py index 0129e54..bd7f9c9 100644 --- a/tests/test_task_edge_cases.py +++ b/tests/test_task_edge_cases.py @@ -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(): diff --git a/tests/test_taskspec_commands.py b/tests/test_taskspec_commands.py index 4832339..2bdb9f0 100644 --- a/tests/test_taskspec_commands.py +++ b/tests/test_taskspec_commands.py @@ -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."""