bd3651e337
CI / Lint, Typecheck & Test (push) Failing after 1m37s
depends_on 为空的纯 fn 任务自动从必需参数名推断依赖(匹配图中任务名), 消除显式声明 depends_on 的样板代码;安全规则:仅必需参数、非 Context 标注、 非 *args/**kwargs、非 soft_depends_on、非自名。新增 px.graph(*specs) 可变 参数快捷构造;_is_context_annotation 重命名为公开 is_context_annotation; __init__/fileops/compose/task 文档示例统一现代化。1694 测试全绿,覆盖率 97.25%。
759 lines
24 KiB
Python
759 lines
24 KiB
Python
"""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() == []
|