Files
pyflowx/tests/test_yaml_loader.py
T

1162 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""YAML 任务编排测试。"""
from __future__ import annotations
from pathlib import Path
import pytest
import pyflowx as px
from pyflowx.task import TaskStatus
from pyflowx.yaml_loader import load_yaml, parse_yaml_string
class TestBasicParsing:
"""基本解析测试。"""
def test_parse_simple_cmd_job(self) -> None:
graph = parse_yaml_string("""
jobs:
hello:
cmd: ["echo", "hello"]
""")
assert "hello" in graph
spec = graph.spec("hello")
assert spec.cmd == ["echo", "hello"]
def test_parse_run_as_shell_string(self) -> None:
graph = parse_yaml_string("""
jobs:
greet:
run: "echo hello | wc -l"
""")
spec = graph.spec("greet")
assert spec.cmd == "echo hello | wc -l"
def test_parse_needs_dependency(self) -> None:
graph = parse_yaml_string("""
jobs:
a:
cmd: ["echo", "a"]
b:
needs: [a]
cmd: ["echo", "b"]
""")
assert graph.dependencies("b") == ("a",)
def test_parse_needs_single_string(self) -> None:
graph = parse_yaml_string("""
jobs:
a:
cmd: ["echo", "a"]
b:
needs: a
cmd: ["echo", "b"]
""")
assert graph.dependencies("b") == ("a",)
def test_parse_timeout(self) -> None:
graph = parse_yaml_string("""
jobs:
slow:
cmd: ["sleep", "10"]
timeout: 300
""")
assert graph.spec("slow").timeout == 300.0
def test_parse_retry(self) -> None:
graph = parse_yaml_string("""
jobs:
flaky:
cmd: ["curl", "http://example.com"]
retry: {max_attempts: 3, delay: 1.0, backoff: 2.0}
""")
spec = graph.spec("flaky")
assert spec.retry.max_attempts == 3
assert spec.retry.delay == 1.0
assert spec.retry.backoff == 2.0
def test_parse_retry_partial_fields(self) -> None:
graph = parse_yaml_string("""
jobs:
flaky:
cmd: ["echo", "flaky"]
retry: {delay: 0.5}
""")
spec = graph.spec("flaky")
assert spec.retry.max_attempts == 1
assert spec.retry.delay == 0.5
def test_parse_env(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["make", "all"]
env: {CI: "true", DEBUG: "1"}
""")
spec = graph.spec("build")
assert spec.env == {"CI": "true", "DEBUG": "1"}
def test_parse_cwd(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["make", "all"]
cwd: /tmp/build
""")
assert graph.spec("build").cwd == Path("/tmp/build")
def test_parse_verbose(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
verbose: true
""")
assert graph.spec("build").verbose is True
def test_parse_tags(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
tags: [api, build]
""")
assert graph.spec("build").tags == ("api", "build")
def test_parse_runs_on_added_to_tags(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
runs-on: linux
""")
assert "linux" in graph.spec("build").tags
def test_parse_priority(self) -> None:
graph = parse_yaml_string("""
jobs:
high:
cmd: ["echo", "high"]
priority: 10
""")
assert graph.spec("high").priority == 10
def test_parse_concurrency_key(self) -> None:
graph = parse_yaml_string("""
jobs:
deploy:
cmd: ["echo", "deploy"]
concurrency-key: deploy_lock
""")
assert graph.spec("deploy").concurrency_key == "deploy_lock"
class TestFieldnameCompat:
"""hyphen/underscore 字段名兼容测试。"""
def test_continue_on_error_hyphen(self) -> None:
graph = parse_yaml_string("""
jobs:
flaky:
cmd: ["echo", "flaky"]
continue-on-error: true
""")
assert graph.spec("flaky").continue_on_error is True
def test_continue_on_error_underscore(self) -> None:
graph = parse_yaml_string("""
jobs:
flaky:
cmd: ["echo", "flaky"]
continue_on_error: true
""")
assert graph.spec("flaky").continue_on_error is True
def test_allow_upstream_skip_hyphen(self) -> None:
graph = parse_yaml_string("""
jobs:
cleanup:
cmd: ["echo", "cleanup"]
allow-upstream-skip: true
""")
assert graph.spec("cleanup").allow_upstream_skip is True
def test_skip_if_missing_hyphen(self) -> None:
graph = parse_yaml_string("""
jobs:
optional:
cmd: ["nonexistent-tool", "arg"]
skip-if-missing: true
""")
assert graph.spec("optional").skip_if_missing is True
class TestMatrixExpansion:
"""矩阵展开测试。"""
def test_single_key_matrix(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["python", "-m", "pytest"]
strategy:
matrix:
version: ["3.10", "3.11", "3.12"]
""")
names = graph.names
assert "test_version-3.10" in names
assert "test_version-3.11" in names
assert "test_version-3.12" in names
assert len(names) == 3
def test_multi_key_matrix_cartesian(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["python", "-m", "pytest"]
strategy:
matrix:
version: ["3.10", "3.11"]
os: ["linux", "macos"]
""")
names = set(graph.names)
assert names == {
"test_version-3.10_os-linux",
"test_version-3.11_os-linux",
"test_version-3.10_os-macos",
"test_version-3.11_os-macos",
}
def test_matrix_placeholder_substitution_in_cmd(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["python${{ matrix.version }}", "-m", "pytest"]
strategy:
matrix:
version: ["3.10", "3.11"]
""")
spec = graph.spec("test_version-3.10")
assert spec.cmd == ["python3.10", "-m", "pytest"]
def test_matrix_placeholder_in_env(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
env: {VERSION: "${{ matrix.version }}"}
strategy:
matrix:
version: ["3.10", "3.11"]
""")
spec = graph.spec("test_version-3.11")
assert spec.env == {"VERSION": "3.11"}
def test_matrix_placeholder_in_cwd(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
cwd: "/tmp/${{ matrix.os }}"
strategy:
matrix:
os: ["linux", "macos"]
""")
spec = graph.spec("test_os-linux")
assert spec.cwd == Path("/tmp/linux")
def test_matrix_dependency_auto_expand(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
strategy:
matrix:
version: ["3.10", "3.11"]
test:
needs: [build]
cmd: ["echo", "test"]
""")
test_spec = graph.spec("test")
assert set(test_spec.depends_on) == {"build_version-3.10", "build_version-3.11"}
def test_matrix_dependency_with_non_matrix_job(self) -> None:
graph = parse_yaml_string("""
jobs:
setup:
cmd: ["echo", "setup"]
build:
needs: [setup]
cmd: ["echo", "build"]
strategy:
matrix:
version: ["3.10", "3.11"]
deploy:
needs: [build, setup]
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "setup" in deploy_spec.depends_on
assert "build_version-3.10" in deploy_spec.depends_on
assert "build_version-3.11" in deploy_spec.depends_on
class TestMatrixIncludeExclude:
"""矩阵 include/exclude 过滤测试。"""
def test_exclude_removes_matching_combo(self) -> None:
"""exclude 剔除匹配的组合。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10", "3.11"]
os: ["linux", "macos"]
exclude:
- version: "3.10"
os: "macos"
""")
names = set(graph.names)
assert "test_version-3.10_os-macos" not in names
assert "test_version-3.10_os-linux" in names
assert "test_version-3.11_os-linux" in names
assert "test_version-3.11_os-macos" in names
assert len(names) == 3
def test_exclude_partial_match_keeps_combo(self) -> None:
"""exclude 仅匹配部分 key 时不剔除。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10", "3.11"]
os: ["linux", "macos"]
exclude:
- version: "3.10"
""")
# exclude {version: 3.10} 匹配两个组合,都剔除
names = set(graph.names)
assert "test_version-3.10_os-linux" not in names
assert "test_version-3.10_os-macos" not in names
assert "test_version-3.11_os-linux" in names
assert "test_version-3.11_os-macos" in names
assert len(names) == 2
def test_include_adds_extra_combo(self) -> None:
"""include 追加额外组合。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10", "3.11"]
include:
- version: "3.12"
extra: "new"
""")
names = set(graph.names)
# 基础矩阵
assert "test_version-3.10" in names
assert "test_version-3.11" in names
# include 追加(含新键 extra
assert "test_version-3.12_extra-new" in names
assert len(names) == 3
def test_include_only_no_base_matrix(self) -> None:
"""无基础矩阵时 include 仍可工作。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
include:
- version: "3.10"
- version: "3.11"
""")
names = set(graph.names)
assert "test_version-3.10" in names
assert "test_version-3.11" in names
assert len(names) == 2
def test_exclude_all_combos(self) -> None:
"""exclude 剔除所有组合时,job 不产生任务。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10", "3.11"]
exclude:
- version: "3.10"
- version: "3.11"
""")
assert len(graph.names) == 0
def test_include_exclude_combined(self) -> None:
"""include 与 exclude 组合使用。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10", "3.11"]
os: ["linux", "macos"]
exclude:
- version: "3.10"
os: "linux"
include:
- version: "3.12"
os: "linux"
""")
names = set(graph.names)
assert "test_version-3.10_os-linux" not in names # excluded
assert "test_version-3.10_os-macos" in names
assert "test_version-3.11_os-linux" in names
assert "test_version-3.11_os-macos" in names
assert "test_version-3.12_os-linux" in names # included
assert len(names) == 4
def test_exclude_not_list_raises(self) -> None:
"""exclude 非列表时抛 ValueError。"""
with pytest.raises(ValueError, match=r"matrix\.exclude 必须是列表"):
parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10"]
exclude: "not a list"
""")
def test_include_not_list_raises(self) -> None:
"""include 非列表时抛 ValueError。"""
with pytest.raises(ValueError, match=r"matrix\.include 必须是列表"):
parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10"]
include: "not a list"
""")
def test_exclude_item_not_mapping_raises(self) -> None:
"""exclude 项非映射时抛 ValueError。"""
with pytest.raises(ValueError, match="matrix include/exclude 项必须是映射"):
parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: ["3.10"]
exclude:
- "not a mapping"
""")
def test_include_with_matrix_placeholder_substitution(self) -> None:
"""include 组合中的值参与占位符替换。"""
graph = parse_yaml_string("""
jobs:
test:
cmd: ["python${{ matrix.version }}", "-m", "pytest"]
strategy:
matrix:
version: ["3.10"]
include:
- version: "3.12"
""")
spec = graph.spec("test_version-3.12")
assert spec.cmd == ["python3.12", "-m", "pytest"]
class TestConditionalNeeds:
"""条件依赖测试。"""
def test_conditional_need_with_env_var_exists(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if env.CI 满足时依赖被加入。"""
monkeypatch.setenv("CI", "true")
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
deploy:
needs:
- job: build
if: "env.CI"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "build" in deploy_spec.depends_on
def test_conditional_need_skipped_when_env_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if env.CI 不满足时依赖被跳过。"""
monkeypatch.delenv("MISSING_VAR", raising=False)
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
deploy:
needs:
- job: build
if: "env.MISSING_VAR"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "build" not in deploy_spec.depends_on
def test_conditional_need_with_env_equals(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if env.VAR == 'x' 精确匹配时依赖被加入。"""
monkeypatch.setenv("BRANCH", "main")
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
deploy:
needs:
- job: build
if: "env.BRANCH == 'main'"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "build" in deploy_spec.depends_on
def test_conditional_need_skipped_with_env_not_equals(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""if env.VAR == 'x' 不匹配时依赖被跳过。"""
monkeypatch.setenv("BRANCH", "dev")
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
deploy:
needs:
- job: build
if: "env.BRANCH == 'main'"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "build" not in deploy_spec.depends_on
def test_mixed_string_and_conditional_needs(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""字符串依赖与条件依赖混合使用。"""
monkeypatch.setenv("CI", "true")
graph = parse_yaml_string("""
jobs:
setup:
cmd: ["echo", "setup"]
build:
cmd: ["echo", "build"]
deploy:
needs:
- setup
- job: build
if: "env.CI"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "setup" in deploy_spec.depends_on
assert "build" in deploy_spec.depends_on
def test_conditional_need_missing_job_field_raises(self) -> None:
"""条件依赖项缺少 job 字段时抛 ValueError。"""
with pytest.raises(ValueError, match="缺少 'job' 字段"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
deploy:
needs:
- if: "env.CI"
cmd: ["echo", "deploy"]
""")
def test_conditional_need_with_matrix_job(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""条件依赖展开矩阵 job。"""
monkeypatch.setenv("DEPLOY", "true")
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
strategy:
matrix:
version: ["3.10", "3.11"]
deploy:
needs:
- job: build
if: "env.DEPLOY"
cmd: ["echo", "deploy"]
""")
deploy_spec = graph.spec("deploy")
assert "build_version-3.10" in deploy_spec.depends_on
assert "build_version-3.11" in deploy_spec.depends_on
class TestOutputs:
"""outputs 字段测试。"""
def test_outputs_parsed_as_metadata(self) -> None:
"""outputs 作为元数据附加到 TaskSpec。"""
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
outputs:
version: "1.0.0"
artifact: "dist.tar.gz"
""")
spec = graph.spec("build")
assert spec.outputs is not None
assert spec.outputs["version"] == "1.0.0"
assert spec.outputs["artifact"] == "dist.tar.gz"
def test_outputs_none_when_not_declared(self) -> None:
"""未声明 outputs 时为 None。"""
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
""")
spec = graph.spec("build")
assert spec.outputs is None
def test_outputs_with_matrix_placeholder(self) -> None:
"""outputs 值支持 ${{ matrix.* }} 占位符。"""
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
outputs:
version: "${{ matrix.version }}"
strategy:
matrix:
version: ["3.10", "3.11"]
""")
spec_310 = graph.spec("build_version-3.10")
spec_311 = graph.spec("build_version-3.11")
assert spec_310.outputs is not None
assert spec_310.outputs["version"] == "3.10"
assert spec_311.outputs is not None
assert spec_311.outputs["version"] == "3.11"
def test_outputs_not_mapping_raises(self) -> None:
"""outputs 非映射时抛 ValueError。"""
with pytest.raises(ValueError, match="outputs 必须是映射"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
outputs: "not a mapping"
""")
def test_outputs_round_trip_via_json(self) -> None:
"""outputs 通过 to_json/from_json 往返保留。"""
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
outputs:
version: "1.0.0"
""")
spec = graph.spec("build")
report = px.RunReport()
from datetime import datetime
from pyflowx.task import TaskResult
report.results["build"] = TaskResult(
spec=spec,
status=TaskStatus.SUCCESS,
value=0,
attempts=1,
started_at=datetime.now(),
finished_at=datetime.now(),
)
text = report.to_json()
restored = px.RunReport.from_json(text)
assert restored.result_of("build").spec.outputs == {"version": "1.0.0"}
def test_outputs_in_to_dict(self) -> None:
"""to_dict 包含 outputs 字段。"""
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
outputs:
version: "1.0.0"
""")
spec = graph.spec("build")
report = px.RunReport()
from datetime import datetime
from pyflowx.task import TaskResult
report.results["build"] = TaskResult(
spec=spec,
status=TaskStatus.SUCCESS,
value=0,
attempts=1,
started_at=datetime.now(),
finished_at=datetime.now(),
)
d = report.to_dict()
assert d["results"][0]["outputs"] == {"version": "1.0.0"}
def test_outputs_none_in_to_dict(self) -> None:
"""无 outputs 时 to_dict 中为 None。"""
report = px.RunReport()
from datetime import datetime
from pyflowx.task import TaskResult, TaskSpec
spec: TaskSpec[int] = TaskSpec("a", lambda: 1)
report.results["a"] = TaskResult(
spec=spec,
status=TaskStatus.SUCCESS,
value=1,
attempts=1,
started_at=datetime.now(),
finished_at=datetime.now(),
)
d = report.to_dict()
assert d["results"][0]["outputs"] is None
class TestConditions:
"""条件解析测试。"""
def test_success_condition(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: "success()"
""")
assert graph.spec("build").conditions == ()
def test_always_condition(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: "always()"
""")
assert graph.spec("build").conditions == ()
def test_env_var_exists_condition(self, monkeypatch: pytest.MonkeyPatch) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: "env.CI"
""")
spec = graph.spec("build")
assert len(spec.conditions) == 1
monkeypatch.delenv("CI", raising=False)
assert spec.conditions[0]({}) is False
monkeypatch.setenv("CI", "true")
assert spec.conditions[0]({}) is True
def test_env_var_equals_condition(self) -> None:
graph = parse_yaml_string("""
jobs:
deploy:
cmd: ["echo", "deploy"]
if: "env.CI == 'true'"
""")
spec = graph.spec("deploy")
assert len(spec.conditions) == 1
def test_env_var_not_equals_condition(self) -> None:
graph = parse_yaml_string("""
jobs:
skip_deploy:
cmd: ["echo", "skip"]
if: "env.CI != 'true'"
""")
spec = graph.spec("skip_deploy")
assert len(spec.conditions) == 1
def test_failure_condition_not_supported(self) -> None:
with pytest.raises(ValueError, match="failure"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: "failure()"
""")
def test_invalid_condition_raises(self) -> None:
with pytest.raises(ValueError, match="无法解析"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: "invalid_expr"
""")
class TestGraphDefaults:
"""图级默认值测试。"""
def test_defaults_retry(self) -> None:
graph = parse_yaml_string("""
defaults:
retry: {max_attempts: 3, jitter: 0.5}
jobs:
build:
cmd: ["echo", "build"]
""")
spec = graph.resolved_spec("build")
assert spec.retry.max_attempts == 3
assert spec.retry.jitter == 0.5
def test_defaults_verbose(self) -> None:
graph = parse_yaml_string("""
defaults:
verbose: true
jobs:
build:
cmd: ["echo", "build"]
""")
spec = graph.resolved_spec("build")
assert spec.verbose is True
def test_defaults_env(self) -> None:
graph = parse_yaml_string("""
defaults:
env: {CI: "true"}
jobs:
build:
cmd: ["echo", "build"]
""")
spec = graph.resolved_spec("build")
assert spec.env == {"CI": "true"}
def test_defaults_timeout(self) -> None:
graph = parse_yaml_string("""
defaults:
timeout: 120
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").timeout == 120.0
def test_defaults_cwd(self, tmp_path: Path) -> None:
graph = parse_yaml_string(f"""
defaults:
cwd: {tmp_path}
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").cwd == tmp_path
def test_defaults_tags(self) -> None:
graph = parse_yaml_string("""
defaults:
tags: [ci]
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").tags == ("ci",)
def test_defaults_priority(self) -> None:
graph = parse_yaml_string("""
defaults:
priority: 5
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").priority == 5
def test_defaults_continue_on_error(self) -> None:
graph = parse_yaml_string("""
defaults:
continue-on-error: true
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").continue_on_error is True
def test_defaults_concurrency_key(self) -> None:
graph = parse_yaml_string("""
defaults:
concurrency-key: lock
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.resolved_spec("build").concurrency_key == "lock"
def test_strategy_at_top_level(self) -> None:
graph = parse_yaml_string("""
strategy: thread
jobs:
build:
cmd: ["echo", "build"]
""")
assert graph.defaults.strategy == "thread"
class TestErrorHandling:
"""错误处理测试。"""
def test_missing_jobs_raises(self) -> None:
with pytest.raises(ValueError, match="jobs"):
parse_yaml_string("strategy: thread")
def test_empty_jobs_raises(self) -> None:
with pytest.raises(ValueError, match="jobs"):
parse_yaml_string("jobs: {}")
def test_job_missing_cmd_and_run_raises(self) -> None:
with pytest.raises(ValueError, match="cmd 或 run"):
parse_yaml_string("""
jobs:
bad:
timeout: 10
""")
def test_non_mapping_root_raises(self) -> None:
with pytest.raises(ValueError, match="根节点"):
parse_yaml_string("- item")
def test_non_mapping_job_raises(self) -> None:
with pytest.raises(ValueError, match="映射"):
parse_yaml_string("""
jobs:
bad: "just a string"
""")
def test_undefined_matrix_var_raises(self) -> None:
with pytest.raises(ValueError, match=r"matrix\.undefined"):
parse_yaml_string("""
jobs:
test:
cmd: ["echo", "${{ matrix.undefined }}"]
strategy:
matrix:
version: ["3.10"]
""")
def test_invalid_retry_raises(self) -> None:
with pytest.raises(ValueError, match="retry"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
retry: "not a mapping"
""")
def test_jobs_not_mapping_raises(self) -> None:
with pytest.raises(ValueError, match="jobs 必须是映射"):
parse_yaml_string("jobs: [1, 2, 3]")
def test_defaults_not_mapping_raises(self) -> None:
with pytest.raises(ValueError, match="defaults 必须是映射"):
parse_yaml_string("""
defaults: "invalid"
jobs:
build:
cmd: ["echo", "build"]
""")
def test_matrix_value_not_list_raises(self) -> None:
with pytest.raises(ValueError, match="必须是列表"):
parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix:
version: "3.10"
""")
def test_env_not_mapping_raises(self) -> None:
with pytest.raises(ValueError, match="env 必须是映射"):
parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
env: "invalid"
""")
def test_need_referencing_unknown_job_raises(self) -> None:
with pytest.raises(px.MissingDependencyError, match="unknown"):
parse_yaml_string("""
jobs:
build:
needs: [unknown]
cmd: ["echo", "build"]
""")
def test_empty_if_expr(self) -> None:
graph = parse_yaml_string("""
jobs:
build:
cmd: ["echo", "build"]
if: ""
""")
assert graph.spec("build").conditions == ()
def test_cmd_as_string_with_matrix(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: "echo ${{ matrix.version }}"
strategy:
matrix:
version: ["3.10"]
""")
assert graph.spec("test_version-3.10").cmd == "echo 3.10"
def test_matrix_not_mapping_returns_none(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix: "invalid"
""")
assert "test" in graph
def test_empty_matrix_keys(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test"]
strategy:
matrix: {}
""")
assert "test" in graph
class TestLoadFromFile:
"""从文件加载测试。"""
def test_load_yaml_file(self, tmp_path: Path) -> None:
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
"""
jobs:
hello:
cmd: ["echo", "hello"]
""",
encoding="utf-8",
)
graph = load_yaml(yaml_file)
assert "hello" in graph
def test_from_yaml_classmethod(self, tmp_path: Path) -> None:
yaml_file = tmp_path / "pipeline.yaml"
yaml_file.write_text(
"""
jobs:
hello:
cmd: ["echo", "hello"]
""",
encoding="utf-8",
)
graph = px.Graph.from_yaml(yaml_file)
assert "hello" in graph
def test_load_yaml_file_not_found(self) -> None:
with pytest.raises(OSError):
load_yaml("/nonexistent/pipeline.yaml")
class TestExecution:
"""端到端执行测试。"""
def test_run_simple_yaml_graph(self) -> None:
graph = parse_yaml_string("""
jobs:
hello:
cmd: ["echo", "hello"]
""")
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("hello").status == TaskStatus.SUCCESS
def test_run_yaml_with_dependency(self) -> None:
graph = parse_yaml_string("""
jobs:
a:
cmd: ["echo", "from-a"]
b:
needs: [a]
cmd: ["echo", "from-b"]
""")
report = px.run(graph, strategy="sequential")
assert report.success
assert "a" in report.results
assert "b" in report.results
def test_run_matrix_graph(self) -> None:
graph = parse_yaml_string("""
jobs:
test:
cmd: ["echo", "test-${{ matrix.version }}"]
strategy:
matrix:
version: ["3.10", "3.11"]
""")
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("test_version-3.10").status == TaskStatus.SUCCESS
assert report.result_of("test_version-3.11").status == TaskStatus.SUCCESS
def test_condition_skips_task(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SKIP_ME", raising=False)
graph = parse_yaml_string("""
jobs:
conditional:
cmd: ["echo", "should-skip"]
if: "env.SKIP_ME"
""")
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("conditional").status == TaskStatus.SKIPPED
def test_condition_executes_when_env_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RUN_ME", "yes")
graph = parse_yaml_string("""
jobs:
conditional:
cmd: ["echo", "should-run"]
if: "env.RUN_ME"
""")
report = px.run(graph, strategy="sequential")
assert report.success
assert report.result_of("conditional").status == TaskStatus.SUCCESS
class TestPublicAPI:
"""公共 API 导出测试。"""
def test_load_yaml_exported(self) -> None:
assert hasattr(px, "load_yaml")
assert px.load_yaml is load_yaml
def test_parse_yaml_string_exported(self) -> None:
assert hasattr(px, "parse_yaml_string")
assert px.parse_yaml_string is parse_yaml_string
def test_graph_from_yaml_classmethod(self) -> None:
assert hasattr(px.Graph, "from_yaml")