Files
pyflowx/tests/test_yaml_loader.py
T
zhou db02443463 feat: 新增 YAML 任务编排功能
1. 新增 yaml_loader 模块,支持加载 GitHub Actions 风格的 YAML 任务图
2. 新增 Graph.from_yaml 静态方法,支持从 YAML 文件构建任务图
3. 新增 yamlrun CLI 工具,支持执行、预览 YAML 任务流水线
4. 添加 pyyaml 运行时依赖与 types-PyYAML 开发依赖
5. 更新 README 文档与对外暴露的 API 接口
2026-07-04 16:00:04 +08:00

1267 lines
30 KiB
Python

"""Tests for yaml_loader module."""
from __future__ import annotations
from pathlib import Path
import pytest
import pyflowx as px
from pyflowx.task import TaskStatus
from pyflowx.yaml_loader import YamlLoadError, load_yaml, parse_yaml_string
# ---------------------------------------------------------------------- #
# load_yaml (文件加载)
# ---------------------------------------------------------------------- #
class TestLoadYaml:
"""测试 load_yaml 函数."""
def test_load_yaml_file_not_exists(self, tmp_path: Path) -> None:
"""文件不存在应抛出 YamlLoadError."""
with pytest.raises(YamlLoadError, match="文件不存在"):
load_yaml(tmp_path / "nonexistent.yaml")
def test_load_yaml_success(self, tmp_path: Path) -> None:
"""成功加载 YAML 文件."""
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
"""
jobs:
setup:
cmd: ["echo", "hello"]
""",
encoding="utf-8",
)
graph = load_yaml(yaml_file)
assert "setup" in graph.names
assert graph.spec("setup").cmd == ["echo", "hello"]
def test_load_yaml_path_string(self, tmp_path: Path) -> None:
"""支持 str 路径."""
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
'jobs:\n t:\n cmd: ["echo"]\n',
encoding="utf-8",
)
graph = load_yaml(str(yaml_file))
assert graph.names == ["t"]
def test_graph_from_yaml_classmethod(self, tmp_path: Path) -> None:
"""Graph.from_yaml 类方法可用."""
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
'jobs:\n t:\n cmd: ["echo"]\n',
encoding="utf-8",
)
graph = px.Graph.from_yaml(yaml_file)
assert graph.names == ["t"]
# ---------------------------------------------------------------------- #
# parse_yaml_string (字符串解析)
# ---------------------------------------------------------------------- #
class TestParseYamlString:
"""测试 parse_yaml_string 函数."""
def test_basic_jobs(self) -> None:
"""基础 job 定义."""
graph = parse_yaml_string(
"""
jobs:
task1:
cmd: ["echo", "hello"]
""",
)
assert graph.names == ["task1"]
assert graph.spec("task1").cmd == ["echo", "hello"]
def test_root_not_mapping(self) -> None:
"""根节点不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="根节点必须是 mapping"):
parse_yaml_string("- item1\n- item2\n")
def test_root_is_scalar(self) -> None:
"""根节点是标量应报错."""
with pytest.raises(YamlLoadError, match="根节点必须是 mapping"):
parse_yaml_string("just a string\n")
def test_empty_content(self) -> None:
"""空内容应报错."""
with pytest.raises(YamlLoadError, match="根节点必须是 mapping"):
parse_yaml_string("")
def test_yaml_syntax_error(self) -> None:
"""YAML 语法错误应报错."""
with pytest.raises(YamlLoadError, match="YAML 解析失败"):
parse_yaml_string(":\n : invalid\n")
def test_jobs_not_mapping(self) -> None:
"""jobs 不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="jobs 必须是 mapping"):
parse_yaml_string("jobs: [1, 2, 3]\n")
def test_jobs_missing(self) -> None:
"""缺少 jobs 字段应报错."""
with pytest.raises(YamlLoadError, match="jobs 必须是 mapping"):
parse_yaml_string("foo: bar\n")
def test_jobs_empty(self) -> None:
"""jobs 为空应报错."""
with pytest.raises(YamlLoadError, match="jobs 不能为空"):
parse_yaml_string("jobs: {}\n")
def test_job_not_mapping(self) -> None:
"""job 配置不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="必须是 mapping"):
parse_yaml_string(
"""
jobs:
bad: "just a string"
""",
)
def test_job_id_not_string(self) -> None:
"""job id 非字符串应报错 (YAML 解析为 int)."""
with pytest.raises(YamlLoadError, match="job id 必须是非空字符串"):
parse_yaml_string(
"""
jobs:
123:
cmd: ["echo"]
""",
)
# ---------------------------------------------------------------------- #
# cmd / run 字段
# ---------------------------------------------------------------------- #
class TestCmdRunFields:
"""测试 cmd 和 run 字段."""
def test_cmd_list_form(self) -> None:
"""cmd 列表形式."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["python", "-m", "pytest"]
""",
)
assert graph.spec("t").cmd == ["python", "-m", "pytest"]
def test_cmd_string_form(self) -> None:
"""cmd 字符串形式 (含矩阵占位符)."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: "echo hello"
""",
)
assert graph.spec("t").cmd == "echo hello"
def test_run_form(self) -> None:
"""run 形式 (shell 字符串)."""
graph = parse_yaml_string(
"""
jobs:
shell_task:
run: "echo hello && exit 0"
""",
)
assert graph.spec("shell_task").cmd == "echo hello && exit 0"
def test_cmd_and_run_conflict(self) -> None:
"""同时提供 cmd 和 run 应报错."""
with pytest.raises(YamlLoadError, match="不能同时提供 cmd 和 run"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
run: "echo"
""",
)
def test_missing_cmd_and_run(self) -> None:
"""缺少 cmd 和 run 应报错."""
with pytest.raises(YamlLoadError, match="必须提供 cmd 或 run"):
parse_yaml_string(
"""
jobs:
t:
verbose: true
""",
)
def test_cmd_invalid_type(self) -> None:
"""cmd 类型错误应报错."""
with pytest.raises(YamlLoadError, match="cmd 必须是 list 或 str"):
parse_yaml_string(
"""
jobs:
t:
cmd: 123
""",
)
# ---------------------------------------------------------------------- #
# needs 依赖
# ---------------------------------------------------------------------- #
class TestNeedsField:
"""测试 needs 依赖字段."""
def test_needs_list(self) -> None:
"""needs 列表形式."""
graph = parse_yaml_string(
"""
jobs:
setup:
cmd: ["echo", "setup"]
build:
needs: [setup]
cmd: ["echo", "build"]
""",
)
assert graph.spec("build").depends_on == ("setup",)
assert graph.layers() == [["setup"], ["build"]]
def test_needs_string(self) -> None:
"""needs 字符串形式."""
graph = parse_yaml_string(
"""
jobs:
a:
cmd: ["echo"]
b:
needs: a
cmd: ["echo"]
""",
)
assert graph.spec("b").depends_on == ("a",)
def test_needs_multiple(self) -> None:
"""多个依赖."""
graph = parse_yaml_string(
"""
jobs:
a:
cmd: ["echo"]
b:
cmd: ["echo"]
c:
needs: [a, b]
cmd: ["echo"]
""",
)
assert graph.spec("c").depends_on == ("a", "b")
def test_parallel_execution(self) -> None:
"""并行任务 (同层无依赖)."""
graph = parse_yaml_string(
"""
jobs:
a:
cmd: ["echo"]
b:
cmd: ["echo"]
c:
needs: [a, b]
cmd: ["echo"]
""",
)
assert graph.layers() == [["a", "b"], ["c"]]
# ---------------------------------------------------------------------- #
# 矩阵 strategy.matrix
# ---------------------------------------------------------------------- #
class TestMatrixExpansion:
"""测试矩阵扇出."""
def test_single_key_matrix(self) -> None:
"""单键矩阵."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["pytest"]
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
""",
)
assert sorted(graph.names) == ["test_python-3.10", "test_python-3.8", "test_python-3.9"]
for name in graph.names:
assert graph.spec(name).cmd == ["pytest"]
def test_multi_key_matrix(self) -> None:
"""多键矩阵 (笛卡尔积)."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["pytest"]
strategy:
matrix:
python: ["3.8", "3.9"]
os: ["linux", "macos"]
""",
)
assert len(graph.names) == 4
expected = {
"test_python-3.8_os-linux",
"test_python-3.9_os-linux",
"test_python-3.8_os-macos",
"test_python-3.9_os-macos",
}
assert set(graph.names) == expected
def test_matrix_placeholder_in_cmd(self) -> None:
"""矩阵占位符替换 cmd."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["python${{ matrix.version }}", "-m", "pytest"]
strategy:
matrix:
version: ["3.8", "3.9"]
""",
)
specs = {s.name: s for s in [graph.spec(n) for n in graph.names]}
assert specs["test_version-3.8"].cmd == ["python3.8", "-m", "pytest"]
assert specs["test_version-3.9"].cmd == ["python3.9", "-m", "pytest"]
def test_matrix_placeholder_in_run(self) -> None:
"""矩阵占位符替换 run."""
graph = parse_yaml_string(
"""
jobs:
test:
run: "python${{ matrix.version }} -m pytest"
strategy:
matrix:
version: ["3.8", "3.9"]
""",
)
specs = {s.name: s for s in [graph.spec(n) for n in graph.names]}
assert specs["test_version-3.8"].cmd == "python3.8 -m pytest"
def test_matrix_placeholder_in_cwd_env(self) -> None:
"""矩阵占位符替换 cwd 和 env."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["pytest"]
cwd: ./build/${{ matrix.os }}
env:
TARGET: ${{ matrix.os }}
strategy:
matrix:
os: ["linux", "macos"]
""",
)
for name in graph.names:
spec = graph.spec(name)
assert spec.cwd is not None
assert "build" in str(spec.cwd)
assert spec.cwd.name in ("linux", "macos")
def test_matrix_scalar_value(self) -> None:
"""矩阵值为标量."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["echo"]
strategy:
matrix:
mode: release
""",
)
assert graph.names == ["test_mode-release"]
def test_matrix_placeholder_undefined(self) -> None:
"""占位符引用未定义的矩阵键应报错."""
with pytest.raises(YamlLoadError, match="未定义"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo", "${{ matrix.undefined }}"]
strategy:
matrix:
mode: release
""",
)
def test_matrix_empty_list(self) -> None:
"""矩阵值为空列表应报错."""
with pytest.raises(YamlLoadError, match="不能为空列表"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
strategy:
matrix:
python: []
""",
)
def test_strategy_not_mapping(self) -> None:
"""strategy 不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="strategy 必须是 mapping"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
strategy: "thread"
""",
)
def test_matrix_not_mapping(self) -> None:
"""matrix 不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="matrix 必须是 mapping"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
strategy:
matrix: [1, 2, 3]
""",
)
def test_matrix_value_invalid_type(self) -> None:
"""矩阵值类型错误应报错."""
with pytest.raises(YamlLoadError, match="必须是列表或标量"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
strategy:
matrix:
k: {a: 1}
""",
)
def test_matrix_special_chars_in_value(self) -> None:
"""矩阵值含特殊字符应被清理."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["echo"]
strategy:
matrix:
version: ["3.8/extra"]
""",
)
assert "test_version-3.8_extra" in graph.names
def test_matrix_preserves_needs(self) -> None:
"""矩阵任务保留 needs 依赖."""
graph = parse_yaml_string(
"""
jobs:
setup:
cmd: ["echo"]
test:
needs: [setup]
cmd: ["pytest"]
strategy:
matrix:
python: ["3.8", "3.9"]
""",
)
for name in graph.names:
if name.startswith("test_"):
assert graph.spec(name).depends_on == ("setup",)
# ---------------------------------------------------------------------- #
# if 条件
# ---------------------------------------------------------------------- #
class TestConditionParsing:
"""测试 if 条件解析."""
def test_if_success(self) -> None:
"""if: success() 返回空 condition 元组."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "success()"
""",
)
assert graph.spec("t").conditions == ()
def test_if_always(self) -> None:
"""if: always() 返回空 condition 元组."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "always()"
""",
)
assert graph.spec("t").conditions == ()
def test_if_failure_unsupported(self) -> None:
"""if: failure() 应报错."""
with pytest.raises(YamlLoadError, match="不支持"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "failure()"
""",
)
def test_if_env_exists(self) -> None:
"""if: env.VAR 检查环境变量存在."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.DEPLOY_TOKEN"
""",
)
assert len(graph.spec("t").conditions) == 1
def test_if_env_equals(self) -> None:
"""if: env.VAR == 'value' 检查环境变量等于."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.ENV == 'prod'"
""",
)
assert len(graph.spec("t").conditions) == 1
def test_if_env_not_equals(self) -> None:
"""if: env.VAR != 'value' 检查环境变量不等于."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.ENV != 'dev'"
""",
)
assert len(graph.spec("t").conditions) == 1
def test_if_unsupported_expr(self) -> None:
"""不支持的 if 表达式应报错."""
with pytest.raises(YamlLoadError, match="不支持的 if 表达式"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "github.event == 'push'"
""",
)
def test_if_not_string(self) -> None:
"""if 非字符串应报错."""
with pytest.raises(YamlLoadError, match="if 条件必须是字符串"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: 123
""",
)
def test_if_empty_string(self) -> None:
"""if 空字符串应报错."""
with pytest.raises(YamlLoadError, match="if 条件不能为空"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: ""
""",
)
def test_if_env_exists_runtime(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if: env.VAR 运行时检查."""
monkeypatch.setenv("TEST_YAML_TOKEN", "abc")
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.TEST_YAML_TOKEN"
""",
)
spec = graph.spec("t")
assert spec.conditions[0]({}) is True
def test_if_env_equals_runtime(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if: env.VAR == 'value' 运行时检查."""
monkeypatch.setenv("TEST_YAML_ENV", "prod")
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.TEST_YAML_ENV == 'prod'"
""",
)
spec = graph.spec("t")
assert spec.conditions[0]({}) is True
def test_if_env_not_equals_runtime(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if: env.VAR != 'value' 运行时检查."""
monkeypatch.setenv("TEST_YAML_ENV2", "staging")
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
if: "env.TEST_YAML_ENV2 != 'prod'"
""",
)
spec = graph.spec("t")
assert spec.conditions[0]({}) is True
# ---------------------------------------------------------------------- #
# retry / timeout / cwd / env / verbose 等字段
# ---------------------------------------------------------------------- #
class TestTaskFields:
"""测试任务配置字段."""
def test_timeout(self) -> None:
"""timeout 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
timeout: 300
""",
)
assert graph.spec("t").timeout == 300.0
def test_retry_full(self) -> None:
"""retry 完整配置."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
retry:
max_attempts: 3
delay: 1.0
backoff: 2.0
jitter: 0.5
""",
)
retry = graph.spec("t").retry
assert retry.max_attempts == 3
assert retry.delay == 1.0
assert retry.backoff == 2.0
assert retry.jitter == 0.5
def test_retry_with_hyphen(self) -> None:
"""retry max-attempts 连字符形式."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
retry: {max-attempts: 5}
""",
)
assert graph.spec("t").retry.max_attempts == 5
def test_retry_invalid_type(self) -> None:
"""retry 类型错误应报错."""
with pytest.raises(YamlLoadError, match="retry 必须是 mapping"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
retry: "invalid"
""",
)
def test_cwd(self) -> None:
"""cwd 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
cwd: ./build
""",
)
assert graph.spec("t").cwd == Path("./build")
def test_env(self) -> None:
"""env 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
env:
DEBUG: "1"
VERBOSE: "true"
""",
)
env = graph.spec("t").env
assert env == {"DEBUG": "1", "VERBOSE": "true"}
def test_verbose(self) -> None:
"""verbose 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
verbose: true
""",
)
assert graph.spec("t").verbose is True
def test_continue_on_error(self) -> None:
"""continue-on-error 字段 (连字符)."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
continue-on-error: true
""",
)
assert graph.spec("t").continue_on_error is True
def test_continue_on_error_underscore(self) -> None:
"""continue_on_error 字段 (下划线)."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
continue_on_error: true
""",
)
assert graph.spec("t").continue_on_error is True
def test_skip_if_missing(self) -> None:
"""skip-if-missing 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
skip-if-missing: true
""",
)
assert graph.spec("t").skip_if_missing is True
def test_allow_upstream_skip(self) -> None:
"""allow-upstream-skip 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
allow-upstream-skip: true
""",
)
assert graph.spec("t").allow_upstream_skip is True
def test_priority(self) -> None:
"""priority 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
priority: 10
""",
)
assert graph.spec("t").priority == 10
def test_concurrency_key(self) -> None:
"""concurrency-key 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
concurrency-key: deploy_lock
""",
)
assert graph.spec("t").concurrency_key == "deploy_lock"
def test_tags(self) -> None:
"""tags 字段."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
tags: [ci, deploy]
""",
)
assert graph.spec("t").tags == ("ci", "deploy")
def test_runs_on_appended_to_tags(self) -> None:
"""runs-on 追加到 tags."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
tags: [ci]
runs-on: linux
""",
)
assert graph.spec("t").tags == ("ci", "linux")
def test_runs_on_only(self) -> None:
"""仅有 runs-on."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
runs-on: macos
""",
)
assert graph.spec("t").tags == ("macos",)
# ---------------------------------------------------------------------- #
# 顶层 strategy 和 defaults
# ---------------------------------------------------------------------- #
class TestTopLevelConfig:
"""测试顶层配置."""
def test_top_strategy(self) -> None:
"""顶层 strategy 设置图级默认策略."""
graph = parse_yaml_string(
"""
strategy: thread
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.strategy == "thread"
def test_top_strategy_overrides_defaults(self) -> None:
"""顶层 strategy 覆盖 defaults.strategy."""
graph = parse_yaml_string(
"""
strategy: async
defaults:
strategy: thread
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.strategy == "async"
def test_top_strategy_invalid_type(self) -> None:
"""顶层 strategy 非字符串应报错."""
with pytest.raises(YamlLoadError, match="顶层 strategy 必须是字符串"):
parse_yaml_string(
"""
strategy: [thread]
jobs:
t:
cmd: ["echo"]
""",
)
def test_defaults_retry(self) -> None:
"""defaults.retry 设置图级重试策略."""
graph = parse_yaml_string(
"""
defaults:
retry: {max_attempts: 5}
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.retry is not None
assert graph.defaults.retry.max_attempts == 5
def test_defaults_verbose(self) -> None:
"""defaults.verbose 设置图级 verbose."""
graph = parse_yaml_string(
"""
defaults:
verbose: true
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.verbose is True
def test_defaults_env(self) -> None:
"""defaults.env 设置图级环境变量."""
graph = parse_yaml_string(
"""
defaults:
env: {GLOBAL: "1"}
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.env == {"GLOBAL": "1"}
def test_defaults_timeout(self) -> None:
"""defaults.timeout 设置图级超时."""
graph = parse_yaml_string(
"""
defaults:
timeout: 100
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.timeout == 100.0
def test_defaults_priority(self) -> None:
"""defaults.priority 设置图级优先级."""
graph = parse_yaml_string(
"""
defaults:
priority: 5
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.priority == 5
def test_defaults_continue_on_error(self) -> None:
"""defaults.continue-on-error 设置图级默认."""
graph = parse_yaml_string(
"""
defaults:
continue-on-error: true
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.continue_on_error is True
def test_defaults_concurrency_key(self) -> None:
"""defaults.concurrency-key 设置图级并发键."""
graph = parse_yaml_string(
"""
defaults:
concurrency-key: global_lock
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.concurrency_key == "global_lock"
def test_defaults_tags(self) -> None:
"""defaults.tags 设置图级标签."""
graph = parse_yaml_string(
"""
defaults:
tags: [common]
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.tags == ("common",)
def test_defaults_cwd(self) -> None:
"""defaults.cwd 设置图级工作目录."""
graph = parse_yaml_string(
"""
defaults:
cwd: ./root
jobs:
t:
cmd: ["echo"]
""",
)
assert graph.defaults.cwd == Path("./root")
# ---------------------------------------------------------------------- #
# 错误处理: 重复任务名/循环依赖
# ---------------------------------------------------------------------- #
class TestErrorHandling:
"""测试错误处理."""
def test_duplicate_task_name(self) -> None:
"""重复任务名应报错."""
with pytest.raises(YamlLoadError, match="重复任务名"):
parse_yaml_string(
"""
jobs:
a:
cmd: ["echo"]
strategy:
matrix:
k: ["x"]
a_k-x:
cmd: ["echo"]
""",
)
def test_cycle_dependency(self) -> None:
"""循环依赖应报错 (由 Graph 抛出)."""
with pytest.raises(px.CycleError):
parse_yaml_string(
"""
jobs:
a:
needs: [b]
cmd: ["echo"]
b:
needs: [a]
cmd: ["echo"]
""",
)
def test_missing_dependency(self) -> None:
"""依赖不存在的任务应报错."""
with pytest.raises(px.MissingDependencyError):
parse_yaml_string(
"""
jobs:
a:
needs: [nonexistent]
cmd: ["echo"]
""",
)
# ---------------------------------------------------------------------- #
# 字段类型转换错误
# ---------------------------------------------------------------------- #
class TestFieldTypeErrors:
"""测试字段类型转换错误."""
def test_timeout_invalid_type(self) -> None:
"""timeout 非数字应报错."""
with pytest.raises(YamlLoadError, match="期望数字"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
timeout: "abc"
""",
)
def test_priority_invalid_type(self) -> None:
"""priority 非整数应报错."""
with pytest.raises(YamlLoadError, match="期望整数"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
priority: "high"
""",
)
def test_tags_invalid_type(self) -> None:
"""tags 非列表/字符串应报错."""
with pytest.raises(YamlLoadError, match="期望字符串或列表"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
tags: 123
""",
)
def test_env_invalid_type(self) -> None:
"""env 非 mapping 应报错."""
with pytest.raises(YamlLoadError, match="期望 mapping"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
env: [1, 2, 3]
""",
)
def test_runs_on_invalid_type(self) -> None:
"""runs-on 非字符串应报错."""
with pytest.raises(YamlLoadError, match="期望字符串"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
runs-on: [linux]
""",
)
def test_concurrency_key_invalid_type(self) -> None:
"""concurrency-key 非字符串应报错."""
with pytest.raises(YamlLoadError, match="期望字符串"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
concurrency-key: 123
""",
)
# ---------------------------------------------------------------------- #
# 完整流水线集成测试
# ---------------------------------------------------------------------- #
class TestIntegrationPipeline:
"""完整流水线集成测试."""
def test_full_ci_pipeline(self) -> None:
"""完整 CI 流水线."""
graph = parse_yaml_string(
"""
strategy: thread
defaults:
verbose: true
env:
CI: "true"
jobs:
setup:
cmd: ["echo", "setup"]
build:
needs: [setup]
cmd: ["python", "-m", "build"]
timeout: 300
retry: {max_attempts: 2}
test:
needs: [build]
cmd: ["pytest", "-x"]
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
lint:
needs: [build]
cmd: ["ruff", "check"]
if: "env.CI"
deploy:
needs: [test, lint]
cmd: ["twine", "upload"]
if: "env.DEPLOY_TOKEN != ''"
allow-upstream-skip: true
""",
)
# 验证任务数
assert len(graph.names) == 1 + 1 + 3 + 1 + 1 # setup/build/test*3/lint/deploy = 7
# 验证分层
layers = graph.layers()
assert layers[0] == ["setup"]
assert layers[1] == ["build"]
# 第 3 层含 4 个并行任务 (test*3 + lint)
assert len(layers[2]) == 4
# deploy 在最后
assert layers[-1] == ["deploy"]
def test_dry_run_layers(self) -> None:
"""dry-run 验证分层."""
graph = parse_yaml_string(
"""
jobs:
a:
cmd: ["echo"]
b:
cmd: ["echo"]
c:
needs: [a, b]
cmd: ["echo"]
""",
)
assert graph.layers() == [["a", "b"], ["c"]]
def test_execute_simple_pipeline(self) -> None:
"""执行简单流水线验证集成正确性."""
graph = parse_yaml_string(
"""
jobs:
hello:
cmd: ["echo", "hello world"]
""",
)
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("hello").status == TaskStatus.SUCCESS
def test_execute_with_failure(self) -> None:
"""执行含失败任务的流水线."""
graph = parse_yaml_string(
"""
jobs:
ok:
cmd: ["true"]
fail:
cmd: ["false"]
continue-on-error: true
""",
)
report = px.run(graph, strategy="sequential")
# fail 任务 continue-on-error, 不影响整图
assert report.success
def test_execute_skipped_due_to_missing_dep(self) -> None:
"""依赖跳过时下游也跳过."""
graph = parse_yaml_string(
"""
jobs:
skip_me:
cmd: ["nonexistent_cmd_xyz"]
skip-if-missing: true
downstream:
needs: [skip_me]
cmd: ["echo"]
allow-upstream-skip: true
""",
)
report = px.run(graph, strategy="sequential")
# skip_me 被跳过, downstream 因 allow-upstream-skip 仍执行
assert report.result_of("skip_me").status == TaskStatus.SKIPPED