228 lines
6.6 KiB
Python
228 lines
6.6 KiB
Python
"""LoopSpec 任务循环展开测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pyflowx as px
|
|
from pyflowx import Graph, LoopSpec, TaskSpec, task
|
|
|
|
|
|
def test_loop_range_expands_to_instances() -> None:
|
|
"""LoopSpec.range 展开为多实例,item 追加到 args。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item * 2
|
|
|
|
spec = TaskSpec("process", fn=process, loop=LoopSpec.range(3))
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert "process" not in graph.specs
|
|
assert set(graph.specs.keys()) == {"process_0", "process_1", "process_2"}
|
|
assert graph.specs["process_0"].args == (0,)
|
|
assert graph.specs["process_2"].args == (2,)
|
|
assert graph.specs["process_0"].loop is None
|
|
|
|
|
|
def test_loop_from_iterable() -> None:
|
|
"""LoopSpec.from_iterable 接受任意可迭代对象。"""
|
|
|
|
def echo(item: str) -> str:
|
|
return item
|
|
|
|
spec = TaskSpec("echo", fn=echo, loop=LoopSpec.from_iterable(["a", "b"]))
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert set(graph.specs.keys()) == {"echo_0", "echo_1"}
|
|
assert graph.specs["echo_0"].args == ("a",)
|
|
assert graph.specs["echo_1"].args == ("b",)
|
|
|
|
|
|
def test_loop_range_with_start_step() -> None:
|
|
"""LoopSpec.range 支持 start/stop/step 三参数(与 Python range 一致)。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
spec = TaskSpec("p", fn=process, loop=LoopSpec.range(0, 10, 2))
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert set(graph.specs.keys()) == {"p_0", "p_1", "p_2", "p_3", "p_4"}
|
|
assert graph.specs["p_0"].args == (0,)
|
|
assert graph.specs["p_4"].args == (8,)
|
|
|
|
|
|
def test_loop_range_two_args() -> None:
|
|
"""LoopSpec.range(start, stop) 两参数形式。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
spec = TaskSpec("p", fn=process, loop=LoopSpec.range(5, 8))
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert set(graph.specs.keys()) == {"p_0", "p_1", "p_2"}
|
|
assert graph.specs["p_0"].args == (5,)
|
|
assert graph.specs["p_2"].args == (7,)
|
|
|
|
|
|
def test_loop_custom_key_fn() -> None:
|
|
"""key_fn 自定义实例名。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
spec = TaskSpec(
|
|
"process",
|
|
fn=process,
|
|
loop=LoopSpec.from_iterable([10, 20], key_fn=lambda _i, item: f"process_{item}"),
|
|
)
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert set(graph.specs.keys()) == {"process_10", "process_20"}
|
|
assert graph.specs["process_10"].args == (10,)
|
|
|
|
|
|
def test_loop_dependency_rewrite() -> None:
|
|
"""其他任务引用 loop 原名时,自动重写为依赖所有展开实例。"""
|
|
|
|
def gen(item: int) -> int:
|
|
return item
|
|
|
|
def aggregate(gen: dict[str, int]) -> int:
|
|
return sum(gen.values())
|
|
|
|
gen_spec = TaskSpec("gen", fn=gen, loop=LoopSpec.range(3))
|
|
agg_spec = TaskSpec("agg", fn=aggregate, depends_on=("gen",))
|
|
graph = Graph.from_specs([gen_spec, agg_spec])
|
|
|
|
assert set(graph.specs["agg"].depends_on) == {"gen_0", "gen_1", "gen_2"}
|
|
|
|
|
|
def test_loop_soft_dependency_rewrite() -> None:
|
|
"""软依赖引用 loop 原名时也重写。"""
|
|
|
|
def gen(item: int) -> int:
|
|
return item
|
|
|
|
def consumer(gen: dict[str, int] | None = None) -> int:
|
|
return sum(gen.values()) if gen else 0
|
|
|
|
gen_spec = TaskSpec("gen", fn=gen, loop=LoopSpec.range(2))
|
|
cons_spec = TaskSpec("cons", fn=consumer, soft_depends_on=("gen",))
|
|
graph = Graph.from_specs([gen_spec, cons_spec])
|
|
|
|
assert set(graph.specs["cons"].soft_depends_on) == {"gen_0", "gen_1"}
|
|
|
|
|
|
def test_loop_chained_dependencies() -> None:
|
|
"""两个 loop 任务链式依赖,下游 loop 引用上游 loop 原名。"""
|
|
|
|
def stage1(item: int) -> int:
|
|
return item + 1
|
|
|
|
def stage2(stage1: int) -> int:
|
|
return stage1 * 2
|
|
|
|
s1 = TaskSpec("s1", fn=stage1, loop=LoopSpec.range(2))
|
|
s2 = TaskSpec("s2", fn=stage2, loop=LoopSpec.range(2), depends_on=("s1",))
|
|
graph = Graph.from_specs([s1, s2])
|
|
|
|
# s2 的每个实例应依赖 s1 的所有实例(因为 s2 引用 "s1" 原名)
|
|
assert set(graph.specs["s2_0"].depends_on) == {"s1_0", "s1_1"}
|
|
assert set(graph.specs["s2_1"].depends_on) == {"s1_0", "s1_1"}
|
|
|
|
|
|
def test_loop_add_method() -> None:
|
|
"""Graph.add 也支持 loop 展开。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
spec = TaskSpec("process", fn=process, loop=LoopSpec.range(2))
|
|
graph = Graph().add(spec)
|
|
|
|
assert set(graph.specs.keys()) == {"process_0", "process_1"}
|
|
|
|
|
|
def test_loop_execution() -> None:
|
|
"""loop 展开后能正常执行,结果注入下游。"""
|
|
|
|
def gen(item: int) -> int:
|
|
return item * 10
|
|
|
|
def aggregate(gen_0: int, gen_1: int, gen_2: int) -> int:
|
|
return gen_0 + gen_1 + gen_2
|
|
|
|
gen_spec = TaskSpec("gen", fn=gen, loop=LoopSpec.range(3))
|
|
agg_spec = TaskSpec("agg", fn=aggregate, depends_on=("gen",))
|
|
graph = Graph.from_specs([gen_spec, agg_spec])
|
|
|
|
report = px.run(graph, strategy="sequential")
|
|
assert report.success
|
|
assert report["agg"] == 0 + 10 + 20
|
|
|
|
|
|
def test_loop_with_task_decorator() -> None:
|
|
"""@task 装饰器支持 loop 参数。"""
|
|
|
|
@task
|
|
def base() -> int:
|
|
return 0
|
|
|
|
@task(loop=LoopSpec.range(2), depends_on=("base",))
|
|
def worker(base: int, item: int) -> int:
|
|
return base + item
|
|
|
|
graph = Graph.from_specs([base, worker])
|
|
assert set(graph.specs.keys()) == {"base", "worker_0", "worker_1"}
|
|
assert set(graph.specs["worker_0"].depends_on) == {"base"}
|
|
|
|
|
|
def test_loop_groups_recorded() -> None:
|
|
"""_loop_groups 记录原名到展开名的映射。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
spec = TaskSpec("p", fn=process, loop=LoopSpec.range(3))
|
|
graph = Graph.from_specs([spec])
|
|
|
|
assert graph._loop_groups == {"p": ["p_0", "p_1", "p_2"]}
|
|
|
|
|
|
def test_loop_empty_items_raises() -> None:
|
|
"""空 items 在 LoopSpec 构造时报错。"""
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="不能为空"):
|
|
LoopSpec.from_iterable([])
|
|
|
|
with pytest.raises(ValueError, match="不能为空"):
|
|
LoopSpec.range(0)
|
|
|
|
with pytest.raises(ValueError, match="不能为空"):
|
|
LoopSpec.range(5, 5) # start == stop, 空序列
|
|
|
|
|
|
def test_loop_preserves_other_fields() -> None:
|
|
"""loop 展开后保留原 spec 的其他字段(retry/tags 等)。"""
|
|
|
|
def process(item: int) -> int:
|
|
return item
|
|
|
|
retry = px.RetryPolicy(max_attempts=3)
|
|
spec = TaskSpec(
|
|
"p",
|
|
fn=process,
|
|
loop=LoopSpec.range(2),
|
|
retry=retry,
|
|
tags=("batch",),
|
|
priority=5,
|
|
)
|
|
graph = Graph.from_specs([spec])
|
|
|
|
for name in ("p_0", "p_1"):
|
|
s = graph.specs[name]
|
|
assert s.retry == retry
|
|
assert s.tags == ("batch",)
|
|
assert s.priority == 5
|