"""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"]