"""Tests for Graph construction, validation, layering and subgraphs.""" from __future__ import annotations import pytest import pyflowx as px from pyflowx.compose import GraphComposer, compose from pyflowx.errors import CycleError, DuplicateTaskError, MissingDependencyError def _fn() -> None: return None def test_from_specs_builds_graph() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("a", "b")), ] ) assert set(graph.names) == {"a", "b", "c"} assert graph.dependencies("c") == ("a", "b") assert len(graph) == 3 assert "a" in graph def test_from_specs_allows_forward_references() -> None: # b depends on a, but a is declared after b — order should not matter. graph = px.Graph.from_specs( [ px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("a", _fn), ] ) assert graph.layers() == [["a"], ["b"]] def test_duplicate_task_raises() -> None: with pytest.raises(DuplicateTaskError): _ = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("a", _fn), ] ) def test_missing_dependency_raises() -> None: with pytest.raises(MissingDependencyError) as exc_info: _ = px.Graph.from_specs([px.TaskSpec("b", _fn, depends_on=("a",))]) assert exc_info.value.task == "b" assert exc_info.value.dependency == "a" def test_cycle_detection() -> None: with pytest.raises(CycleError): _ = px.Graph.from_specs( [ px.TaskSpec("a", _fn, depends_on=("c",)), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("b",)), ] ) def test_layers_grouping() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn), px.TaskSpec("c", _fn, depends_on=("a", "b")), px.TaskSpec("d", _fn, depends_on=("c",)), ] ) layers = graph.layers() assert layers == [["a", "b"], ["c"], ["d"]] def test_layers_cached() -> None: """layers() 结果缓存:重复调用返回同一列表对象。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), ] ) first = graph.layers() second = graph.layers() assert first is second # 缓存命中返回同一对象 def test_layers_cache_invalidated_on_add() -> None: """添加任务后缓存失效,layers() 返回新结果。""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) first = graph.layers() assert first == [["a"]] graph.add(px.TaskSpec("b", _fn, depends_on=("a",))) second = graph.layers() assert second == [["a"], ["b"]] assert first is not second # 缓存已失效,新对象 def test_self_dependency_rejected() -> None: with pytest.raises(ValueError): _ = px.TaskSpec("a", _fn, depends_on=("a",)) def test_to_mermaid() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), ] ) mermaid = graph.to_mermaid() assert mermaid.startswith("graph TD") assert 'a["a"]' in mermaid assert "a --> b" in mermaid def test_to_mermaid_invalid_orientation() -> None: graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) with pytest.raises(ValueError): _ = graph.to_mermaid("XX") def test_subgraph_by_tags() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn, tags=("ingest",)), px.TaskSpec("b", _fn, depends_on=("a",), tags=("ingest",)), px.TaskSpec("c", _fn, depends_on=("b",), tags=("report",)), ] ) sub = graph.subgraph(["ingest"]) assert set(sub.names) == {"a", "b"} # Edge to dropped task c is removed; b no longer waits for anything # outside the subgraph (c was never a dep of b anyway). assert sub.dependencies("b") == ("a",) def test_subgraph_by_names() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("b",)), ] ) sub = graph.subgraph_by_names(["a", "b"]) assert set(sub.names) == {"a", "b"} # c is dropped, so b's dep on c (none here) — but a->b edge preserved. assert sub.dependencies("b") == ("a",) def test_subgraph_with_deps_basic() -> None: """subgraph_with_deps 应包含传递依赖:选 c 时 a/b 也应包含。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("b",)), ] ) sub = graph.subgraph_with_deps(["c"]) assert set(sub.names) == {"a", "b", "c"} assert sub.dependencies("b") == ("a",) assert sub.dependencies("c") == ("b",) def test_subgraph_with_deps_soft_deps() -> None: """subgraph_with_deps 应包含软依赖。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn), px.TaskSpec("c", _fn, depends_on=("a",), soft_depends_on=("b",)), ] ) sub = graph.subgraph_with_deps(["c"]) assert set(sub.names) == {"a", "b", "c"} def test_subgraph_with_deps_diamond() -> None: """菱形依赖:选 d 时 a/b/c 都应包含。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("a",)), px.TaskSpec("d", _fn, depends_on=("b", "c")), ] ) sub = graph.subgraph_with_deps(["d"]) assert set(sub.names) == {"a", "b", "c", "d"} def test_subgraph_with_deps_multiple_seeds() -> None: """多个种子任务的传递闭包应取并集。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), px.TaskSpec("c", _fn, depends_on=("a",)), px.TaskSpec("d", _fn, depends_on=("c",)), ] ) sub = graph.subgraph_with_deps(["b", "d"]) assert set(sub.names) == {"a", "b", "c", "d"} def test_subgraph_with_deps_unknown_name() -> None: """未知任务名应抛出 KeyError。""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) with pytest.raises(KeyError, match="Unknown task name"): _ = graph.subgraph_with_deps(["missing"]) def test_subgraph_with_deps_empty() -> None: """空名称列表应返回空图。""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) sub = graph.subgraph_with_deps([]) assert len(sub) == 0 def test_subgraph_with_deps_preserves_metadata() -> None: """子图应保留原任务的元数据。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn, tags=("x",), retry=px.RetryPolicy(max_attempts=3)), px.TaskSpec("b", _fn, depends_on=("a",), tags=("y",)), ] ) sub = graph.subgraph_with_deps(["b"]) assert sub.spec("a").tags == ("x",) assert sub.spec("a").retry.max_attempts == 3 def test_subgraph_by_names_unknown() -> None: graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) with pytest.raises(KeyError): _ = graph.subgraph_by_names(["nope"]) def test_describe() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), ] ) desc = graph.describe() assert "Layer 1" in desc assert "Layer 2" in desc # ---------------------------------------------------------------------- # # 增量 add API 与其他访问器 # ---------------------------------------------------------------------- # def test_add_chains_and_validates() -> None: """add() 应返回 self 以支持链式调用,并即时校验。""" graph = px.Graph() ret = graph.add(px.TaskSpec("a", _fn)) assert ret is graph assert "a" in graph # 缺失依赖应即时报错 with pytest.raises(MissingDependencyError): _ = graph.add(px.TaskSpec("b", _fn, depends_on=("missing",))) def test_add_duplicate_raises() -> None: graph = px.Graph() _ = graph.add(px.TaskSpec("a", _fn)) with pytest.raises(DuplicateTaskError): _ = graph.add(px.TaskSpec("a", _fn)) def test_all_specs_returns_view() -> None: graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) view = graph.all_specs() assert set(view.keys()) == {"a"} # 返回的是只读视图,修改不影响内部 assert view is graph.all_specs() or view == graph.all_specs() def test_all_deps_combines_hard_and_soft() -> None: """all_deps 应返回硬依赖 + 软依赖的组合。""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn), px.TaskSpec("c", _fn, depends_on=("a",), soft_depends_on=("b",)), ] ) all_deps = graph.all_deps("c") assert set(all_deps) == {"a", "b"} # 硬依赖在前,软依赖在后 assert all_deps == ("a", "b") def test_spec_accessor() -> None: graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) assert graph.spec("a").name == "a" with pytest.raises(KeyError): _ = graph.spec("missing") def test_dependencies_accessor() -> None: graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, depends_on=("a",)), ] ) assert graph.dependencies("a") == () assert graph.dependencies("b") == ("a",) def test_repr() -> None: graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) assert repr(graph) == "Graph(tasks=1)" def test_empty_graph_layers() -> None: """空图的 layers() 应返回空列表。""" graph = px.Graph() assert graph.layers() == [] assert graph.to_mermaid() == "graph TD\n" def test_subgraph_preserves_metadata() -> None: """子图应保留原任务的 retry/timeout/tags 等元数据。""" graph = px.Graph.from_specs( [ px.TaskSpec( "a", _fn, tags=("x",), retry=px.RetryPolicy(max_attempts=3), timeout=5.0, ), px.TaskSpec("b", _fn, depends_on=("a",), tags=("y",)), ] ) sub = graph.subgraph(["x"]) spec = sub.spec("a") assert spec.retry.max_attempts == 3 assert spec.timeout == 5.0 assert spec.tags == ("x",) def test_subgraph_by_tags_no_match() -> None: """无匹配 tag 时返回空图。""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn, tags=("x",))]) sub = graph.subgraph(["z"]) assert len(sub) == 0 # ---------------------------------------------------------------------- # # from_specs str 类型分支测试 # ---------------------------------------------------------------------- # def test_from_specs_with_string_ref() -> None: """from_specs 接受字符串引用并收集到 pending_refs.""" # 字符串引用被收集到 _pending_refs,而非尝试打开文件 graph = px.Graph.from_specs(["ref_cmd"]) assert graph._pending_refs == ["ref_cmd"] def test_from_specs_with_invalid_type() -> None: """from_specs 接受不支持的类型时应抛 TypeError.""" with pytest.raises(TypeError, match="from_specs 只接受 TaskSpec 或 str"): _ = px.Graph.from_specs([123]) # type: ignore[list-item] # ---------------------------------------------------------------------- # # to_mermaid 软依赖测试 # ---------------------------------------------------------------------- # def test_to_mermaid_soft_depends_on() -> None: """to_mermaid 应正确绘制软依赖为虚线.""" graph = px.Graph.from_specs( [ px.TaskSpec("a", _fn), px.TaskSpec("b", _fn, soft_depends_on=("a",)), ] ) mermaid = graph.to_mermaid() assert "a -.-> b" in mermaid # 软依赖用虚线 # ---------------------------------------------------------------------- # # GraphComposer 与 compose 测试 # ---------------------------------------------------------------------- # def test_graph_composer_resolve_all() -> None: """GraphComposer.resolve_all 应展开所有图的字符串引用.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn), px.TaskSpec("a2", _fn, depends_on=("a1",))]) # 创建带 _pending_refs 的图 graph_b = px.Graph.from_specs([px.TaskSpec("b1", _fn)]) graph_b._pending_refs = ["cmd_a"] # 手动设置内部属性 composer = GraphComposer({"cmd_a": graph_a, "cmd_b": graph_b}) resolved = composer.resolve_all() # graph_b 应包含 graph_a 的任务 assert "a1" in resolved["cmd_b"] assert "a2" in resolved["cmd_b"] def test_graph_composer_parse_ref_self_reference() -> None: """GraphComposer.parse_ref 应检测循环引用.""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) composer = GraphComposer({"cmd": graph}) with pytest.raises(ValueError, match="循环引用"): _ = composer.parse_ref("cmd", "cmd") def test_graph_composer_parse_ref_cmd_not_found() -> None: """GraphComposer.parse_ref 应检测引用的命令不存在.""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) composer = GraphComposer({"cmd": graph}) with pytest.raises(ValueError, match="引用的命令 'missing' 不存在"): _ = composer.parse_ref("missing", "current") def test_graph_composer_parse_ref_task_not_found() -> None: """GraphComposer.parse_ref 应检测任务不存在于引用的命令中.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn)]) graph_b = px.Graph.from_specs([px.TaskSpec("b1", _fn)]) composer = GraphComposer({"cmd_a": graph_a, "cmd_b": graph_b}) with pytest.raises(ValueError, match="任务 'missing' 不存在于命令 'cmd_a' 中"): _ = composer.parse_ref("cmd_a.missing", "cmd_b") def test_graph_composer_expand_refs_no_pending() -> None: """GraphComposer.expand_refs 无 pending_refs 时应原样返回.""" graph = px.Graph.from_specs([px.TaskSpec("a", _fn)]) composer = GraphComposer({"cmd": graph}) expanded = composer.expand_refs(graph, "cmd") assert expanded is graph def test_compose_function() -> None: """compose() 函数应等同于 GraphComposer().resolve_all()。""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn)]) graph_b = px.Graph.from_specs([px.TaskSpec("b1", _fn)]) graph_b._pending_refs = ["cmd_a"] # 手动设置内部属性 resolved = compose({"cmd_a": graph_a, "cmd_b": graph_b}) assert "a1" in resolved["cmd_b"] def test_graph_composer_expand_refs_multiple_refs_chain() -> None: """expand_refs 多个 ref 应串联依赖:后一个 ref 首任务依赖前一个 ref 末任务.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn)]) graph_c = px.Graph.from_specs([px.TaskSpec("c1", _fn)]) graph_b = px.Graph.from_specs([px.TaskSpec("b1", _fn)]) graph_b._pending_refs = ["cmd_a", "cmd_c"] composer = GraphComposer({"cmd_a": graph_a, "cmd_c": graph_c, "cmd_b": graph_b}) resolved = composer.resolve_all() # c1 应依赖 a1(后 ref 首任务依赖前 ref 末任务) assert "a1" in resolved["cmd_b"] assert "c1" in resolved["cmd_b"] assert "b1" in resolved["cmd_b"] c1_spec = resolved["cmd_b"].all_specs()["c1"] assert "a1" in c1_spec.depends_on def test_graph_composer_expand_refs_ref_returns_empty() -> None: """expand_refs 引用空图时,previous_ref_last_task 保持 None,original_specs 走 else 分支.""" graph_empty = px.Graph.from_specs([]) graph_b = px.Graph.from_specs([px.TaskSpec("b1", _fn)]) graph_b._pending_refs = ["empty_cmd"] composer = GraphComposer({"empty_cmd": graph_empty, "cmd_b": graph_b}) resolved = composer.resolve_all() # b1 保留,无额外依赖 assert "b1" in resolved["cmd_b"] b1_spec = resolved["cmd_b"].all_specs()["b1"] assert b1_spec.depends_on == () def test_graph_composer_expand_refs_multiple_original_specs_serialized() -> None: """expand_refs 多个 original_specs 应串行依赖,且首个依赖 ref 末任务.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn)]) graph_b = px.Graph.from_specs( [ px.TaskSpec("b1", _fn), px.TaskSpec("b2", _fn), px.TaskSpec("b3", _fn), ] ) graph_b._pending_refs = ["cmd_a"] composer = GraphComposer({"cmd_a": graph_a, "cmd_b": graph_b}) resolved = composer.resolve_all() specs = resolved["cmd_b"].all_specs() # b1 依赖 a1(ref 末任务) assert "a1" in specs["b1"].depends_on # b2 依赖 b1,b3 依赖 b2(串行) assert "b1" in specs["b2"].depends_on assert "b2" in specs["b3"].depends_on def test_graph_composer_parse_ref_dot_notation_success() -> None: """parse_ref 'cmd.task' 形式应返回对应单个 TaskSpec.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn), px.TaskSpec("a2", _fn)]) composer = GraphComposer({"cmd_a": graph_a}) result = composer.parse_ref("cmd_a.a2", "cmd_b") assert len(result) == 1 assert result[0].name == "a2" def test_graph_composer_parse_ref_dot_notation_cmd_not_found() -> None: """parse_ref 'missing.task' 形式应检测命令不存在.""" graph_a = px.Graph.from_specs([px.TaskSpec("a1", _fn)]) composer = GraphComposer({"cmd_a": graph_a}) with pytest.raises(ValueError, match="引用的命令 'missing' 不存在"): _ = composer.parse_ref("missing.task", "cmd_b") # ---------------------------------------------------------------------- # # resolved_spec defaults 测试 # ---------------------------------------------------------------------- # def test_resolved_spec_applies_defaults() -> None: """resolved_spec 应应用 Graph.defaults。""" defaults = px.GraphDefaults(timeout=10.0, retry=px.RetryPolicy(max_attempts=2)) graph = px.Graph.from_specs([px.TaskSpec("a", _fn)], defaults=defaults) resolved = graph.resolved_spec("a") assert resolved.timeout == 10.0 assert resolved.retry.max_attempts == 2 def test_resolved_spec_no_override() -> None: """resolved_spec 不应覆盖任务已有的设置。""" defaults = px.GraphDefaults(timeout=10.0) graph = px.Graph.from_specs([px.TaskSpec("a", _fn, timeout=5.0)], defaults=defaults) resolved = graph.resolved_spec("a") assert resolved.timeout == 5.0 # 保持原值,不被 defaults 覆盖 # ---------------------------------------------------------------------- # # depends_on 自动推断测试 # ---------------------------------------------------------------------- # def test_auto_infer_deps_from_param_names() -> None: """depends_on 为空的纯 fn 任务,从必需参数名自动推断依赖。""" def extract() -> list[int]: return [1, 2, 3] def double(extract: list[int]) -> list[int]: return [x * 2 for x in extract] graph = px.graph(px.task(extract), px.task(double)) assert graph.dependencies("double") == ("extract",) assert graph.layers() == [["extract"], ["double"]] def test_auto_infer_respects_explicit_depends_on() -> None: """显式声明 depends_on 时不自动推断。""" def extract() -> int: return 1 def other() -> int: return 2 def consumer(extract: int, other: int) -> int: return extract + other spec = px.TaskSpec("consumer", consumer, depends_on=("extract",)) graph = px.graph(px.task(extract), px.task(other), spec) assert graph.dependencies("consumer") == ("extract",) def test_auto_infer_skips_optional_params() -> None: """有默认值的参数不参与自动推断。""" def source() -> int: return 1 def consumer(source: int, flag: bool = False) -> int: return source if flag else 0 graph = px.graph(px.task(source), px.task(consumer)) assert graph.dependencies("consumer") == ("source",) def test_auto_infer_skips_context_annotation() -> None: """Context 标注的参数不参与自动推断。""" def source() -> int: return 1 def consumer(ctx: px.Context) -> int: return len(ctx) graph = px.graph(px.task(source), px.task(consumer)) assert graph.dependencies("consumer") == () def test_auto_infer_skips_cmd_tasks() -> None: """cmd 任务(无 fn)不自动推断依赖。""" graph = px.graph(px.cmd(["echo", "a"], name="a"), px.cmd(["echo", "b"], name="b")) assert graph.dependencies("a") == () assert graph.dependencies("b") == () def test_auto_infer_skips_var_args() -> None: """*args / **kwargs 参数不参与自动推断。""" def source() -> int: return 1 def consumer(*args: int, **kwargs: int) -> int: return sum(args) + sum(kwargs.values()) graph = px.graph(px.task(source), px.task(consumer)) assert graph.dependencies("consumer") == () def test_auto_infer_only_matches_known_names() -> None: """参数名不在图中任务名集合时不参与推断。""" def source() -> int: return 1 def consumer(source: int, unknown: int) -> int: return source + unknown graph = px.graph(px.task(source), px.task(consumer)) assert graph.dependencies("consumer") == ("source",) def test_auto_infer_skips_self_name() -> None: """参数名与任务自身名相同时不形成自依赖。""" def a(a: int) -> int: return a graph = px.graph(px.task(a)) assert graph.dependencies("a") == () def test_auto_infer_via_add_method() -> None: """Graph.add() 也能自动推断依赖(基于图中已有任务名)。""" def extract() -> int: return 1 def double(extract: int) -> int: return extract * 2 graph = px.Graph().add(px.task(extract)).add(px.task(double)) assert graph.dependencies("double") == ("extract",) def test_auto_infer_multiple_deps() -> None: """多个必需参数匹配任务名时全部推断为依赖。""" def a() -> int: return 1 def b() -> int: return 2 def c(a: int, b: int) -> int: return a + b graph = px.graph(px.task(a), px.task(b), px.task(c)) assert set(graph.dependencies("c")) == {"a", "b"} assert graph.layers() == [["a", "b"], ["c"]] def test_auto_infer_preserves_param_order() -> None: """推断出的 depends_on 保持参数声明顺序。""" def a() -> int: return 1 def b() -> int: return 2 def c(a: int, b: int) -> int: return a + b graph = px.graph(px.task(b), px.task(a), px.task(c)) assert graph.dependencies("c") == ("a", "b") def test_auto_infer_runs_end_to_end() -> None: """自动推断后图可正确执行并注入结果。""" def extract() -> list[int]: return [1, 2, 3] def double(extract: list[int]) -> list[int]: return [x * 2 for x in extract] graph = px.graph(px.task(extract), px.task(double)) report = px.run(graph) assert report["double"] == [2, 4, 6] # ---------------------------------------------------------------------- # # px.graph() 快捷构造测试 # ---------------------------------------------------------------------- # def test_graph_shorthand_equivalent_to_from_specs() -> None: """px.graph(*specs) 等价于 Graph.from_specs(list(specs))。""" spec_a = px.TaskSpec("a", _fn) spec_b = px.TaskSpec("b", _fn, depends_on=("a",)) g1 = px.graph(spec_a, spec_b) g2 = px.Graph.from_specs([spec_a, spec_b]) assert g1.names == g2.names assert g1.layers() == g2.layers() def test_graph_shorthand_accepts_string_refs() -> None: """px.graph() 接受字符串引用(与 from_specs 一致)。""" spec_a = px.TaskSpec("a", _fn) g1 = px.graph(spec_a, "a") g2 = px.Graph.from_specs([spec_a, "a"]) assert g1.names == g2.names def test_graph_shorthand_with_defaults() -> None: """px.graph() 透传 defaults 参数。""" defaults = px.GraphDefaults(timeout=10.0) graph = px.graph(px.TaskSpec("a", _fn), defaults=defaults) assert graph.resolved_spec("a").timeout == 10.0 def test_graph_shorthand_with_namespace() -> None: """px.graph() 透传 namespace 参数(存储为属性,供 add_subgraph 合并时使用)。""" graph = px.graph(px.TaskSpec("a", _fn), namespace="ns") assert graph.namespace == "ns" def test_graph_shorthand_empty() -> None: """px.graph() 无参数时返回空图。""" graph = px.graph() assert len(graph) == 0 assert graph.layers() == []