Skip to content

Commit 7ba8963

Browse files
committed
chore: simple examples for task params
1 parent 0a22d9a commit 7ba8963

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from __future__ import annotations
2+
3+
from collections import defaultdict, deque
4+
from dataclasses import dataclass
5+
from typing import Dict, List, Sequence, Tuple
6+
7+
8+
@dataclass(frozen=True)
9+
class DagNode:
10+
name: str
11+
inputs: Tuple[str, ...] = ()
12+
output: str | None = None
13+
14+
15+
class Dag:
16+
"""Minimal DAG API with dependency validation and parallel batches."""
17+
18+
def __init__(self) -> None:
19+
self._nodes: Dict[str, DagNode] = {}
20+
self._parents: Dict[str, set[str]] = defaultdict(set)
21+
self._children: Dict[str, set[str]] = defaultdict(set)
22+
self._data_producers: Dict[str, str] = {}
23+
24+
def task(self, name: str, *, needs: Sequence[str] = (), produces: str | None = None) -> Dag:
25+
if name in self._nodes:
26+
raise ValueError(f"Task '{name}' already exists")
27+
28+
if produces and produces in self._data_producers:
29+
producer = self._data_producers[produces]
30+
raise ValueError(f"Data '{produces}' is already produced by '{producer}'")
31+
32+
node = DagNode(name=name, inputs=tuple(needs), output=produces)
33+
self._nodes[name] = node
34+
if produces:
35+
self._data_producers[produces] = name
36+
return self
37+
38+
def wire(self) -> Dag:
39+
"""Resolve data dependencies into task edges."""
40+
for node in self._nodes.values():
41+
for data_id in node.inputs:
42+
parent = self._data_producers.get(data_id)
43+
if parent is None:
44+
raise ValueError(
45+
f"Task '{node.name}' requires '{data_id}', but no upstream task produces it"
46+
)
47+
self._parents[node.name].add(parent)
48+
self._children[parent].add(node.name)
49+
50+
self._assert_acyclic()
51+
return self
52+
53+
def execution_batches(self) -> List[List[str]]:
54+
"""
55+
Return topological levels.
56+
Tasks in the same inner list can run in parallel.
57+
"""
58+
indegree = {name: len(self._parents[name]) for name in self._nodes}
59+
frontier = deque(sorted([n for n, d in indegree.items() if d == 0]))
60+
batches: List[List[str]] = []
61+
62+
while frontier:
63+
level: List[str] = list(frontier)
64+
frontier.clear()
65+
batches.append(level)
66+
67+
for task_name in level:
68+
for child in sorted(self._children[task_name]):
69+
indegree[child] -= 1
70+
if indegree[child] == 0:
71+
frontier.append(child)
72+
73+
total = sum(len(batch) for batch in batches)
74+
if total != len(self._nodes):
75+
raise ValueError("Graph contains a cycle")
76+
return batches
77+
78+
def edges(self) -> List[Tuple[str, str]]:
79+
out: List[Tuple[str, str]] = []
80+
for parent, children in sorted(self._children.items()):
81+
for child in sorted(children):
82+
out.append((parent, child))
83+
return out
84+
85+
def to_mermaid_mmd(self, direction: str = "LR") -> str:
86+
"""
87+
Export graph as Mermaid mmd text.
88+
Call this after `wire()` so task dependencies are resolved.
89+
"""
90+
lines: List[str] = [f"flowchart {direction}"]
91+
92+
for node_name, node in sorted(self._nodes.items()):
93+
node_lines = [node.name]
94+
if node.inputs:
95+
node_lines.append(f"needs: {', '.join(node.inputs)}")
96+
if node.output:
97+
node_lines.append(f"produces: {node.output}")
98+
label = "<br/>".join(node_lines)
99+
lines.append(f' {node_name}["{label}"]')
100+
101+
for parent, child in self.edges():
102+
lines.append(f" {parent} --> {child}")
103+
104+
return "\n".join(lines)
105+
106+
def _assert_acyclic(self) -> None:
107+
visited: set[str] = set()
108+
in_stack: set[str] = set()
109+
110+
def dfs(node_name: str) -> None:
111+
visited.add(node_name)
112+
in_stack.add(node_name)
113+
for child_name in self._children[node_name]:
114+
if child_name not in visited:
115+
dfs(child_name)
116+
elif child_name in in_stack:
117+
raise ValueError(f"Cycle detected at '{child_name}'")
118+
in_stack.remove(node_name)
119+
120+
for name in self._nodes:
121+
if name not in visited:
122+
dfs(name)
123+
124+
125+
def dag_example() -> Dag:
126+
"""
127+
Typical syntax:
128+
- split: one output consumed by several branches
129+
- join: one task requiring multiple inputs
130+
- final output: last task produces the sink artifact
131+
"""
132+
dag = (
133+
Dag()
134+
.task("load_users", produces="users")
135+
.task("load_orders", produces="orders")
136+
.task("clean_users", needs=("users",), produces="users_clean")
137+
.task("clean_orders", needs=("orders",), produces="orders_clean")
138+
.task("extract_features_a", needs=("users_clean","orders_clean"), produces="features_a")
139+
.task("extract_features_b", needs=("users_clean",), produces="features_b")
140+
.task(
141+
"join_user_order_features",
142+
needs=("features_a", "features_b", "orders_clean"),
143+
produces="joined_features",
144+
)
145+
.task("train_model", needs=("joined_features",), produces="model")
146+
.task("evaluate_model", needs=("model",), produces="report")
147+
.wire()
148+
)
149+
return dag
150+
151+
152+
if __name__ == "__main__":
153+
dag = dag_example()
154+
print("Edges:", dag.edges())
155+
print("Parallel batches:", dag.execution_batches())
156+
print("\nMermaid mmd:\n")
157+
print(dag.to_mermaid_mmd())
158+
159+

experiments/examples/src/kgpipe_examples/task_examples.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from kgpipe.common import TaskInput, TaskOutput
2+
from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType
23
from kgpipe_examples.config import ExtendedFormats
34
from kgpipe.common.registry import Registry
45

@@ -28,3 +29,18 @@ def pipe_task_remote(inputs: TaskInput, outputs: TaskOutput):
2829
outputs["output"].path.touch()
2930

3031

32+
@Registry.task(
33+
input_spec={"input": ExtendedFormats.SPECIAL2},
34+
output_spec={"output": ExtendedFormats.SPECIAL_KG},
35+
config_spec=ConfigurationDefinition(
36+
name="pipe_task_with_config_spec",
37+
description="Configuration specification for the pipe_task_with_config task",
38+
parameters=[
39+
Parameter(name="some_parameter", datatype=ParameterType.string, default_value="default", required=False)
40+
]
41+
)
42+
)
43+
def pipe_task_with_config(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile):
44+
# print config
45+
print(config)
46+
outputs["output"].path.touch()

0 commit comments

Comments
 (0)