1162 lines
30 KiB
Python
1162 lines
30 KiB
Python
"""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")
|