Files
pyflowx/tests/test_graph.py
T
zhou bd3651e337
CI / Lint, Typecheck & Test (push) Failing after 1m37s
feat: 改进 API 简洁性 — depends_on 自动推断 + px.graph() 快捷构造 + 文档现代化
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%。
2026-07-08 10:20:21 +08:00

759 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 保持 Noneoriginal_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 依赖 a1ref 末任务)
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() == []