"""测试 TaskSpec 的命令和条件执行功能.""" import sys from pathlib import Path from typing import Any import pytest import pyflowx as px from pyflowx.conditions import ( BuiltinConditions, Constants, ) # 跨平台的 echo 命令 if sys.platform == "win32": ECHO_CMD = ["cmd", "/c", "echo"] else: ECHO_CMD = ["echo"] def test_taskspec_with_cmd_list(): """测试使用命令列表的 TaskSpec.""" graph = px.Graph.from_specs([ px.TaskSpec("echo_test", cmd=[*ECHO_CMD, "hello"]), ]) report = px.run(graph, strategy="sequential") assert report.success assert "echo_test" in report.results assert report.results["echo_test"].status == px.TaskStatus.SUCCESS def test_taskspec_with_cmd_string(): """测试使用 shell 命令字符串的 TaskSpec.""" if sys.platform == "win32": shell_cmd = 'cmd /c "echo hello from shell"' else: shell_cmd = "echo 'hello from shell'" graph = px.Graph.from_specs([ px.TaskSpec("shell_test", cmd=shell_cmd), ]) report = px.run(graph, strategy="sequential") assert report.success assert "shell_test" in report.results assert report.results["shell_test"].status == px.TaskStatus.SUCCESS def test_taskspec_with_conditions_skip(): """测试条件不满足时任务被跳过.""" # 创建一个永远不会满足的条件 def never_true(_ctx): return False graph = px.Graph.from_specs([ px.TaskSpec( "should_skip", cmd=[*ECHO_CMD, "this should not run"], conditions=(never_true,), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert "should_skip" in report.results assert report.results["should_skip"].status == px.TaskStatus.SKIPPED def test_taskspec_with_conditions_execute(): """测试条件满足时任务正常执行.""" # 创建一个总是满足的条件 def always_true(_ctx): return True graph = px.Graph.from_specs([ px.TaskSpec( "should_run", cmd=[*ECHO_CMD, "this should run"], conditions=(always_true,), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert "should_run" in report.results assert report.results["should_run"].status == px.TaskStatus.SUCCESS def test_platform_conditions(): """测试平台条件.""" if sys.platform == "win32": win_cmd = ["cmd", "/c", "echo", "Windows"] posix_cmd = ["echo", "POSIX"] else: win_cmd = ["echo", "Windows"] posix_cmd = ["echo", "POSIX"] graph = px.Graph.from_specs([ px.TaskSpec( "win_task", cmd=win_cmd, conditions=(lambda _ctx: Constants.IS_WINDOWS,), ), px.TaskSpec( "linux_task", cmd=posix_cmd, conditions=(lambda _ctx: Constants.IS_LINUX,), ), px.TaskSpec( "macos_task", cmd=posix_cmd, conditions=(lambda _ctx: Constants.IS_MACOS,), ), ]) report = px.run(graph, strategy="sequential") assert report.success # 检查只有当前平台的任务执行了 if sys.platform == "win32": assert report.results["win_task"].status == px.TaskStatus.SUCCESS assert report.results["linux_task"].status == px.TaskStatus.SKIPPED assert report.results["macos_task"].status == px.TaskStatus.SKIPPED elif sys.platform == "linux": assert report.results["win_task"].status == px.TaskStatus.SKIPPED assert report.results["linux_task"].status == px.TaskStatus.SUCCESS assert report.results["macos_task"].status == px.TaskStatus.SKIPPED elif sys.platform == "darwin": assert report.results["win_task"].status == px.TaskStatus.SKIPPED assert report.results["linux_task"].status == px.TaskStatus.SKIPPED assert report.results["macos_task"].status == px.TaskStatus.SUCCESS def test_app_installed_conditions(): """测试应用安装条件.""" # 使用 sys.executable 保证可移植 python_cmd = [sys.executable, "--version"] py_name = "python" if sys.platform == "win32" else "python3" graph = px.Graph.from_specs([ px.TaskSpec( "python_check", cmd=python_cmd, conditions=(BuiltinConditions.HAS_INSTALLED(py_name),), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert "python_check" in report.results # python 应该总是安装的 assert report.results["python_check"].status == px.TaskStatus.SUCCESS def test_combined_conditions(): """测试组合条件.""" # AND 条件 and_condition = BuiltinConditions.AND( lambda _ctx: True, lambda _ctx: True, ) # OR 条件 or_condition = BuiltinConditions.OR( lambda _ctx: True, lambda _ctx: False, ) # NOT 条件 not_condition = BuiltinConditions.NOT(lambda _ctx: False) graph = px.Graph.from_specs([ px.TaskSpec( "and_test", cmd=[*ECHO_CMD, "AND"], conditions=(and_condition,), ), px.TaskSpec( "or_test", cmd=[*ECHO_CMD, "OR"], conditions=(or_condition,), ), px.TaskSpec( "not_test", cmd=[*ECHO_CMD, "NOT"], conditions=(not_condition,), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert report.results["and_test"].status == px.TaskStatus.SUCCESS assert report.results["or_test"].status == px.TaskStatus.SUCCESS assert report.results["not_test"].status == px.TaskStatus.SUCCESS def test_taskspec_with_cwd(): """测试工作目录设置.""" if sys.platform == "win32": ls_cmd = ["cmd", "/c", "dir"] else: ls_cmd = ["ls", "-la"] graph = px.Graph.from_specs([ px.TaskSpec( "list_current", cmd=ls_cmd, cwd=Path.cwd(), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert "list_current" in report.results assert report.results["list_current"].status == px.TaskStatus.SUCCESS @pytest.mark.slow def test_taskspec_with_timeout(): """测试超时设置.""" graph = px.Graph.from_specs([ # 短时间任务应该成功 px.TaskSpec( "short_task", cmd=[sys.executable, "-c", "import time; time.sleep(0.1)"], timeout=1.0, ), ]) report = px.run(graph, strategy="sequential") assert report.success assert "short_task" in report.results assert report.results["short_task"].status == px.TaskStatus.SUCCESS def test_taskspec_dependency_with_conditions(): """测试依赖和条件的组合.""" graph = px.Graph.from_specs([ px.TaskSpec( "first", cmd=[*ECHO_CMD, "first"], conditions=(lambda _ctx: True,), ), px.TaskSpec( "second", cmd=[*ECHO_CMD, "second"], depends_on=("first",), conditions=(lambda _ctx: True,), ), px.TaskSpec( "third", cmd=[*ECHO_CMD, "third"], depends_on=("second",), ), ]) report = px.run(graph, strategy="sequential") assert report.success assert report.results["first"].status == px.TaskStatus.SUCCESS assert report.results["second"].status == px.TaskStatus.SUCCESS assert report.results["third"].status == px.TaskStatus.SUCCESS def test_taskspec_mixed_fn_and_cmd(): """测试混合使用 fn 和 cmd.""" def my_function(): return "result from function" graph = px.Graph.from_specs([ px.TaskSpec("fn_task", fn=my_function), px.TaskSpec("cmd_task", cmd=[*ECHO_CMD, "from command"]), ]) report = px.run(graph, strategy="sequential") assert report.success assert report.results["fn_task"].status == px.TaskStatus.SUCCESS assert report.results["fn_task"].value == "result from function" assert report.results["cmd_task"].status == px.TaskStatus.SUCCESS def test_taskspec_cmd_overrides_fn(): """测试 cmd 参数优先于 fn 参数.""" def my_function(): return "should not run" graph = px.Graph.from_specs([ px.TaskSpec( "cmd_priority", fn=my_function, cmd=[*ECHO_CMD, "cmd takes priority"], ), ]) report = px.run(graph, strategy="sequential") assert report.success assert report.results["cmd_priority"].status == px.TaskStatus.SUCCESS # cmd 应该被执行,而不是 fn assert report.results["cmd_priority"].value is None def test_taskspec_callable_cmd(): """测试 cmd 参数使用可调用对象.""" def my_callable(): return "callable result" graph = px.Graph.from_specs([ px.TaskSpec("callable_cmd", cmd=my_callable), ]) report = px.run(graph, strategy="sequential") assert report.success assert report.results["callable_cmd"].status == px.TaskStatus.SUCCESS assert report.results["callable_cmd"].value == "callable result" # ---------------------------------------------------------------------- # # verbose 模式测试 # ---------------------------------------------------------------------- # class TestTaskSpecVerbose: """测试 TaskSpec 的 verbose 字段.""" def test_verbose_default_is_false(self) -> None: """verbose 默认应为 False.""" spec: px.TaskSpec[Any] = px.TaskSpec[Any]("a", cmd=[*ECHO_CMD, "hi"]) assert spec.verbose is False def test_verbose_true_prints_command(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose=True 时应打印执行的命令.""" graph = px.Graph.from_specs([px.TaskSpec("echo", cmd=[*ECHO_CMD, "verbose-output"], verbose=True)]) _ = px.run(graph, strategy="sequential") captured = capsys.readouterr() assert "执行命令" in captured.out assert "返回码" in captured.out def test_verbose_false_silent(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose=False 时不应打印命令信息.""" graph = px.Graph.from_specs([px.TaskSpec[Any]("echo", cmd=[*ECHO_CMD, "silent"], verbose=False)]) _ = px.run(graph, strategy="sequential") captured = capsys.readouterr() assert "执行命令" not in captured.out assert "返回码" not in captured.out def test_verbose_true_shell_cmd(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose=True 时 shell 命令也应打印执行信息.""" if sys.platform == "win32": shell_cmd = 'cmd /c "echo shell-verbose"' else: shell_cmd = "echo 'shell-verbose'" graph = px.Graph.from_specs([px.TaskSpec("shell", cmd=shell_cmd, verbose=True)]) _ = px.run(graph, strategy="sequential") captured = capsys.readouterr() assert "执行 Shell" in captured.out def test_verbose_prints_cwd(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose=True 且设置了 cwd 时应打印工作目录.""" import tempfile with tempfile.TemporaryDirectory() as tmpdir: graph = px.Graph.from_specs([px.TaskSpec[Any]("ls", cmd=ECHO_CMD, cwd=Path(tmpdir), verbose=True)]) _ = px.run(graph, strategy="sequential") captured = capsys.readouterr() assert "工作目录" in captured.out def test_verbose_failure_includes_returncode(self, capsys: pytest.CaptureFixture[str]) -> None: """verbose=True 时失败也应打印返回码.""" from pyflowx.errors import TaskFailedError graph = px.Graph.from_specs([ px.TaskSpec( "fail", cmd=[sys.executable, "-c", "import sys; sys.exit(1)"], verbose=True, ) ]) with pytest.raises(TaskFailedError): _ = px.run(graph, strategy="sequential") captured = capsys.readouterr() assert "返回码" in captured.out # ---------------------------------------------------------------------- # # _wrap_cmd 错误路径测试 # ---------------------------------------------------------------------- # class TestTaskSpecCmdErrors: """测试 _wrap_cmd 的错误处理路径.""" def test_cmd_list_file_not_found(self) -> None: """命令不存在时应抛出 RuntimeError.""" from pyflowx.errors import TaskFailedError graph = px.Graph.from_specs( [px.TaskSpec("missing", cmd=["this-command-does-not-exist-xyz"], skip_if_missing=False)], ) with pytest.raises(TaskFailedError) as exc_info: _ = px.run(graph, strategy="sequential") # 错误信息应包含命令未找到 assert "命令未找到" in str(exc_info.value.cause) or "not found" in str(exc_info.value.cause).lower() def test_cmd_list_failure_includes_stderr(self) -> None: """命令失败时错误信息应包含 stderr.""" from pyflowx.errors import TaskFailedError graph = px.Graph.from_specs([ px.TaskSpec( "fail", cmd=[ sys.executable, "-c", "import sys; sys.stderr.write('error-msg'); sys.exit(1)", ], ) ]) with pytest.raises(TaskFailedError) as exc_info: _ = px.run(graph, strategy="sequential") # 非 verbose 模式下, stderr 应包含在错误信息中 assert "error-msg" in str(exc_info.value.cause) def test_cmd_string_file_not_found(self) -> None: """shell 命令不存在时应抛出 RuntimeError.""" from pyflowx.errors import TaskFailedError graph = px.Graph.from_specs([px.TaskSpec("missing", cmd="this-command-does-not-exist-xyz-123")]) with pytest.raises(TaskFailedError): _ = px.run(graph, strategy="sequential") def test_cmd_string_failure(self) -> None: """shell 命令失败时应抛出 RuntimeError.""" from pyflowx.errors import TaskFailedError graph = px.Graph.from_specs([ px.TaskSpec("fail", cmd=f'{sys.executable} -c "import sys; sys.exit(1)"'), ]) with pytest.raises(TaskFailedError) as exc_info: _ = 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 graph = px.Graph.from_specs([ px.TaskSpec( "slow", cmd=[sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.1, ) ]) with pytest.raises(TaskFailedError) as exc_info: _ = 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 graph = px.Graph.from_specs([ px.TaskSpec( "slow", cmd=f'{sys.executable} -c "import time; time.sleep(5)"', timeout=0.1, ), ]) with pytest.raises(TaskFailedError) as exc_info: _ = px.run(graph, strategy="sequential") assert "超时" in str(exc_info.value.cause) def test_no_fn_no_cmd_raises(self) -> None: """没有 fn 和 cmd 时应抛出 ValueError.""" with pytest.raises(ValueError, match="必须提供 fn 或 cmd"): _ = px.TaskSpec("empty")