Files
pyflowx/tests/test_yaml_loader.py
T
zhou 6931f36fd1
CI / Lint, Typecheck & Test (push) Successful in 2m27s
feat: 新增函数注册机制与 CLI 工具函数模块
- 新增 registry.py 提供 register_fn/get_fn/has_fn 函数注册机制, 支持 @register_fn 和 @register_fn("name") 两种用法
- 新增 cli/_ops 包 (files/dev/media/system 四个子模块), 聚合 59 个可复用函数供 YAML fn 字段引用
- 扩展 yaml_loader 支持 fn 字段、args/kwargs 传参、${VAR} 变量占位符
- 新增 test_registry.py (20 个测试) 和扩展 test_yaml_loader.py
- 更新自驱动规则: 自动 commit+push, 删除需要用户明确指示的步骤
2026-07-04 18:24:52 +08:00

1577 lines
38 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/fn"):
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/fn 之一"):
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
# ---------------------------------------------------------------------- #
# fn 字段 + 变量占位符
# ---------------------------------------------------------------------- #
class TestFnField:
"""测试 fn 字段引用注册函数."""
def setup_method(self) -> None:
"""每个测试前清空 registry."""
px.FnRegistry.clear()
def teardown_method(self) -> None:
"""每个测试后清空 registry."""
px.FnRegistry.clear()
def test_fn_basic(self) -> None:
"""fn 引用注册函数."""
@px.register_fn("greet")
def _greet(name: str) -> str:
return f"hello {name}"
graph = parse_yaml_string(
"""
jobs:
greet:
fn: greet
args: ["world"]
""",
)
report = px.run(graph, strategy="sequential")
assert report.success
assert report["greet"] == "hello world"
def test_fn_with_kwargs(self) -> None:
"""fn 使用 kwargs 传参."""
@px.register_fn("add")
def _add(a: int, b: int) -> int:
return a + b
graph = parse_yaml_string(
"""
jobs:
add:
fn: add
args: [1, 2]
""",
)
report = px.run(graph, strategy="sequential")
assert report["add"] == 3
def test_fn_not_registered(self) -> None:
"""fn 引用未注册的函数应报错."""
with pytest.raises(YamlLoadError, match="未注册"):
parse_yaml_string(
"""
jobs:
t:
fn: nonexistent_fn
""",
)
def test_fn_not_string(self) -> None:
"""fn 不是字符串应报错."""
with pytest.raises(YamlLoadError, match="fn 必须是字符串"):
parse_yaml_string(
"""
jobs:
t:
fn: 123
""",
)
def test_fn_args_not_list(self) -> None:
"""args 不是列表应报错."""
with pytest.raises(YamlLoadError, match="args 必须是列表"):
parse_yaml_string(
"""
jobs:
t:
fn: some_fn
args: "not a list"
""",
)
def test_fn_kwargs_not_mapping(self) -> None:
"""kwargs 不是 mapping 应报错."""
with pytest.raises(YamlLoadError, match="kwargs 必须是 mapping"):
parse_yaml_string(
"""
jobs:
t:
fn: some_fn
kwargs: [1, 2]
""",
)
def test_fn_with_cmd_conflict(self) -> None:
"""fn 与 cmd 同时提供应报错."""
with pytest.raises(YamlLoadError, match="不能同时提供"):
parse_yaml_string(
"""
jobs:
t:
fn: some_fn
cmd: ["echo"]
""",
)
def test_fn_with_needs(self) -> None:
"""fn 任务可以有 needs 依赖."""
@px.register_fn("upper")
def _upper(s: str) -> str:
return s.upper()
graph = parse_yaml_string(
"""
jobs:
setup:
cmd: ["echo", "hello"]
process:
fn: upper
args: ["data"]
needs: [setup]
""",
)
assert graph.spec("process").depends_on == ("setup",)
report = px.run(graph, strategy="sequential")
assert report["process"] == "DATA"
def test_fn_matrix_placeholder_in_args(self) -> None:
"""fn 的 args 支持矩阵占位符."""
@px.register_fn("echo_val")
def _echo_val(val: str) -> str:
return val
graph = parse_yaml_string(
"""
jobs:
test:
fn: echo_val
args: ["${{ matrix.mode }}"]
strategy:
matrix:
mode: [release, debug]
""",
)
assert sorted(graph.names) == ["test_mode-debug", "test_mode-release"]
report = px.run(graph, strategy="sequential")
assert report["test_mode-release"] == "release"
assert report["test_mode-debug"] == "debug"
# ---------------------------------------------------------------------- #
# 变量占位符 ${VAR}
# ---------------------------------------------------------------------- #
class TestVariablePlaceholder:
"""测试 ${VAR} 变量占位符."""
def test_variable_in_cmd(self) -> None:
"""变量在 cmd 中替换."""
graph = parse_yaml_string(
"""
jobs:
echo:
cmd: ["echo", "${MSG}"]
""",
variables={"MSG": "hello"},
)
assert graph.spec("echo").cmd == ["echo", "hello"]
def test_variable_in_run(self) -> None:
"""变量在 run 中替换."""
graph = parse_yaml_string(
"""
jobs:
t:
run: "echo ${MSG} && exit 0"
""",
variables={"MSG": "hi"},
)
assert graph.spec("t").cmd == "echo hi && exit 0"
def test_variable_in_cwd(self) -> None:
"""变量在 cwd 中替换."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["pwd"]
cwd: "${BASE_DIR}/sub"
""",
variables={"BASE_DIR": "/tmp"},
)
spec = graph.spec("t")
assert spec.cwd is not None
assert "/tmp" in str(spec.cwd)
def test_variable_in_env(self) -> None:
"""变量在 env 中替换."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo"]
env:
PATH: "${BIN_PATH}"
""",
variables={"BIN_PATH": "/usr/local/bin"},
)
assert graph.spec("t").env == {"PATH": "/usr/local/bin"}
def test_variable_in_fn_args(self) -> None:
"""变量在 fn 的 args 中替换."""
@px.register_fn("concat")
def _concat(a: str, b: str) -> str:
return a + b
graph = parse_yaml_string(
"""
jobs:
t:
fn: concat
args: ["${A}", "${B}"]
""",
variables={"A": "foo", "B": "bar"},
)
report = px.run(graph, strategy="sequential")
assert report["t"] == "foobar"
def test_variable_undefined(self) -> None:
"""引用未定义变量应报错."""
with pytest.raises(YamlLoadError, match="变量 'UNDEFINED' 未定义"):
parse_yaml_string(
"""
jobs:
t:
cmd: ["echo", "${UNDEFINED}"]
""",
variables={"OTHER": "x"},
)
def test_multiple_variables(self) -> None:
"""多个变量同时替换."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo", "${A}", "${B}", "${A}"]
""",
variables={"A": "x", "B": "y"},
)
assert graph.spec("t").cmd == ["echo", "x", "y", "x"]
def test_variable_no_variables_provided(self) -> None:
"""未提供 variables 时, ${VAR} 原样保留."""
graph = parse_yaml_string(
"""
jobs:
t:
cmd: ["echo", "${VAR}"]
""",
)
# 没有 variables, 占位符保留
assert graph.spec("t").cmd == ["echo", "${VAR}"]
def test_variable_and_matrix_combined(self) -> None:
"""变量和矩阵占位符同时替换."""
graph = parse_yaml_string(
"""
jobs:
test:
cmd: ["echo", "${PREFIX}", "${{ matrix.mode }}"]
strategy:
matrix:
mode: [release, debug]
""",
variables={"PREFIX": "build"},
)
for name in graph.names:
spec = graph.spec(name)
cmd = spec.cmd
assert isinstance(cmd, list)
assert cmd[1] == "build"
assert cmd[2] in ("release", "debug")
def test_load_yaml_with_variables(self, tmp_path: Path) -> None:
"""load_yaml 支持 variables 参数."""
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
'jobs:\n t:\n cmd: ["echo", "${MSG}"]\n',
encoding="utf-8",
)
graph = load_yaml(yaml_file, variables={"MSG": "loaded"})
assert graph.spec("t").cmd == ["echo", "loaded"]
def test_graph_from_yaml_with_variables(self, tmp_path: Path) -> None:
"""Graph.from_yaml 支持 variables 参数."""
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
'jobs:\n t:\n cmd: ["echo", "${MSG}"]\n',
encoding="utf-8",
)
graph = px.Graph.from_yaml(yaml_file, variables={"MSG": "from_class"})
assert graph.spec("t").cmd == ["echo", "from_class"]