taskflow-tr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
task_flow/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from .api import CompiledFunction, TransformedFunction, compile, transform
2
+ from .runtime import Executor, InlineExecutor, ProcessExecutor, ThreadExecutor
3
+
4
+ __all__ = [
5
+ "CompiledFunction",
6
+ "Executor",
7
+ "InlineExecutor",
8
+ "ProcessExecutor",
9
+ "ThreadExecutor",
10
+ "TransformedFunction",
11
+ "compile",
12
+ "transform",
13
+ ]
task_flow/api.py ADDED
@@ -0,0 +1,121 @@
1
+ import functools
2
+ from typing import Any, Callable, Optional
3
+
4
+ from task_flow.compiler import Compiler
5
+ from task_flow.ir import GraphIR
6
+ from task_flow.printer import get_printer
7
+ from task_flow.runtime import InlineExecutor, ProcessExecutor, ThreadExecutor
8
+
9
+ _MISSING = object()
10
+ _FORMATS = {"dict", "json", "mermaid", "graphviz"}
11
+ _EXECUTORS = {"inline", "thread", "process"}
12
+
13
+
14
+ class CompiledFunction:
15
+ def __init__(self, python_function: Callable[..., Any], default_format: str = "json"):
16
+ self.python_function = python_function
17
+ self._graph_ir = Compiler(python_function).compile()
18
+ self.default_format = _validate_format(default_format)
19
+ functools.update_wrapper(self, python_function)
20
+
21
+ @property
22
+ def graph_ir(self) -> GraphIR:
23
+ return self._graph_ir
24
+
25
+ def __str__(self) -> str:
26
+ return self.__format__("")
27
+
28
+ def __format__(self, format_spec: str) -> str:
29
+ format_name = self.default_format if format_spec == "" else _validate_format(format_spec)
30
+ rendered = get_printer(format_name).print(self.graph_ir)
31
+ return repr(rendered) if format_name == "dict" else rendered
32
+
33
+
34
+ class TransformedFunction:
35
+ def __init__(self, compiled: CompiledFunction, executor_name: str, workers: Optional[int]):
36
+ self.compiled = compiled
37
+ self.executor_name = executor_name
38
+ self.workers = workers
39
+ functools.update_wrapper(self, compiled.python_function)
40
+
41
+ @property
42
+ def graph_ir(self) -> GraphIR:
43
+ return self.compiled.graph_ir
44
+
45
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
46
+ executor = _create_executor(self.executor_name, self.workers)
47
+ with executor:
48
+ return executor.run(self.compiled, args=args, kwargs=kwargs)
49
+
50
+ def __str__(self) -> str:
51
+ return str(self.compiled)
52
+
53
+ def __format__(self, format_spec: str) -> str:
54
+ return self.compiled.__format__(format_spec)
55
+
56
+
57
+ def _validate_format(format_name: str) -> str:
58
+ if format_name not in _FORMATS:
59
+ raise ValueError(
60
+ "unknown graph format %r; expected one of: dict, json, mermaid, graphviz" % format_name
61
+ )
62
+ return format_name
63
+
64
+
65
+ def compile(function: Any = _MISSING, *, format: Any = _MISSING) -> Any:
66
+ if function is _MISSING:
67
+ if format is _MISSING:
68
+ raise TypeError("compile() requires format when called with parentheses")
69
+ format_name = _validate_format(format)
70
+
71
+ def decorator(target: Callable[..., Any]) -> CompiledFunction:
72
+ return CompiledFunction(target, default_format=format_name)
73
+
74
+ return decorator
75
+ if format is not _MISSING:
76
+ raise TypeError("use compile(format=...) as a decorator")
77
+ if not callable(function):
78
+ raise TypeError("compile expects a Python function")
79
+ return CompiledFunction(function, default_format="json")
80
+
81
+
82
+ def transform(
83
+ function: Any = _MISSING, *, executor: Any = _MISSING, workers: Optional[int] = None
84
+ ) -> Any:
85
+ if function is _MISSING:
86
+ if executor is _MISSING:
87
+ raise TypeError("transform() requires executor when called with parentheses")
88
+ executor_name = _validate_executor(executor, workers)
89
+
90
+ def decorator(target: Callable[..., Any]) -> TransformedFunction:
91
+ return TransformedFunction(CompiledFunction(target), executor_name, workers)
92
+
93
+ return decorator
94
+ if executor is not _MISSING:
95
+ raise TypeError("use transform(executor=...) as a decorator")
96
+ if workers is not None:
97
+ raise TypeError("workers requires an explicit executor")
98
+ if not callable(function):
99
+ raise TypeError("transform expects a Python function")
100
+ return TransformedFunction(CompiledFunction(function), "inline", None)
101
+
102
+
103
+ def _validate_executor(executor_name: str, workers: Optional[int]) -> str:
104
+ if executor_name not in _EXECUTORS:
105
+ raise ValueError(
106
+ "unknown executor %r; expected one of: inline, thread, process" % executor_name
107
+ )
108
+ if executor_name == "inline":
109
+ if workers is not None:
110
+ raise ValueError("inline executor does not accept workers")
111
+ elif not isinstance(workers, int) or isinstance(workers, bool) or workers <= 0:
112
+ raise ValueError("thread and process executors require positive integer workers")
113
+ return executor_name
114
+
115
+
116
+ def _create_executor(executor_name: str, workers: Optional[int]):
117
+ if executor_name == "inline":
118
+ return InlineExecutor()
119
+ if executor_name == "thread":
120
+ return ThreadExecutor(thread_num=workers) # type: ignore[arg-type]
121
+ return ProcessExecutor(process_num=workers) # type: ignore[arg-type]
@@ -0,0 +1,4 @@
1
+ from .builder import GraphBuilder
2
+ from .compiler import Compiler, UnsupportedSyntaxError
3
+
4
+ __all__ = ["Compiler", "GraphBuilder", "UnsupportedSyntaxError"]
@@ -0,0 +1,61 @@
1
+ import re
2
+ from collections import defaultdict
3
+ from typing import Any, Callable, Tuple
4
+
5
+ from task_flow.ir import GraphIR, NodeIR
6
+
7
+
8
+ class GraphBuilder:
9
+ def __init__(self, name: str):
10
+ self.name = name
11
+ self._nodes = [] # type: List[NodeIR]
12
+ self._inputs = [] # type: List[Tuple[str, str]]
13
+ self._outputs = () # type: Tuple[str, ...]
14
+ self._output_kind = "none"
15
+ self._counts = defaultdict(int) # type: DefaultDict[str, int]
16
+
17
+ @staticmethod
18
+ def _slug(value: str) -> str:
19
+ value = re.sub(r"[^0-9A-Za-z_.]+", "_", value).strip("_")
20
+ return value or "anonymous"
21
+
22
+ def _next_id(self, kind: str, name: str) -> str:
23
+ base = "%s.%s" % (kind, self._slug(name))
24
+ self._counts[base] += 1
25
+ occurrence = self._counts[base]
26
+ return base if occurrence == 1 else "%s.%s" % (base, occurrence)
27
+
28
+ def add_input(self, name: str) -> str:
29
+ node_id = self._next_id("input", name)
30
+ self._nodes.append(NodeIR(node_id, "input", name, None, ()))
31
+ self._inputs.append((name, node_id))
32
+ return node_id
33
+
34
+ def add_constant(self, value: Any) -> str:
35
+ node_id = self._next_id("constant", type(value).__name__)
36
+ self._nodes.append(NodeIR(node_id, "constant", repr(value), None, (), value=value))
37
+ return node_id
38
+
39
+ def add_call(
40
+ self, operation: Callable[..., Any], dependencies: Tuple[str, ...], name: str = ""
41
+ ) -> str:
42
+ operation_name = name or getattr(operation, "__qualname__", operation.__class__.__name__)
43
+ module = getattr(operation, "__module__", "")
44
+ display_name = "%s.%s" % (module, operation_name) if module else operation_name
45
+ node_id = self._next_id("call", display_name)
46
+ self._nodes.append(NodeIR(node_id, "call", display_name, operation, dependencies))
47
+ return node_id
48
+
49
+ def set_outputs(self, outputs: Tuple[str, ...], output_kind: str) -> None:
50
+ self._outputs = outputs
51
+ self._output_kind = output_kind
52
+
53
+ def build(self) -> GraphIR:
54
+ return GraphIR(
55
+ "1.0",
56
+ self.name,
57
+ tuple(self._nodes),
58
+ tuple(self._inputs),
59
+ self._outputs,
60
+ self._output_kind,
61
+ )
@@ -0,0 +1,161 @@
1
+ import ast
2
+ import inspect
3
+ import operator
4
+ import textwrap
5
+ from typing import Any, Callable
6
+
7
+ from task_flow.ir import GraphIR
8
+
9
+ from .builder import GraphBuilder
10
+
11
+
12
+ class UnsupportedSyntaxError(ValueError):
13
+ pass
14
+
15
+
16
+ _BINARY_OPERATIONS = {
17
+ ast.Add: operator.add,
18
+ ast.Sub: operator.sub,
19
+ ast.Mult: operator.mul,
20
+ ast.Div: operator.truediv,
21
+ ast.FloorDiv: operator.floordiv,
22
+ }
23
+
24
+
25
+ class Compiler(ast.NodeVisitor):
26
+ def __init__(self, function: Callable[..., Any]):
27
+ self.function = function
28
+ self.builder = GraphBuilder(function.__name__)
29
+ self.visible = {} # type: Dict[str, str]
30
+ self.environment = dict(function.__globals__)
31
+ closure = inspect.getclosurevars(function)
32
+ self.environment.update(closure.globals)
33
+ self.environment.update(closure.nonlocals)
34
+ self.environment.update(closure.builtins)
35
+ self._returned = False
36
+
37
+ def compile(self) -> GraphIR:
38
+ try:
39
+ source = textwrap.dedent(inspect.getsource(self.function))
40
+ except (OSError, TypeError) as exc:
41
+ raise ValueError(
42
+ "cannot read source for function %s" % self.function.__qualname__
43
+ ) from exc
44
+ module = ast.parse(source)
45
+ function_node = next(
46
+ (node for node in module.body if isinstance(node, ast.FunctionDef)), None
47
+ )
48
+ if function_node is None:
49
+ raise UnsupportedSyntaxError("only regular Python functions are supported")
50
+ self.visit(function_node)
51
+ if not self._returned:
52
+ self.builder.set_outputs((), "none")
53
+ return self.builder.build()
54
+
55
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
56
+ if node.args.posonlyargs or node.args.kwonlyargs or node.args.vararg or node.args.kwarg:
57
+ raise UnsupportedSyntaxError(
58
+ "positional-only, keyword-only and variadic parameters are not supported"
59
+ )
60
+ if node.args.defaults or node.args.kw_defaults:
61
+ raise UnsupportedSyntaxError("default parameters are not supported")
62
+ for argument in node.args.args:
63
+ self.visible[argument.arg] = self.builder.add_input(argument.arg)
64
+ for statement in node.body:
65
+ self.visit(statement)
66
+ if self._returned:
67
+ break
68
+
69
+ def generic_visit(self, node: ast.AST) -> None:
70
+ raise UnsupportedSyntaxError("unsupported syntax: %s" % type(node).__name__)
71
+
72
+ def visit_Assign(self, node: ast.Assign) -> None:
73
+ if len(node.targets) != 1:
74
+ raise UnsupportedSyntaxError("chained assignment is not supported")
75
+ target = node.targets[0]
76
+ if isinstance(target, (ast.Tuple, ast.List)):
77
+ if not isinstance(node.value, type(target)):
78
+ raise UnsupportedSyntaxError("unpacking requires a matching literal")
79
+ if len(target.elts) != len(node.value.elts):
80
+ raise UnsupportedSyntaxError("unpacking target size mismatch")
81
+ for target_item, value_item in zip(target.elts, node.value.elts):
82
+ self._assign_name(target_item, self._compile_expression(value_item))
83
+ return
84
+ self._assign_name(target, self._compile_expression(node.value))
85
+
86
+ def _assign_name(self, target: ast.AST, node_id: str) -> None:
87
+ if not isinstance(target, ast.Name):
88
+ raise UnsupportedSyntaxError("only name assignment is supported")
89
+ self.visible[target.id] = node_id
90
+
91
+ def visit_Expr(self, node: ast.Expr) -> None:
92
+ self._compile_expression(node.value)
93
+
94
+ def visit_Return(self, node: ast.Return) -> None:
95
+ if self._returned:
96
+ raise UnsupportedSyntaxError("multiple return statements are not supported")
97
+ self._returned = True
98
+ if node.value is None or (
99
+ isinstance(node.value, ast.Constant) and node.value.value is None
100
+ ):
101
+ self.builder.set_outputs((), "none")
102
+ elif isinstance(node.value, ast.Tuple):
103
+ self.builder.set_outputs(
104
+ tuple(self._compile_expression(item) for item in node.value.elts), "tuple"
105
+ )
106
+ elif isinstance(node.value, ast.List):
107
+ self.builder.set_outputs(
108
+ tuple(self._compile_expression(item) for item in node.value.elts), "list"
109
+ )
110
+ else:
111
+ self.builder.set_outputs((self._compile_expression(node.value),), "single")
112
+
113
+ def _compile_expression(self, node: ast.AST) -> str:
114
+ if isinstance(node, ast.Name):
115
+ if node.id not in self.visible:
116
+ raise UnsupportedSyntaxError("unknown local name: %s" % node.id)
117
+ return self.visible[node.id]
118
+ if isinstance(node, ast.Constant):
119
+ return self.builder.add_constant(node.value)
120
+ if isinstance(node, ast.BinOp):
121
+ operation = _BINARY_OPERATIONS.get(type(node.op))
122
+ if operation is None:
123
+ raise UnsupportedSyntaxError(
124
+ "unsupported binary operation: %s" % type(node.op).__name__
125
+ )
126
+ return self.builder.add_call(
127
+ operation,
128
+ (self._compile_expression(node.left), self._compile_expression(node.right)),
129
+ )
130
+ if isinstance(node, ast.Call):
131
+ return self._compile_call(node)
132
+ raise UnsupportedSyntaxError("unsupported expression: %s" % type(node).__name__)
133
+
134
+ def _compile_call(self, node: ast.Call) -> str:
135
+ if node.keywords:
136
+ raise UnsupportedSyntaxError("keyword call arguments are not supported")
137
+ function = self._resolve_callable(node.func)
138
+ return self.builder.add_call(
139
+ function, tuple(self._compile_expression(item) for item in node.args)
140
+ )
141
+
142
+ def _resolve_callable(self, node: ast.AST) -> Callable[..., Any]:
143
+ value = (
144
+ self.environment.get(node.id)
145
+ if isinstance(node, ast.Name)
146
+ else self._resolve_value(node)
147
+ if isinstance(node, ast.Attribute)
148
+ else None
149
+ )
150
+ if not callable(value):
151
+ raise UnsupportedSyntaxError("cannot resolve callable from source")
152
+ return value
153
+
154
+ def _resolve_value(self, node: ast.AST) -> Any:
155
+ if isinstance(node, ast.Name):
156
+ if node.id not in self.environment:
157
+ raise UnsupportedSyntaxError("unknown global name: %s" % node.id)
158
+ return self.environment[node.id]
159
+ if isinstance(node, ast.Attribute):
160
+ return getattr(self._resolve_value(node.value), node.attr)
161
+ raise UnsupportedSyntaxError("unsupported callable reference")
@@ -0,0 +1,3 @@
1
+ from .graph import GraphIR, NodeIR
2
+
3
+ __all__ = ["GraphIR", "NodeIR"]
task_flow/ir/graph.py ADDED
@@ -0,0 +1,68 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Callable, Dict, Iterator, Optional, Tuple
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class NodeIR:
7
+ id: str
8
+ kind: str
9
+ name: str
10
+ operation: Optional[Callable[..., Any]]
11
+ dependencies: Tuple[str, ...]
12
+ value: Any = None
13
+
14
+ def __post_init__(self) -> None:
15
+ if self.kind not in {"input", "constant", "call"}:
16
+ raise ValueError("unknown node kind: %s" % self.kind)
17
+ if self.kind == "call" and self.operation is None:
18
+ raise ValueError("call node %s requires an operation" % self.id)
19
+ if self.kind != "call" and self.operation is not None:
20
+ raise ValueError("%s node %s cannot define an operation" % (self.kind, self.id))
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class GraphIR:
25
+ ir_version: str
26
+ name: str
27
+ nodes: Tuple[NodeIR, ...]
28
+ inputs: Tuple[Tuple[str, str], ...]
29
+ outputs: Tuple[str, ...]
30
+ output_kind: str
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.ir_version != "1.0":
34
+ raise ValueError("unsupported GraphIR version: %s" % self.ir_version)
35
+ if self.output_kind not in {"none", "single", "tuple", "list"}:
36
+ raise ValueError("unknown output kind: %s" % self.output_kind)
37
+ node_map = self.node_map
38
+ if len(node_map) != len(self.nodes):
39
+ raise ValueError("GraphIR node IDs must be unique")
40
+ for node in self.nodes:
41
+ for dependency in node.dependencies:
42
+ if dependency not in node_map:
43
+ raise ValueError("node %s depends on missing node %s" % (node.id, dependency))
44
+ input_ids = []
45
+ input_names = []
46
+ for name, node_id in self.inputs:
47
+ if name in input_names:
48
+ raise ValueError("duplicate input name: %s" % name)
49
+ if node_id in input_ids:
50
+ raise ValueError("duplicate input node: %s" % node_id)
51
+ if node_id not in node_map or node_map[node_id].kind != "input":
52
+ raise ValueError("invalid input node: %s" % node_id)
53
+ input_names.append(name)
54
+ input_ids.append(node_id)
55
+ for output in self.outputs:
56
+ if output not in node_map:
57
+ raise ValueError("invalid output node: %s" % output)
58
+ if self.output_kind == "single" and len(self.outputs) != 1:
59
+ raise ValueError("single output requires one node")
60
+ if self.output_kind == "none" and self.outputs:
61
+ raise ValueError("none output cannot contain output nodes")
62
+
63
+ @property
64
+ def node_map(self) -> Dict[str, NodeIR]:
65
+ return {node.id: node for node in self.nodes}
66
+
67
+ def __iter__(self) -> Iterator[NodeIR]:
68
+ return iter(self.nodes)
@@ -0,0 +1,3 @@
1
+ from .printer import DictPrinter, GraphvizPrinter, JsonPrinter, MermaidPrinter, get_printer
2
+
3
+ __all__ = ["DictPrinter", "GraphvizPrinter", "JsonPrinter", "MermaidPrinter", "get_printer"]
@@ -0,0 +1,112 @@
1
+ import json
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any, Dict
4
+
5
+ from task_flow.ir import GraphIR, NodeIR
6
+
7
+
8
+ def _operation_name(node: NodeIR) -> Any:
9
+ if node.operation is None:
10
+ return None
11
+ module = getattr(node.operation, "__module__", "")
12
+ name = getattr(node.operation, "__qualname__", node.operation.__class__.__name__)
13
+ return "%s.%s" % (module, name) if module else name
14
+
15
+
16
+ class Printer(ABC):
17
+ @abstractmethod
18
+ def print(self, graph: GraphIR) -> Any:
19
+ raise NotImplementedError
20
+
21
+
22
+ class DictPrinter(Printer):
23
+ def print(self, graph: GraphIR) -> Dict[str, Any]:
24
+ nodes = []
25
+ for node in graph.nodes:
26
+ item = {
27
+ "id": node.id,
28
+ "kind": node.kind,
29
+ "name": node.name,
30
+ "dependencies": list(node.dependencies),
31
+ }
32
+ operation = _operation_name(node)
33
+ if operation is not None:
34
+ item["operation"] = operation
35
+ if node.kind == "constant":
36
+ item["value"] = node.value
37
+ nodes.append(item)
38
+ return {
39
+ "ir_version": graph.ir_version,
40
+ "name": graph.name,
41
+ "inputs": [{"name": name, "node": node_id} for name, node_id in graph.inputs],
42
+ "outputs": list(graph.outputs),
43
+ "output_kind": graph.output_kind,
44
+ "nodes": nodes,
45
+ }
46
+
47
+
48
+ class JsonPrinter(Printer):
49
+ def print(self, graph: GraphIR) -> str:
50
+ return json.dumps(DictPrinter().print(graph), indent=2, ensure_ascii=False, default=repr)
51
+
52
+
53
+ def _mermaid_text(value: str) -> str:
54
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
55
+
56
+
57
+ class MermaidPrinter(Printer):
58
+ def print(self, graph: GraphIR) -> str:
59
+ lines = ["flowchart TD"]
60
+ for index, node in enumerate(graph.nodes):
61
+ alias = "n%s" % index
62
+ shape = (
63
+ '(["%s"])'
64
+ if node.kind == "input"
65
+ else '{{"%s"}}'
66
+ if node.kind == "constant"
67
+ else '["%s"]'
68
+ )
69
+ lines.append(" %s%s" % (alias, shape % _mermaid_text(node.name)))
70
+ aliases = {node.id: "n%s" % index for index, node in enumerate(graph.nodes)}
71
+ for node in graph.nodes:
72
+ for dependency in node.dependencies:
73
+ lines.append(" %s --> %s" % (aliases[dependency], aliases[node.id]))
74
+ return "\n".join(lines)
75
+
76
+
77
+ def _dot_text(value: str) -> str:
78
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
79
+
80
+
81
+ class GraphvizPrinter(Printer):
82
+ def print(self, graph: GraphIR) -> str:
83
+ lines = ['digraph "%s" {' % _dot_text(graph.name)]
84
+ aliases = {node.id: "n%s" % index for index, node in enumerate(graph.nodes)}
85
+ shapes = {"input": "oval", "constant": "diamond", "call": "box"}
86
+ for node in graph.nodes:
87
+ lines.append(
88
+ ' %s [label="%s", shape=%s];'
89
+ % (aliases[node.id], _dot_text(node.name), shapes[node.kind])
90
+ )
91
+ for node in graph.nodes:
92
+ for dependency in node.dependencies:
93
+ lines.append(" %s -> %s;" % (aliases[dependency], aliases[node.id]))
94
+ lines.append("}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ _PRINTERS = {
99
+ "dict": DictPrinter,
100
+ "json": JsonPrinter,
101
+ "mermaid": MermaidPrinter,
102
+ "graphviz": GraphvizPrinter,
103
+ }
104
+
105
+
106
+ def get_printer(format_name: str) -> Printer:
107
+ try:
108
+ return _PRINTERS[format_name]()
109
+ except KeyError as exc:
110
+ raise ValueError(
111
+ "unknown graph format %r; expected one of: %s" % (format_name, ", ".join(_PRINTERS))
112
+ ) from exc
@@ -0,0 +1,3 @@
1
+ from .executor import Executor, InlineExecutor, ProcessExecutor, ThreadExecutor
2
+
3
+ __all__ = ["Executor", "InlineExecutor", "ProcessExecutor", "ThreadExecutor"]
@@ -0,0 +1,140 @@
1
+ import multiprocessing
2
+ from abc import ABC, abstractmethod
3
+ from concurrent.futures import (
4
+ FIRST_COMPLETED,
5
+ Future,
6
+ ProcessPoolExecutor,
7
+ ThreadPoolExecutor,
8
+ wait,
9
+ )
10
+ from typing import Any, Dict, Optional, Tuple
11
+
12
+ from task_flow.ir import GraphIR, NodeIR
13
+
14
+ __all__ = ["Executor", "InlineExecutor", "ProcessExecutor", "ThreadExecutor"]
15
+
16
+
17
+ class Executor(ABC):
18
+ def __enter__(self) -> "Executor":
19
+ return self
20
+
21
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
22
+ self.close()
23
+ return False
24
+
25
+ def close(self) -> None:
26
+ pass
27
+
28
+ def run(
29
+ self, function: Any, args: Tuple[Any, ...] = (), kwargs: Optional[Dict[str, Any]] = None
30
+ ) -> Any:
31
+ if not hasattr(function, "graph_ir") or not hasattr(function, "python_function"):
32
+ raise TypeError("Executor.run expects a CompiledFunction")
33
+ kwargs = {} if kwargs is None else dict(kwargs)
34
+ bound = __import__("inspect").signature(function.python_function).bind(*args, **kwargs)
35
+ graph = function.graph_ir # type: GraphIR
36
+ values = {} # type: Dict[str, Any]
37
+ for name, node_id in graph.inputs:
38
+ values[node_id] = bound.arguments[name]
39
+ for node in graph.nodes:
40
+ if node.kind == "constant":
41
+ values[node.id] = node.value
42
+
43
+ calls = [node for node in graph.nodes if node.kind == "call"]
44
+ waiting = {
45
+ node.id: sum(dependency not in values for dependency in node.dependencies)
46
+ for node in calls
47
+ }
48
+ children = {} # type: Dict[str, list]
49
+ for node in calls:
50
+ for dependency in node.dependencies:
51
+ children.setdefault(dependency, []).append(node.id)
52
+ node_map = graph.node_map
53
+ pending = {} # type: Dict[Future, NodeIR]
54
+
55
+ def submit(node: NodeIR) -> None:
56
+ inputs = tuple(values[dependency] for dependency in node.dependencies)
57
+ pending[self._submit(node, inputs)] = node
58
+
59
+ for node in calls:
60
+ if waiting[node.id] == 0:
61
+ submit(node)
62
+
63
+ completed = 0
64
+ while pending:
65
+ future = self._wait_any(tuple(pending))
66
+ node = pending.pop(future)
67
+ values[node.id] = future.result()
68
+ completed += 1
69
+ for child_id in children.get(node.id, ()):
70
+ waiting[child_id] -= 1
71
+ if waiting[child_id] == 0:
72
+ submit(node_map[child_id])
73
+
74
+ if completed != len(calls):
75
+ blocked = sorted(node.id for node in calls if node.id not in values)
76
+ raise ValueError("GraphIR contains a cycle or unresolved dependencies: %s" % blocked)
77
+ return self._build_result(graph, values)
78
+
79
+ @staticmethod
80
+ def _build_result(graph: GraphIR, values: Dict[str, Any]) -> Any:
81
+ results = tuple(values[node_id] for node_id in graph.outputs)
82
+ if graph.output_kind == "none":
83
+ return None
84
+ if graph.output_kind == "single":
85
+ return results[0]
86
+ if graph.output_kind == "list":
87
+ return list(results)
88
+ return results
89
+
90
+ @abstractmethod
91
+ def _submit(self, node: NodeIR, inputs: Tuple[Any, ...]) -> Future:
92
+ raise NotImplementedError
93
+
94
+ @abstractmethod
95
+ def _wait_any(self, futures: Tuple[Future, ...]) -> Future:
96
+ raise NotImplementedError
97
+
98
+
99
+ class InlineExecutor(Executor):
100
+ def _submit(self, node: NodeIR, inputs: Tuple[Any, ...]) -> Future:
101
+ future = Future()
102
+ try:
103
+ future.set_result(node.operation(*inputs)) # type: ignore[misc]
104
+ except BaseException as exc:
105
+ future.set_exception(exc)
106
+ return future
107
+
108
+ def _wait_any(self, futures: Tuple[Future, ...]) -> Future:
109
+ return futures[0]
110
+
111
+
112
+ class _PoolExecutor(Executor):
113
+ pool = None
114
+
115
+ def _submit(self, node: NodeIR, inputs: Tuple[Any, ...]) -> Future:
116
+ return self.pool.submit(node.operation, *inputs)
117
+
118
+ def _wait_any(self, futures: Tuple[Future, ...]) -> Future:
119
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
120
+ return next(iter(done))
121
+
122
+ def close(self) -> None:
123
+ if self.pool is not None:
124
+ self.pool.shutdown(wait=True)
125
+
126
+
127
+ class ThreadExecutor(_PoolExecutor):
128
+ def __init__(self, thread_num: int):
129
+ if thread_num <= 0:
130
+ raise ValueError("thread_num must be positive")
131
+ self.pool = ThreadPoolExecutor(max_workers=thread_num)
132
+
133
+
134
+ class ProcessExecutor(_PoolExecutor):
135
+ def __init__(self, process_num: int):
136
+ if process_num <= 0:
137
+ raise ValueError("process_num must be positive")
138
+ methods = multiprocessing.get_all_start_methods()
139
+ context = multiprocessing.get_context("fork") if "fork" in methods else None
140
+ self.pool = ProcessPoolExecutor(max_workers=process_num, mp_context=context)
@@ -0,0 +1,345 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskflow-tr
3
+ Version: 0.1.0
4
+ Summary: Compile Python functions into GraphIR DAGs and execute them inline, with threads, or with processes.
5
+ Author-email: gaoxinge <gaoxx5@gmail.com>
6
+ Maintainer-email: gaoxinge <gaoxx5@gmail.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 gaoxinge
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Project-URL: Homepage, https://github.com/gaoxinge/task_flow
30
+ Project-URL: Repository, https://github.com/gaoxinge/task_flow
31
+ Project-URL: Issues, https://github.com/gaoxinge/task_flow/issues
32
+ Project-URL: Documentation, https://github.com/gaoxinge/task_flow#readme
33
+ Keywords: dag,graph-ir,workflow,concurrency,threading,multiprocessing
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.8
40
+ Classifier: Programming Language :: Python :: 3.9
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Programming Language :: Python :: 3.14
46
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
47
+ Classifier: Topic :: System :: Distributed Computing
48
+ Requires-Python: >=3.8
49
+ Description-Content-Type: text/markdown
50
+ License-File: LICENSE
51
+ Provides-Extra: examples
52
+ Requires-Dist: flask>=2; extra == "examples"
53
+ Requires-Dist: grpcio>=1.48; extra == "examples"
54
+ Requires-Dist: grpcio-tools>=1.48; extra == "examples"
55
+ Requires-Dist: protobuf>=3.20; extra == "examples"
56
+ Requires-Dist: requests>=2; extra == "examples"
57
+ Dynamic: license-file
58
+
59
+ # taskflow-tr
60
+
61
+ taskflow-tr 将一小部分 Python 函数编译成静态 `GraphIR`,可打印计算图,也可使用串行、线程池或进程池执行。PyPI 发行名是 `taskflow-tr`,Python 导入名是 `task_flow`。
62
+
63
+ ## 环境与安装
64
+
65
+ 项目最低支持 Python 3.8。通过 PyPI 安装核心包:
66
+
67
+ ```bash
68
+ pip install taskflow-tr
69
+ ```
70
+
71
+ 如需运行 README 中的 HTTP 和 gRPC 集成示例:
72
+
73
+ ```bash
74
+ pip install "taskflow-tr[examples]"
75
+ ```
76
+
77
+ 项目开发环境使用 [uv](https://docs.astral.sh/uv/) 管理:
78
+
79
+ ```bash
80
+ uv sync --all-groups
81
+ ```
82
+
83
+ ## 直接执行函数
84
+
85
+ `@transform` 默认使用 `InlineExecutor`:
86
+
87
+ ```python
88
+ from task_flow import transform
89
+
90
+
91
+ @transform
92
+ def add(a, b):
93
+ return a + b
94
+
95
+
96
+ assert add(2, b=1) == 3
97
+ ```
98
+
99
+ 显式使用线程池或进程池时必须指定工作数量:
100
+
101
+ ```python
102
+ @transform(executor="thread", workers=4)
103
+ def threaded_add(a, b):
104
+ return a + b
105
+
106
+
107
+ @transform(executor="process", workers=4)
108
+ def process_add(a, b):
109
+ return a + b
110
+ ```
111
+
112
+ `@transform()` 是非法形式;带括号时必须显式传入 `executor`。
113
+
114
+ ## 编译和打印 GraphIR
115
+
116
+ `@compile` 只编译函数,不绑定执行策略。`print()` 默认输出 JSON:
117
+
118
+ ```python
119
+ from task_flow import compile
120
+
121
+
122
+ @compile
123
+ def add(a, b):
124
+ return a + b
125
+
126
+
127
+ print(add)
128
+ print(format(add, "dict"))
129
+ print(format(add, "mermaid"))
130
+ print(format(add, "graphviz"))
131
+ ```
132
+
133
+ 也可以在装饰时改变默认打印格式:
134
+
135
+ ```python
136
+ @compile(format="mermaid")
137
+ def workflow(a, b):
138
+ return a + b
139
+
140
+
141
+ print(workflow)
142
+ ```
143
+
144
+ 支持 `dict`、`json`、`mermaid` 和 `graphviz` 四种格式。Graphviz 格式返回 DOT 源码,不要求 Runtime 安装 Graphviz。
145
+
146
+ `@compile()` 是非法形式;带括号时必须显式传入 `format`。
147
+
148
+ ## 显式选择 Executor
149
+
150
+ 编译结果可以被不同 Executor 重复执行,无需重新编译:
151
+
152
+ ```python
153
+ from task_flow import InlineExecutor, ProcessExecutor, ThreadExecutor
154
+
155
+
156
+ with InlineExecutor() as executor:
157
+ assert executor.run(add, args=(2,), kwargs={"b": 1}) == 3
158
+
159
+ with ThreadExecutor(thread_num=4) as executor:
160
+ assert executor.run(add, args=(2, 1)) == 3
161
+
162
+ with ProcessExecutor(process_num=4) as executor:
163
+ assert executor.run(add, args=(2, 1)) == 3
164
+ ```
165
+
166
+ 线程执行适合 I/O 或会释放 GIL 的扩展调用;进程执行适合 CPU 密集型 Python 代码。进程池中的调用函数与参数必须能被 `pickle` 序列化,应优先使用模块顶层命名函数,避免 lambda 和局部函数。
167
+
168
+ ## 完整示例
169
+
170
+ ### 并行计算与 Graphviz 输出
171
+
172
+ 下面的计算图包含四个相互独立的运算节点,线程 Executor 可以并发执行它们:
173
+
174
+ ```python
175
+ import time
176
+ from operator import add, floordiv, mul, sub
177
+
178
+ from task_flow import compile, transform
179
+
180
+
181
+ def delayed(operation, x, y):
182
+ time.sleep(0.1)
183
+ return operation(x, y)
184
+
185
+
186
+ def add0(x, y):
187
+ return delayed(add, x, y)
188
+
189
+
190
+ def sub0(x, y):
191
+ return delayed(sub, x, y)
192
+
193
+
194
+ def mul0(x, y):
195
+ return delayed(mul, x, y)
196
+
197
+
198
+ def div0(x, y):
199
+ return delayed(floordiv, x, y)
200
+
201
+
202
+ @transform(executor="thread", workers=4)
203
+ def compute_graph(a, b):
204
+ result_add = add0(a, b)
205
+ result_sub = sub0(a, b)
206
+ result_mul = mul0(a, b)
207
+ result_div = div0(a, b)
208
+ return result_add, result_sub, result_mul, result_div
209
+
210
+
211
+ assert compute_graph(2, 1) == (3, 1, 2, 2)
212
+
213
+
214
+ @compile(format="graphviz")
215
+ def printable_graph(a, b):
216
+ result_add = a + b
217
+ result_sub = a - b
218
+ result_mul = a * b
219
+ result_div = a // b
220
+ return result_add, result_sub, result_mul, result_div
221
+
222
+
223
+ print(printable_graph) # 输出 DOT 源码
224
+ ```
225
+
226
+ ### 集成 HTTP 服务
227
+
228
+ HTTP 和 gRPC 只是 `TransformedFunction` 的调用入口,调度逻辑仍由 taskflow-tr 管理。安装示例依赖:
229
+
230
+ ```bash
231
+ uv sync --extra examples
232
+ ```
233
+
234
+ 使用 Flask 暴露计算接口:
235
+
236
+ ```python
237
+ from flask import Flask, jsonify, request
238
+
239
+
240
+ app = Flask(__name__)
241
+
242
+
243
+ @app.post("/compute")
244
+ def compute():
245
+ inputs = request.get_json()
246
+ x, y, z, w = compute_graph(inputs["x"], inputs["y"])
247
+ return jsonify({"x": x, "y": y, "z": z, "w": w})
248
+
249
+
250
+ if __name__ == "__main__":
251
+ app.run(host="0.0.0.0", port=8000)
252
+ ```
253
+
254
+ 请求示例:
255
+
256
+ ```python
257
+ import requests
258
+
259
+
260
+ response = requests.post(
261
+ "http://127.0.0.1:8000/compute",
262
+ json={"x": 2, "y": 1},
263
+ timeout=30,
264
+ )
265
+ assert response.json() == {"x": 3, "y": 1, "z": 2, "w": 2}
266
+ ```
267
+
268
+ ### 集成 gRPC 服务
269
+
270
+ 先定义 `compute.proto`:
271
+
272
+ ```proto
273
+ syntax = "proto3";
274
+ package example;
275
+
276
+ message Inputs {
277
+ int32 x = 1;
278
+ int32 y = 2;
279
+ }
280
+
281
+ message Outputs {
282
+ int32 x = 1;
283
+ int32 y = 2;
284
+ int32 z = 3;
285
+ int32 w = 4;
286
+ }
287
+
288
+ service App {
289
+ rpc Compute (Inputs) returns (Outputs);
290
+ }
291
+ ```
292
+
293
+ 生成 Python 代码:
294
+
295
+ ```bash
296
+ uv run python -m grpc_tools.protoc \
297
+ -I. \
298
+ --python_out=. \
299
+ --grpc_python_out=. \
300
+ compute.proto
301
+ ```
302
+
303
+ 在生成的服务接口中调用同一个 `compute_graph`:
304
+
305
+ ```python
306
+ from concurrent import futures
307
+
308
+ import grpc
309
+ import compute_pb2
310
+ import compute_pb2_grpc
311
+
312
+
313
+ class App(compute_pb2_grpc.AppServicer):
314
+ def Compute(self, inputs, context):
315
+ x, y, z, w = compute_graph(inputs.x, inputs.y)
316
+ return compute_pb2.Outputs(x=x, y=y, z=z, w=w)
317
+
318
+
319
+ server = grpc.server(futures.ThreadPoolExecutor(max_workers=3))
320
+ compute_pb2_grpc.add_AppServicer_to_server(App(), server)
321
+ server.add_insecure_port("0.0.0.0:8000")
322
+ server.start()
323
+ server.wait_for_termination()
324
+ ```
325
+
326
+ ## V1 编译范围
327
+
328
+ V1 支持普通位置或关键字参数、常量、简单赋值、普通函数调用、`+`、`-`、`*`、`/`、`//`,以及 `None`、单值、tuple 和 list 返回。条件、循环、递归、可变参数、默认参数和关键字调用参数尚未纳入 V1。
329
+
330
+ 总体流程:
331
+
332
+ ```text
333
+ Python Source -> Python AST -> GraphIR -> Executor -> Result
334
+ |
335
+ +-> Printer -> dict / JSON / Mermaid / Graphviz
336
+ ```
337
+
338
+ V1 不包含 Planner、ExecutablePlanIR、Dask、Ray 或 MPI 后端。设计说明见 [`docs/v1/dev.md`](docs/v1/dev.md)。
339
+
340
+ ## 开发
341
+
342
+ ```bash
343
+ uv run pytest
344
+ uv run python -m benchmarks.benchmark_executor
345
+ ```
@@ -0,0 +1,16 @@
1
+ task_flow/__init__.py,sha256=jw7xigbwdpbVjGB8RP-EYPJSsVU0KUxzNgFLjbiMcFk,335
2
+ task_flow/api.py,sha256=Zv7wfgqEcfOZWJWumORSGd43w3AzjYIBCdKZ30zqfUA,4642
3
+ task_flow/compiler/__init__.py,sha256=g2_LuWHt5rqbKaaVbDO2iJKQ5fD6EcgrWbfXJeQ6pgU,155
4
+ task_flow/compiler/builder.py,sha256=ULLO6Tgu08yeRwGxda08qQqOcDW4zp6guzhctKdsPjI,2249
5
+ task_flow/compiler/compiler.py,sha256=BrM6225_365fSrTTmepnloRT7geyF9kY8ztXTM7ChCw,6652
6
+ task_flow/ir/__init__.py,sha256=hHuEgw5BDUGO9iso2p0C_0MATIbd2uZLyFIFCaznO0U,68
7
+ task_flow/ir/graph.py,sha256=kWA0CWvLp0zb5oCl2Luojttx80YGong-XBmRZ4_eF8o,2721
8
+ task_flow/printer/__init__.py,sha256=DyyN0Chjp6Fphh_wgSivXbUlSA0m5roCSmI75VXrPj8,186
9
+ task_flow/printer/printer.py,sha256=Xs6hFdbYo-WqaUU6YARGprwepnS4iF9cv-RjmTx6Wh8,3712
10
+ task_flow/runtime/__init__.py,sha256=ANHuKxQ0rcTvCNxOh50Cws0lKAIKVVMYdjTUcZRRRA4,159
11
+ task_flow/runtime/executor.py,sha256=9W96zKAmnq9Eedp3XKhgdH6CKTLRTQNLOnW4jW1ic-4,4876
12
+ taskflow_tr-0.1.0.dist-info/licenses/LICENSE,sha256=0-vpht0C3dJgVh7KnbT1wjz--5Yd_jznV74lQuSthEQ,1065
13
+ taskflow_tr-0.1.0.dist-info/METADATA,sha256=6WQb4-DTHmx2jRXm6ufEXwyNEJ2kFPWvYXBBZIbJBSE,8963
14
+ taskflow_tr-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ taskflow_tr-0.1.0.dist-info/top_level.txt,sha256=oMr2tOAseQ0qj3RIu9FTFz9Chwk5q_mq6Q0T8WHEFu0,10
16
+ taskflow_tr-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gaoxinge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ task_flow