6931f36fd1
CI / Lint, Typecheck & Test (push) Successful in 2m27s
- 新增 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, 删除需要用户明确指示的步骤
1577 lines
38 KiB
Python
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"]
|