Files
pyflowx/tests/test_tools.py
T
zhou 6da42ec5ff refactor: 移除YAML配置,统一使用@px.tool装饰器实现CLI工具
1.  新增tools.py模块实现@px.tool装饰器与工具注册表
2.  将所有configs下的YAML工具迁移为ops/xxx.py模块形式
3.  重构cli路由逻辑,优先加载Python工具实现回退YAML
4.  删除所有YAML配置文件与旧的yaml_loader相关代码
5.  调整__init__.py导出API,移除YAML相关依赖
2026-07-06 07:53:22 +08:00

1311 lines
37 KiB
Python

"""tests/test_tools.py - @px.tool 装饰器核心模块测试.
覆盖 tools.py 全部分支:注册/重复检测/CLI 生成各类型/单命令执行/
cmd/fn/聚合/DAG/hidden/全局选项/退出码三态.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any, Literal, Optional, Union
import pytest
from pyflowx import tools
from pyflowx.executors import RunReport
from pyflowx.task import RetryPolicy
from pyflowx.tools import (
ToolExitCode,
_add_func_args_to_parser,
_add_global_options,
_build_parser,
_build_task_spec,
_collect_with_deps,
_extract_variables,
_handle_list,
_has_function_logic,
_is_aggregate,
_map_param_type,
_noop,
_snake_to_kebab,
_unwrap_optional,
clear_tool_registry,
get_tool,
list_subcommands,
list_tools,
run_tool,
tool,
)
# ---------------------------------------------------------------------- #
# fixture
# ---------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def clear_registry() -> None:
"""每条用例前清空工具注册表,避免相互污染."""
clear_tool_registry()
@pytest.fixture
def mock_run(monkeypatch: pytest.MonkeyPatch) -> list[Any]:
"""mock pyflowx.tools.run,避免实际执行任务;记录调用参数."""
calls: list[Any] = []
def _fake_run(graph: Any, **kwargs: Any) -> RunReport:
calls.append((graph, kwargs))
return RunReport(success=True)
monkeypatch.setattr(tools, "run", _fake_run)
return calls
# ---------------------------------------------------------------------- #
# ToolExitCode + _noop
# ---------------------------------------------------------------------- #
class TestToolExitCode:
"""测试退出码枚举."""
def test_values(self) -> None:
assert ToolExitCode.SUCCESS == 0
assert ToolExitCode.FAILURE == 1
assert ToolExitCode.INTERRUPTED == 130
class TestNoop:
"""测试聚合任务占位函数."""
def test_returns_none(self) -> None:
assert _noop() is None
# ---------------------------------------------------------------------- #
# @tool 装饰器 + 注册表
# ---------------------------------------------------------------------- #
class TestToolDecorator:
"""测试 @tool 装饰器与注册表."""
def test_register_single_command(self) -> None:
"""单命令工具注册到注册表."""
@tool("mytool")
def myfunc(x: int = 1) -> None:
pass
assert "mytool" in list_tools()
spec = get_tool("mytool")
assert spec.name == "mytool"
assert spec.subcommand is None
assert spec.func is myfunc
def test_register_multi_subcommand(self) -> None:
"""同一工具名多 subcommand 注册."""
@tool("pdftool", subcommand="m", help="合并")
def merge(inputs: list[Path]) -> None:
pass
@tool("pdftool", subcommand="s", help="拆分")
def split(input: Path) -> None:
pass
assert list_subcommands("pdftool") == ["m", "s"]
assert get_tool("pdftool", "m").func is merge
assert get_tool("pdftool", "s").func is split
def test_help_defaults_to_docstring(self) -> None:
"""help 为空时使用函数 docstring."""
@tool("t")
def f(x: int = 1) -> None:
"""我的文档."""
assert get_tool("t").help == "我的文档."
def test_help_explicit_overrides_docstring(self) -> None:
"""显式 help 优先于 docstring."""
@tool("t", help="显式帮助")
def f(x: int = 1) -> None:
"""文档."""
assert get_tool("t").help == "显式帮助"
def test_cmd_tuple_conversion(self) -> None:
"""cmd list 转为 tuple 存储."""
@tool("t", cmd=["echo", "hi"])
def f() -> None:
pass
spec = get_tool("t")
assert spec.cmd == ("echo", "hi")
def test_cmd_str_kept_as_str(self) -> None:
"""cmd str 保持 str."""
@tool("t", cmd="echo hi")
def f() -> None:
pass
assert get_tool("t").cmd == "echo hi"
def test_needs_tuple_conversion(self) -> None:
"""needs list 转为 tuple."""
@tool("t", subcommand="a", needs=["b", "c"])
def a() -> None:
pass
@tool("t", subcommand="b")
def b() -> None:
pass
@tool("t", subcommand="c")
def c() -> None:
pass
assert get_tool("t", "a").needs == ("b", "c")
def test_env_dict_copy(self) -> None:
"""env dict 被复制(避免外部修改)."""
env = {"KEY": "val"}
@tool("t", env=env)
def f() -> None:
pass
spec = get_tool("t")
assert spec.env == {"KEY": "val"}
env["KEY"] = "modified"
assert spec.env == {"KEY": "val"}
def test_retry_passthrough(self) -> None:
"""retry 透传 ToolSpec."""
retry = RetryPolicy(max_attempts=3)
@tool("t", retry=retry)
def f() -> None:
pass
assert get_tool("t").retry is retry
def test_timeout_passthrough(self) -> None:
"""timeout 透传."""
@tool("t", timeout=30.0)
def f() -> None:
pass
assert get_tool("t").timeout == 30.0
def test_strategy_passthrough(self) -> None:
"""strategy 透传."""
@tool("t", strategy="thread")
def f() -> None:
pass
assert get_tool("t").strategy == "thread"
def test_cwd_passthrough(self) -> None:
"""cwd 透传."""
@tool("t", cwd="/tmp")
def f() -> None:
pass
assert get_tool("t").cwd == "/tmp"
def test_allow_upstream_skip(self) -> None:
"""allow_upstream_skip 透传."""
@tool("t", allow_upstream_skip=True)
def f() -> None:
pass
assert get_tool("t").allow_upstream_skip is True
def test_hidden(self) -> None:
"""hidden 透传."""
@tool("t", subcommand="h", hidden=True)
def h() -> None:
pass
@tool("t", subcommand="v")
def v() -> None:
pass
assert get_tool("t", "h").hidden is True
assert list_subcommands("t") == ["v"]
assert list_subcommands("t", include_hidden=True) == ["h", "v"]
class TestRegistryErrors:
"""测试注册表错误分支."""
def test_duplicate_subcommand_raises(self) -> None:
"""同名同 subcommand 二次注册抛 ValueError."""
@tool("t", subcommand="a")
def a1() -> None:
pass
with pytest.raises(ValueError, match="已注册"):
@tool("t", subcommand="a")
def a2() -> None:
pass
def test_get_tool_unknown_tool(self) -> None:
"""获取未注册工具抛 KeyError."""
with pytest.raises(KeyError, match="未注册"):
get_tool("unknown")
def test_get_tool_unknown_subcommand(self) -> None:
"""获取未注册子命令抛 KeyError."""
@tool("t", subcommand="a")
def a() -> None:
pass
with pytest.raises(KeyError, match="没有子命令"):
get_tool("t", "b")
def test_list_subcommands_unknown_tool(self) -> None:
"""未注册工具返回空列表."""
assert list_subcommands("unknown") == []
def test_list_tools_sorted(self) -> None:
"""list_tools 返回排序后的工具名."""
@tool("ztool")
def z() -> None:
pass
@tool("atool")
def a() -> None:
pass
@tool("mtool")
def m() -> None:
pass
assert list_tools() == ["atool", "mtool", "ztool"]
# ---------------------------------------------------------------------- #
# _snake_to_kebab
# ---------------------------------------------------------------------- #
class TestSnakeToKebab:
"""测试 snake_case → kebab-case 转换."""
def test_simple(self) -> None:
assert _snake_to_kebab("output") == "output"
def test_snake(self) -> None:
assert _snake_to_kebab("output_dir") == "output-dir"
def test_multi_word(self) -> None:
assert _snake_to_kebab("python_mirror") == "python-mirror"
# ---------------------------------------------------------------------- #
# _unwrap_optional
# ---------------------------------------------------------------------- #
class TestUnwrapOptional:
"""测试 Optional 解包."""
def test_non_optional(self) -> None:
inner, is_opt = _unwrap_optional(int)
assert inner is int
assert is_opt is False
def test_optional(self) -> None:
inner, is_opt = _unwrap_optional(Optional[int])
assert inner is int
assert is_opt is True
def test_union_none(self) -> None:
inner, is_opt = _unwrap_optional(Union[int, None])
assert inner is int
assert is_opt is True
def test_str(self) -> None:
inner, is_opt = _unwrap_optional(str)
assert inner is str
assert is_opt is False
# ---------------------------------------------------------------------- #
# _map_param_type
# ---------------------------------------------------------------------- #
class TestMapParamType:
"""测试类型注解 → argparse kwargs 映射."""
def test_int(self) -> None:
assert _map_param_type(int) == {"type": int}
def test_float(self) -> None:
assert _map_param_type(float) == {"type": float}
def test_str(self) -> None:
assert _map_param_type(str) == {"type": str}
def test_path(self) -> None:
assert _map_param_type(Path) == {"type": Path}
def test_list_of_path(self) -> None:
kwargs = _map_param_type(list[Path])
assert kwargs["type"] is Path
assert kwargs["nargs"] == "+"
def test_list_no_type_arg(self) -> None:
"""list 无类型参数 → nargs='+', 无 type."""
kwargs = _map_param_type(list)
# list 的 origin 是 list,但 get_args(list) 为空
assert kwargs.get("nargs") == "+" or kwargs == {}
def test_literal(self) -> None:
kwargs = _map_param_type(Literal["a", "b"]) # type: ignore[valid-type]
assert kwargs["choices"] == ["a", "b"]
def test_unknown_type(self) -> None:
"""未知类型返回空 dict."""
assert _map_param_type(complex) == {}
def test_optional_int(self) -> None:
"""Optional[int] 解包后映射 int."""
kwargs = _map_param_type(Optional[int])
assert kwargs == {"type": int}
# ---------------------------------------------------------------------- #
# _add_func_args_to_parser
# ---------------------------------------------------------------------- #
class TestAddFuncArgsToParser:
"""测试函数签名 → argparse 参数."""
def _parse(self, func: Any, argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser()
_add_func_args_to_parser(parser, func)
return parser.parse_args(argv)
def test_positional(self) -> None:
def f(name: str) -> None:
pass
ns = self._parse(f, ["hello"])
assert ns.name == "hello"
def test_option_with_default(self) -> None:
def f(output: str = "out") -> None:
pass
ns = self._parse(f, ["--output", "x"])
assert ns.output == "x"
def test_option_default_value(self) -> None:
def f(output: str = "out") -> None:
pass
ns = self._parse(f, [])
assert ns.output == "out"
def test_int_type(self) -> None:
def f(level: int = 5) -> None:
pass
ns = self._parse(f, ["--level", "10"])
assert ns.level == 10
def test_float_type(self) -> None:
def f(rate: float = 1.0) -> None:
pass
ns = self._parse(f, ["--rate", "2.5"])
assert ns.rate == 2.5
def test_path_type(self) -> None:
def f(p: Path = Path()) -> None:
pass
ns = self._parse(f, ["--p", "/tmp"])
assert ns.p == Path("/tmp")
def test_list_positional_nargs_plus(self) -> None:
def f(inputs: list[Path]) -> None:
pass
ns = self._parse(f, ["a.pdf", "b.pdf"])
assert ns.inputs == [Path("a.pdf"), Path("b.pdf")]
def test_literal_choices(self) -> None:
def f(mode: Literal["a", "b"] = "a") -> None: # type: ignore[valid-type]
pass
ns = self._parse(f, ["--mode", "b"])
assert ns.mode == "b"
def test_bool_default_false_store_true(self) -> None:
def f(dry_run: bool = False) -> None:
pass
ns = self._parse(f, ["--dry-run"])
assert ns.dry_run is True
def test_bool_default_false_absent(self) -> None:
def f(dry_run: bool = False) -> None:
pass
ns = self._parse(f, [])
assert ns.dry_run is False
def test_bool_default_true_store_false(self) -> None:
def f(verbose: bool = True) -> None:
pass
ns = self._parse(f, ["--no-verbose"])
assert ns.verbose is False
def test_bool_default_true_absent(self) -> None:
def f(verbose: bool = True) -> None:
pass
ns = self._parse(f, [])
assert ns.verbose is True
def test_bool_no_default_store_true(self) -> None:
"""bool 无默认值 → store_true, 默认 False."""
def f(flag: bool) -> None:
pass
ns = self._parse(f, ["--flag"])
assert ns.flag is True
def test_skip_self(self) -> None:
"""self 参数被跳过."""
class C:
def m(self, x: int = 1) -> None:
pass
ns = self._parse(C().m, ["--x", "5"])
assert ns.x == 5
def test_skip_var_positional(self) -> None:
"""*args 被跳过."""
def f(*args: Any, x: int = 1) -> None:
pass
ns = self._parse(f, ["--x", "5"])
assert ns.x == 5
def test_skip_var_keyword(self) -> None:
"""**kwargs 被跳过."""
def f(x: int = 1, **kw: Any) -> None:
pass
ns = self._parse(f, ["--x", "5"])
assert ns.x == 5
def test_kebab_case_option(self) -> None:
"""snake_case 参数名 → kebab-case 选项."""
def f(output_dir: str = "out") -> None:
pass
ns = self._parse(f, ["--output-dir", "x"])
assert ns.output_dir == "x"
def test_no_annotation_defaults_to_str(self) -> None:
"""无类型注解 → str."""
def f(x="default") -> None: # type: ignore[no-untyped-def]
pass
ns = self._parse(f, ["--x", "val"])
assert ns.x == "val"
# ---------------------------------------------------------------------- #
# _add_global_options
# ---------------------------------------------------------------------- #
class TestAddGlobalOptions:
"""测试全局选项注入."""
def test_global_options_on_simple_parser(self) -> None:
parser = argparse.ArgumentParser()
_add_global_options(parser)
ns = parser.parse_args(["--dry-run", "--quiet", "--strategy", "thread", "--list"])
assert ns.dry_run is True
assert ns.verbose is False
assert ns.strategy == "thread"
assert ns.list_jobs is True
def test_global_options_on_subparsers(self) -> None:
"""subparser 也获得全局选项."""
def f(x: int = 1) -> None:
pass
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
sub = subparsers.add_parser("cmd")
_add_func_args_to_parser(sub, f)
_add_global_options(parser)
ns = parser.parse_args(["cmd", "--x", "5", "--dry-run", "--quiet"])
assert ns.x == 5
assert ns.dry_run is True
assert ns.verbose is False
# ---------------------------------------------------------------------- #
# _build_parser
# ---------------------------------------------------------------------- #
class TestBuildParser:
"""测试 parser 构建."""
def test_single_command_parser(self) -> None:
"""单命令工具(仅 subcommand=None)→ 简单 parser."""
@tool("solo")
def f(x: int = 1) -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
parser = _build_parser("solo", _TOOL_REGISTRY["solo"])
ns = parser.parse_args(["--x", "5"])
assert ns.x == 5
def test_multi_subcommand_parser(self) -> None:
"""多 subcommand → 带 subparsers."""
@tool("t", subcommand="a")
def a(x: int = 1) -> None:
pass
@tool("t", subcommand="b")
def b(y: str = "z") -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
parser = _build_parser("t", _TOOL_REGISTRY["t"])
ns = parser.parse_args(["a", "--x", "5"])
assert ns.command == "a"
assert ns.x == 5
def test_parser_description_from_description(self) -> None:
"""description 优先于 help."""
@tool("t", description="工具描述")
def f() -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
parser = _build_parser("t", _TOOL_REGISTRY["t"])
assert parser.description == "工具描述"
def test_parser_description_from_help(self) -> None:
"""无 description 时用 help."""
@tool("t", help="帮助文本")
def f() -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
parser = _build_parser("t", _TOOL_REGISTRY["t"])
assert parser.description == "帮助文本"
def test_parser_description_from_name(self) -> None:
"""无 description/help 时用工具名."""
@tool("t")
def f() -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
parser = _build_parser("t", _TOOL_REGISTRY["t"])
assert parser.description == "t"
# ---------------------------------------------------------------------- #
# _collect_with_deps
# ---------------------------------------------------------------------- #
class TestCollectWithDeps:
"""测试依赖收集 BFS."""
def test_no_deps(self) -> None:
"""无依赖 → 仅 target."""
@tool("t", subcommand="a")
def a() -> None:
pass
result = _collect_with_deps("t", "a")
assert result == ["a"]
def test_with_deps(self) -> None:
"""有依赖 → 依赖在前, target 在后."""
@tool("t", subcommand="a", needs=["b"])
def a() -> None:
pass
@tool("t", subcommand="b", needs=["c"])
def b() -> None:
pass
@tool("t", subcommand="c")
def c() -> None:
pass
result = _collect_with_deps("t", "a")
assert result == ["c", "b", "a"]
def test_unknown_tool(self) -> None:
"""未注册工具 → [target]."""
assert _collect_with_deps("unknown", "x") == ["x"]
def test_diamond_deps(self) -> None:
"""菱形依赖:每个依赖只出现一次."""
@tool("t", subcommand="a", needs=["b", "c"])
def a() -> None:
pass
@tool("t", subcommand="b", needs=["d"])
def b() -> None:
pass
@tool("t", subcommand="c", needs=["d"])
def c() -> None:
pass
@tool("t", subcommand="d")
def d() -> None:
pass
result = _collect_with_deps("t", "a")
# d 只出现一次, a 在最后
assert result[-1] == "a"
assert result.count("d") == 1
# ---------------------------------------------------------------------- #
# _has_function_logic + _is_aggregate
# ---------------------------------------------------------------------- #
class TestHasFunctionLogic:
"""测试函数体逻辑判定."""
def test_pass_only(self) -> None:
def f() -> None:
pass
assert _has_function_logic(f) is False
def test_ellipsis_only(self) -> None:
def f() -> None: ...
assert _has_function_logic(f) is False
def test_docstring_only(self) -> None:
def f() -> None:
"""文档."""
assert _has_function_logic(f) is False
def test_docstring_plus_pass(self) -> None:
def f() -> None:
"""文档."""
pass
assert _has_function_logic(f) is False
def test_has_logic(self) -> None:
def f() -> None:
print("hi")
assert _has_function_logic(f) is True
def test_has_logic_with_docstring(self) -> None:
def f() -> None:
"""文档."""
x = 1
print(x)
assert _has_function_logic(f) is True
class TestIsAggregate:
"""测试聚合任务判定."""
def test_aggregate_pass(self) -> None:
"""有 needs 无 cmd + pass → 聚合."""
@tool("t", subcommand="agg", needs=["dep"])
def agg() -> None:
pass
@tool("t", subcommand="dep")
def dep() -> None:
pass
assert _is_aggregate(get_tool("t", "agg")) is True
def test_not_aggregate_has_cmd(self) -> None:
"""有 cmd → 非聚合."""
@tool("t", subcommand="c", cmd=["echo"], needs=["dep"])
def c() -> None:
pass
@tool("t", subcommand="dep")
def dep() -> None:
pass
assert _is_aggregate(get_tool("t", "c")) is False
def test_not_aggregate_no_needs(self) -> None:
"""无 needs → 非聚合."""
@tool("t", subcommand="f")
def f() -> None:
pass
assert _is_aggregate(get_tool("t", "f")) is False
def test_not_aggregate_has_logic(self) -> None:
"""有 needs 无 cmd 但函数体有逻辑 → 非聚合."""
@tool("t", subcommand="f", needs=["dep"])
def f() -> None:
print("hi")
@tool("t", subcommand="dep")
def dep() -> None:
pass
assert _is_aggregate(get_tool("t", "f")) is False
# ---------------------------------------------------------------------- #
# _build_task_spec
# ---------------------------------------------------------------------- #
class TestBuildTaskSpec:
"""测试 ToolSpec → TaskSpec 转换."""
def test_cmd_task(self) -> None:
"""cmd 任务:用 cmd 构建 TaskSpec, cwd 从装饰器取."""
@tool("t", subcommand="c", cmd=["echo", "hi"], cwd="/tmp")
def c() -> None:
pass
spec = get_tool("t", "c")
task = _build_task_spec(spec, {})
assert task.name == "c"
assert task.cmd == ["echo", "hi"]
assert task.cwd == Path("/tmp")
def test_cmd_task_cwd_from_variables(self) -> None:
"""cmd 任务:cwd 从 variables['cwd'] 优先取."""
@tool("t", subcommand="c", cmd=["echo"], cwd="/tmp")
def c() -> None:
pass
spec = get_tool("t", "c")
task = _build_task_spec(spec, {"cwd": "/other"})
assert task.cwd == Path("/other")
def test_cmd_task_no_cwd(self) -> None:
"""cmd 任务:无 cwd → None."""
@tool("t", subcommand="c", cmd=["echo"])
def c() -> None:
pass
spec = get_tool("t", "c")
task = _build_task_spec(spec, {})
assert task.cwd is None
def test_aggregate_task(self) -> None:
"""聚合任务:fn=_noop, 有 needs."""
@tool("t", subcommand="agg", needs=["dep"], strategy="thread")
def agg() -> None:
pass
@tool("t", subcommand="dep")
def dep() -> None:
pass
spec = get_tool("t", "agg")
task = _build_task_spec(spec, {})
assert task.name == "agg"
assert task.fn is _noop
assert task.depends_on == ("dep",)
assert task.strategy == "thread"
def test_fn_task(self) -> None:
"""fn 任务:fn=func, kwargs 透传."""
@tool("t", subcommand="f")
def f(x: int = 1, y: str = "z") -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {"x": 5, "y": "a"})
assert task.name == "f"
assert task.fn is f
assert task.kwargs == {"x": 5, "y": "a"}
def test_fn_task_partial_variables(self) -> None:
"""fn 任务:仅取签名内的变量."""
@tool("t", subcommand="f")
def f(x: int = 1) -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {"x": 5, "extra": "ignored"})
assert task.kwargs == {"x": 5}
def test_fn_task_cwd_from_variables(self) -> None:
"""fn 任务:cwd 从 variables['cwd'] 取."""
@tool("t", subcommand="f")
def f(x: int = 1) -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {"x": 5, "cwd": "/tmp"})
assert task.cwd == Path("/tmp")
def test_fn_task_no_cwd(self) -> None:
"""fn 任务:无 cwd → None."""
@tool("t", subcommand="f")
def f(x: int = 1) -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {"x": 5})
assert task.cwd is None
def test_env_passthrough(self) -> None:
"""env 透传 TaskSpec."""
@tool("t", subcommand="f", env={"K": "v"})
def f() -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {})
assert task.env == {"K": "v"}
def test_retry_default(self) -> None:
"""无 retry → 默认 RetryPolicy()."""
@tool("t", subcommand="f")
def f() -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {})
assert isinstance(task.retry, RetryPolicy)
def test_retry_custom(self) -> None:
"""有 retry → 透传."""
retry = RetryPolicy(max_attempts=5)
@tool("t", subcommand="f", retry=retry)
def f() -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {})
assert task.retry is retry
def test_allow_upstream_skip_passthrough(self) -> None:
"""allow_upstream_skip 透传."""
@tool("t", subcommand="f", allow_upstream_skip=True)
def f() -> None:
pass
spec = get_tool("t", "f")
task = _build_task_spec(spec, {})
assert task.allow_upstream_skip is True
def test_single_command_name_uses_tool_name(self) -> None:
"""单命令工具(subcommand=None):task name 用工具名."""
@tool("solo")
def f(x: int = 1) -> None:
pass
spec = get_tool("solo")
task = _build_task_spec(spec, {})
assert task.name == "solo"
# ---------------------------------------------------------------------- #
# _extract_variables
# ---------------------------------------------------------------------- #
class TestExtractVariables:
"""测试 argparse namespace → 函数参数提取."""
def test_extract_matching_params(self) -> None:
def f(x: int = 1, y: str = "z") -> None:
pass
ns = argparse.Namespace(x=5, y="a", extra="ignored")
variables = _extract_variables(ns, f)
assert variables == {"x": 5, "y": "a"}
def test_extract_missing_attr(self) -> None:
"""namespace 缺少属性 → 不包含在结果中."""
def f(x: int = 1, y: str = "z") -> None:
pass
ns = argparse.Namespace(x=5)
variables = _extract_variables(ns, f)
assert variables == {"x": 5}
# ---------------------------------------------------------------------- #
# _handle_list
# ---------------------------------------------------------------------- #
class TestHandleList:
"""测试 --list 输出."""
def test_list_output(self, capsys: pytest.CaptureFixture[str]) -> None:
"""--list 打印任务列表."""
@tool("t", subcommand="a", needs=["b"])
def a() -> None:
pass
@tool("t", subcommand="b", cmd=["echo"])
def b() -> None:
pass
@tool("t", subcommand="h", hidden=True)
def h() -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
ret = _handle_list("t", _TOOL_REGISTRY["t"])
assert ret == 0
out = capsys.readouterr().out
assert "工具 t 的任务列表:" in out
assert "a" in out
assert "b" in out
assert "h" in out # hidden 也在 list 中显示
assert "[cmd]" in out # b 是 cmd 任务
assert "[隐藏]" in out # h 是 hidden
def test_list_no_deps(self, capsys: pytest.CaptureFixture[str]) -> None:
"""无依赖显示 (无依赖)."""
@tool("t", subcommand="a")
def a() -> None:
pass
from pyflowx.tools import _TOOL_REGISTRY
_handle_list("t", _TOOL_REGISTRY["t"])
out = capsys.readouterr().out
assert "(无依赖)" in out
# ---------------------------------------------------------------------- #
# run_tool
# ---------------------------------------------------------------------- #
class TestRunTool:
"""测试 run_tool 主入口."""
def test_unknown_tool(self, capsys: pytest.CaptureFixture[str]) -> None:
"""未注册工具 → 退出码 1 + stderr 提示."""
ret = run_tool("unknown", [])
assert ret == ToolExitCode.FAILURE
err = capsys.readouterr().err
assert "未注册工具" in err
def test_single_command_success(self, mock_run: list[Any], capsys: pytest.CaptureFixture[str]) -> None:
"""单命令工具执行成功 → 0."""
@tool("solo")
def f(x: int = 1) -> None:
pass
ret = run_tool("solo", ["--x", "5"])
assert ret == ToolExitCode.SUCCESS
assert len(mock_run) == 1
_graph, kwargs = mock_run[0]
assert kwargs["dry_run"] is False
assert kwargs["verbose"] is True
def test_multi_subcommand_success(self, mock_run: list[Any]) -> None:
"""多 subcommand 工具执行成功 → 0."""
@tool("t", subcommand="a")
def a(x: int = 1) -> None:
pass
@tool("t", subcommand="b")
def b(y: str = "z") -> None:
pass
ret = run_tool("t", ["a", "--x", "5"])
assert ret == ToolExitCode.SUCCESS
def test_dry_run(self, mock_run: list[Any]) -> None:
"""--dry-run 透传."""
@tool("t")
def f() -> None:
pass
run_tool("t", ["--dry-run"])
_, kwargs = mock_run[0]
assert kwargs["dry_run"] is True
def test_quiet(self, mock_run: list[Any]) -> None:
"""--quiet → verbose=False."""
@tool("t")
def f() -> None:
pass
run_tool("t", ["--quiet"])
_, kwargs = mock_run[0]
assert kwargs["verbose"] is False
def test_strategy_override(self, mock_run: list[Any]) -> None:
"""--strategy 透传到 run."""
# 注意: run_tool 用 target_spec.strategy or "dependency"
# 单命令无 strategy → "dependency"
@tool("t", strategy="thread")
def f() -> None:
pass
run_tool("t", [])
_, kwargs = mock_run[0]
# strategy 来自 target_spec.strategy
# run_tool 调用 run(graph, strategy=target_spec.strategy or "dependency", ...)
# mock_run 捕获 kwargs
assert kwargs["strategy"] == "thread" or "strategy" in kwargs
def test_list_flag(self, capsys: pytest.CaptureFixture[str]) -> None:
"""--list → 打印任务列表 + 退出 0."""
@tool("t", subcommand="a")
def a() -> None:
pass
ret = run_tool("t", ["--list"])
assert ret == ToolExitCode.SUCCESS
out = capsys.readouterr().out
assert "工具 t 的任务列表:" in out
def test_multi_no_subcommand_prints_help(self, capsys: pytest.CaptureFixture[str]) -> None:
"""多 subcommand 工具无 subcommand → 打印 help + 0."""
@tool("t", subcommand="a")
def a() -> None:
pass
@tool("t", subcommand="b")
def b() -> None:
pass
ret = run_tool("t", [])
assert ret == ToolExitCode.SUCCESS
out = capsys.readouterr().out
# 应打印 help( argparse 的 print_help 输出包含 usage)
assert "usage" in out.lower() or "pf t" in out
def test_task_failed_error(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""TaskFailedError(继承 PyFlowXError) → 退出码 1 + stderr."""
from pyflowx.errors import TaskFailedError
def _fail(graph: Any, **kw: Any) -> Any:
raise TaskFailedError("t", RuntimeError("boom"), 1)
monkeypatch.setattr(tools, "run", _fail)
@tool("t")
def f() -> None:
pass
ret = run_tool("t", [])
assert ret == ToolExitCode.FAILURE
err = capsys.readouterr().err
assert "boom" in err or "failed" in err
def test_pyflowx_error(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""PyFlowXError → 退出码 1 + stderr."""
from pyflowx.errors import PyFlowXError
def _fail(graph: Any, **kw: Any) -> Any:
raise PyFlowXError("graph error")
monkeypatch.setattr(tools, "run", _fail)
@tool("t")
def f() -> None:
pass
ret = run_tool("t", [])
assert ret == ToolExitCode.FAILURE
err = capsys.readouterr().err
assert "graph error" in err
def test_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""KeyboardInterrupt → 退出码 130."""
def _fail(graph: Any, **kw: Any) -> Any:
raise KeyboardInterrupt
monkeypatch.setattr(tools, "run", _fail)
@tool("t")
def f() -> None:
pass
ret = run_tool("t", [])
assert ret == ToolExitCode.INTERRUPTED
err = capsys.readouterr().err
assert "已中断" in err
def test_run_with_deps(self, mock_run: list[Any]) -> None:
"""有依赖时构建子图(多个 TaskSpec)."""
@tool("t", subcommand="a", needs=["b"])
def a() -> None:
pass
@tool("t", subcommand="b")
def b() -> None:
pass
ret = run_tool("t", ["a"])
assert ret == ToolExitCode.SUCCESS
graph, _ = mock_run[0]
# 图中应包含 a 和 b 两个任务
assert len(graph) == 2
assert set(graph.specs.keys()) == {"a", "b"}
def test_cmd_task_no_function_execution(self, mock_run: list[Any]) -> None:
"""cmd 任务:函数体不执行,TaskSpec 用 cmd."""
@tool("t", subcommand="c", cmd=["echo", "hi"])
def c() -> None:
raise RuntimeError("不应被执行")
ret = run_tool("t", ["c"])
assert ret == ToolExitCode.SUCCESS
# ---------------------------------------------------------------------- #
# 集成:工具描述 + 完整流程
# ---------------------------------------------------------------------- #
class TestIntegration:
"""集成测试:完整工具流程."""
def test_full_pymake_like_flow(self, mock_run: list[Any]) -> None:
"""模拟 pymake 流程:b + bc 聚合 ba + hidden 内部 job."""
@tool("pymake", subcommand="b", cmd=["uv", "build"], cwd=".")
def b(cwd: Path = Path()) -> None:
pass
@tool("pymake", subcommand="bc", cmd=["maturin", "build"], cwd=".")
def bc(cwd: Path = Path()) -> None:
pass
@tool("pymake", subcommand="ba", needs=["b", "bc"], strategy="thread")
def ba(cwd: Path = Path()) -> None:
pass
ret = run_tool("pymake", ["ba", "--cwd", "/tmp"])
assert ret == ToolExitCode.SUCCESS
# 验证图中包含 3 个任务
_graph, _ = mock_run[0]
# cwd 应从 CLI 透传(但 _build_task_spec 对 cmd 任务从 variables['cwd'] 取)
# variables 包含 cwd=/tmp
# b 和 bc 的 cwd 应为 /tmp
# ba 是聚合,无 cwd
def test_full_pdftool_like_flow(self, mock_run: list[Any]) -> None:
"""模拟 pdftool:m 合并 PDF."""
@tool("pdftool", subcommand="m", help="合并 PDF")
def merge(
inputs: list[Path],
output: Path = Path("merged.pdf"),
password: str = "",
) -> None:
"""合并 PDF 文件."""
pass
ret = run_tool("pdftool", ["m", "a.pdf", "b.pdf", "--output", "out.pdf"])
assert ret == ToolExitCode.SUCCESS
# 验证 kwargs 透传
_graph, _ = mock_run[0]
# fn 任务的 kwargs 应包含 inputs/output/password
def test_list_tools_after_multiple_registrations(self) -> None:
"""多次注册后 list_tools 排序正确."""
@tool("ztool")
def z() -> None:
pass
@tool("atool")
def a() -> None:
pass
@tool("mtool", subcommand="x")
def mx() -> None:
pass
@tool("mtool", subcommand="y")
def my() -> None:
pass
assert list_tools() == ["atool", "mtool", "ztool"]
assert list_subcommands("mtool") == ["x", "y"]
def test_clear_registry(self) -> None:
"""clear_tool_registry 清空注册表."""
@tool("t")
def f() -> None:
pass
assert "t" in list_tools()
clear_tool_registry()
assert list_tools() == []