tasktree 0.0.7__py3-none-any.whl → 0.0.9__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.
- tasktree/cli.py +78 -22
- tasktree/docker.py +25 -0
- tasktree/executor.py +346 -34
- tasktree/graph.py +124 -26
- tasktree/hasher.py +73 -2
- tasktree/parser.py +1288 -35
- tasktree/substitution.py +198 -0
- tasktree/types.py +11 -2
- tasktree-0.0.9.dist-info/METADATA +1240 -0
- tasktree-0.0.9.dist-info/RECORD +15 -0
- tasktree-0.0.7.dist-info/METADATA +0 -654
- tasktree-0.0.7.dist-info/RECORD +0 -14
- {tasktree-0.0.7.dist-info → tasktree-0.0.9.dist-info}/WHEEL +0 -0
- {tasktree-0.0.7.dist-info → tasktree-0.0.9.dist-info}/entry_points.txt +0 -0
tasktree/graph.py
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from graphlib import TopologicalSorter
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
|
-
from tasktree.
|
|
7
|
+
from tasktree.hasher import hash_args
|
|
8
|
+
from tasktree.parser import Recipe, Task, DependencyInvocation, parse_dependency_spec
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class CycleError(Exception):
|
|
@@ -18,15 +20,61 @@ class TaskNotFoundError(Exception):
|
|
|
18
20
|
pass
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
class TaskNode:
|
|
24
|
+
"""Represents a node in the dependency graph (task + arguments).
|
|
25
|
+
|
|
26
|
+
Each node represents a unique invocation of a task with specific arguments.
|
|
27
|
+
Tasks invoked with different arguments are considered different nodes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, task_name: str, args: dict[str, Any] | None = None):
|
|
31
|
+
self.task_name = task_name
|
|
32
|
+
self.args = args # Keep None as None
|
|
33
|
+
|
|
34
|
+
def __hash__(self):
|
|
35
|
+
"""Hash based on task name and sorted args."""
|
|
36
|
+
# Treat None and {} as equivalent for hashing
|
|
37
|
+
if not self.args:
|
|
38
|
+
return hash(self.task_name)
|
|
39
|
+
args_hash = hash_args(self.args)
|
|
40
|
+
return hash((self.task_name, args_hash))
|
|
41
|
+
|
|
42
|
+
def __eq__(self, other):
|
|
43
|
+
"""Equality based on task name and args."""
|
|
44
|
+
if not isinstance(other, TaskNode):
|
|
45
|
+
return False
|
|
46
|
+
# Treat None and {} as equivalent
|
|
47
|
+
self_args = self.args if self.args else {}
|
|
48
|
+
other_args = other.args if other.args else {}
|
|
49
|
+
return self.task_name == other.task_name and self_args == other_args
|
|
50
|
+
|
|
51
|
+
def __repr__(self):
|
|
52
|
+
if not self.args:
|
|
53
|
+
return f"TaskNode({self.task_name})"
|
|
54
|
+
args_str = ", ".join(f"{k}={v}" for k, v in sorted(self.args.items()))
|
|
55
|
+
return f"TaskNode({self.task_name}, {{{args_str}}})"
|
|
56
|
+
|
|
57
|
+
def __str__(self):
|
|
58
|
+
if not self.args:
|
|
59
|
+
return self.task_name
|
|
60
|
+
args_str = ", ".join(f"{k}={v}" for k, v in sorted(self.args.items()))
|
|
61
|
+
return f"{self.task_name}({args_str})"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_execution_order(
|
|
65
|
+
recipe: Recipe,
|
|
66
|
+
target_task: str,
|
|
67
|
+
target_args: dict[str, Any] | None = None
|
|
68
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
22
69
|
"""Resolve execution order for a task and its dependencies.
|
|
23
70
|
|
|
24
71
|
Args:
|
|
25
72
|
recipe: Parsed recipe containing all tasks
|
|
26
73
|
target_task: Name of the task to execute
|
|
74
|
+
target_args: Arguments for the target task (optional)
|
|
27
75
|
|
|
28
76
|
Returns:
|
|
29
|
-
List of
|
|
77
|
+
List of (task_name, args_dict) tuples in execution order (dependencies first)
|
|
30
78
|
|
|
31
79
|
Raises:
|
|
32
80
|
TaskNotFoundError: If target task or any dependency doesn't exist
|
|
@@ -35,33 +83,61 @@ def resolve_execution_order(recipe: Recipe, target_task: str) -> list[str]:
|
|
|
35
83
|
if target_task not in recipe.tasks:
|
|
36
84
|
raise TaskNotFoundError(f"Task not found: {target_task}")
|
|
37
85
|
|
|
38
|
-
# Build dependency graph
|
|
39
|
-
graph: dict[
|
|
86
|
+
# Build dependency graph using TaskNode objects
|
|
87
|
+
graph: dict[TaskNode, set[TaskNode]] = {}
|
|
88
|
+
|
|
89
|
+
# Track seen nodes to detect duplicates
|
|
90
|
+
seen_invocations: dict[tuple[str, str], TaskNode] = {} # (task_name, args_hash) -> node
|
|
40
91
|
|
|
41
|
-
def
|
|
92
|
+
def get_or_create_node(task_name: str, args: dict[str, Any] | None) -> TaskNode:
|
|
93
|
+
"""Get existing node or create new one for this invocation."""
|
|
94
|
+
args_hash = hash_args(args) if args else ""
|
|
95
|
+
key = (task_name, args_hash)
|
|
96
|
+
|
|
97
|
+
if key not in seen_invocations:
|
|
98
|
+
seen_invocations[key] = TaskNode(task_name, args)
|
|
99
|
+
return seen_invocations[key]
|
|
100
|
+
|
|
101
|
+
def build_graph(node: TaskNode) -> None:
|
|
42
102
|
"""Recursively build dependency graph."""
|
|
43
|
-
if
|
|
103
|
+
if node in graph:
|
|
44
104
|
# Already processed
|
|
45
105
|
return
|
|
46
106
|
|
|
47
|
-
task = recipe.tasks.get(task_name)
|
|
107
|
+
task = recipe.tasks.get(node.task_name)
|
|
48
108
|
if task is None:
|
|
49
|
-
raise TaskNotFoundError(f"Task not found: {task_name}")
|
|
109
|
+
raise TaskNotFoundError(f"Task not found: {node.task_name}")
|
|
50
110
|
|
|
51
|
-
#
|
|
52
|
-
|
|
111
|
+
# Parse and normalize dependencies
|
|
112
|
+
dep_nodes = set()
|
|
113
|
+
for dep_spec in task.deps:
|
|
114
|
+
# Parse dependency specification
|
|
115
|
+
dep_inv = parse_dependency_spec(dep_spec, recipe)
|
|
116
|
+
|
|
117
|
+
# Create or get node for this dependency invocation
|
|
118
|
+
dep_node = get_or_create_node(dep_inv.task_name, dep_inv.args)
|
|
119
|
+
dep_nodes.add(dep_node)
|
|
120
|
+
|
|
121
|
+
# Add task to graph with its dependency nodes
|
|
122
|
+
graph[node] = dep_nodes
|
|
53
123
|
|
|
54
124
|
# Recursively process dependencies
|
|
55
|
-
for
|
|
56
|
-
build_graph(
|
|
125
|
+
for dep_node in dep_nodes:
|
|
126
|
+
build_graph(dep_node)
|
|
127
|
+
|
|
128
|
+
# Create root node for target task
|
|
129
|
+
root_node = get_or_create_node(target_task, target_args)
|
|
57
130
|
|
|
58
131
|
# Build graph starting from target task
|
|
59
|
-
build_graph(
|
|
132
|
+
build_graph(root_node)
|
|
60
133
|
|
|
61
134
|
# Use TopologicalSorter to resolve execution order
|
|
62
135
|
try:
|
|
63
136
|
sorter = TopologicalSorter(graph)
|
|
64
|
-
|
|
137
|
+
ordered_nodes = list(sorter.static_order())
|
|
138
|
+
|
|
139
|
+
# Convert TaskNode objects to (task_name, args_dict) tuples
|
|
140
|
+
return [(node.task_name, node.args) for node in ordered_nodes]
|
|
65
141
|
except ValueError as e:
|
|
66
142
|
raise CycleError(f"Dependency cycle detected: {e}")
|
|
67
143
|
|
|
@@ -87,8 +163,10 @@ def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
|
|
|
87
163
|
implicit_inputs = []
|
|
88
164
|
|
|
89
165
|
# Inherit from dependencies
|
|
90
|
-
for
|
|
91
|
-
|
|
166
|
+
for dep_spec in task.deps:
|
|
167
|
+
# Parse dependency to get task name (ignore args for input inheritance)
|
|
168
|
+
dep_inv = parse_dependency_spec(dep_spec, recipe)
|
|
169
|
+
dep_task = recipe.tasks.get(dep_inv.task_name)
|
|
92
170
|
if dep_task is None:
|
|
93
171
|
continue
|
|
94
172
|
|
|
@@ -125,7 +203,7 @@ def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
|
|
|
125
203
|
return implicit_inputs
|
|
126
204
|
|
|
127
205
|
|
|
128
|
-
def build_dependency_tree(recipe: Recipe, target_task: str) -> dict:
|
|
206
|
+
def build_dependency_tree(recipe: Recipe, target_task: str, target_args: dict[str, Any] | None = None) -> dict:
|
|
129
207
|
"""Build a tree structure representing dependencies for visualization.
|
|
130
208
|
|
|
131
209
|
Note: This builds a true tree representation where shared dependencies may
|
|
@@ -135,6 +213,7 @@ def build_dependency_tree(recipe: Recipe, target_task: str) -> dict:
|
|
|
135
213
|
Args:
|
|
136
214
|
recipe: Parsed recipe containing all tasks
|
|
137
215
|
target_task: Name of the task to build tree for
|
|
216
|
+
target_args: Arguments for the target task (optional)
|
|
138
217
|
|
|
139
218
|
Returns:
|
|
140
219
|
Nested dictionary representing the dependency tree
|
|
@@ -144,25 +223,44 @@ def build_dependency_tree(recipe: Recipe, target_task: str) -> dict:
|
|
|
144
223
|
|
|
145
224
|
current_path = set() # Track current recursion path for cycle detection
|
|
146
225
|
|
|
147
|
-
def build_tree(task_name: str) -> dict:
|
|
226
|
+
def build_tree(task_name: str, args: dict[str, Any] | None) -> dict:
|
|
148
227
|
"""Recursively build dependency tree."""
|
|
149
228
|
task = recipe.tasks.get(task_name)
|
|
150
229
|
if task is None:
|
|
151
230
|
raise TaskNotFoundError(f"Task not found: {task_name}")
|
|
152
231
|
|
|
232
|
+
# Create node identifier for cycle detection
|
|
233
|
+
from tasktree.hasher import hash_args
|
|
234
|
+
args_dict = args or {}
|
|
235
|
+
node_id = (task_name, hash_args(args_dict) if args_dict else "")
|
|
236
|
+
|
|
153
237
|
# Detect cycles in current recursion path
|
|
154
|
-
if
|
|
155
|
-
|
|
238
|
+
if node_id in current_path:
|
|
239
|
+
display_name = task_name if not args_dict else f"{task_name}({', '.join(f'{k}={v}' for k, v in sorted(args_dict.items()))})"
|
|
240
|
+
return {"name": display_name, "deps": [], "cycle": True}
|
|
241
|
+
|
|
242
|
+
current_path.add(node_id)
|
|
243
|
+
|
|
244
|
+
# Parse dependencies
|
|
245
|
+
dep_trees = []
|
|
246
|
+
for dep_spec in task.deps:
|
|
247
|
+
dep_inv = parse_dependency_spec(dep_spec, recipe)
|
|
248
|
+
dep_tree = build_tree(dep_inv.task_name, dep_inv.args)
|
|
249
|
+
dep_trees.append(dep_tree)
|
|
156
250
|
|
|
157
|
-
|
|
251
|
+
# Create display name (include args if present)
|
|
252
|
+
display_name = task_name
|
|
253
|
+
if args_dict:
|
|
254
|
+
args_str = ", ".join(f"{k}={v}" for k, v in sorted(args_dict.items()))
|
|
255
|
+
display_name = f"{task_name}({args_str})"
|
|
158
256
|
|
|
159
257
|
tree = {
|
|
160
|
-
"name":
|
|
161
|
-
"deps":
|
|
258
|
+
"name": display_name,
|
|
259
|
+
"deps": dep_trees,
|
|
162
260
|
}
|
|
163
261
|
|
|
164
|
-
current_path.remove(
|
|
262
|
+
current_path.remove(node_id)
|
|
165
263
|
|
|
166
264
|
return tree
|
|
167
265
|
|
|
168
|
-
return build_tree(target_task)
|
|
266
|
+
return build_tree(target_task, target_args)
|
tasktree/hasher.py
CHANGED
|
@@ -3,15 +3,86 @@ import json
|
|
|
3
3
|
from typing import Any, Optional
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def
|
|
6
|
+
def _arg_sort_key(arg: str | dict[str, Any]) -> str:
|
|
7
|
+
"""Extract the sort key from an arg for deterministic hashing.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
arg: Either a string arg or dict arg specification
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The argument name to use as a sort key
|
|
14
|
+
"""
|
|
15
|
+
if isinstance(arg, dict):
|
|
16
|
+
# Dict args have exactly one key - the argument name
|
|
17
|
+
# This is validated by parse_arg_spec in parser.py
|
|
18
|
+
return next(iter(arg.keys()))
|
|
19
|
+
return arg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | dict[str, Any]]:
|
|
23
|
+
normalized_args = []
|
|
24
|
+
for arg in args:
|
|
25
|
+
if isinstance(arg, dict):
|
|
26
|
+
# Deep copy and sort choices if present
|
|
27
|
+
normalized = {}
|
|
28
|
+
for key, value in arg.items():
|
|
29
|
+
if isinstance(value, dict) and 'choices' in value:
|
|
30
|
+
normalized[key] = {**value, 'choices': sorted(value['choices'], key=str)}
|
|
31
|
+
else:
|
|
32
|
+
normalized[key] = value
|
|
33
|
+
normalized_args.append(normalized)
|
|
34
|
+
else:
|
|
35
|
+
normalized_args.append(arg)
|
|
36
|
+
|
|
37
|
+
return normalized_args
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hash_task(
|
|
41
|
+
cmd: str,
|
|
42
|
+
outputs: list[str],
|
|
43
|
+
working_dir: str,
|
|
44
|
+
args: list[str | dict[str, Any]],
|
|
45
|
+
env: str = "",
|
|
46
|
+
deps: list[str | dict[str, Any]] | None = None
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Hash task definition including dependencies.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
cmd: Task command
|
|
52
|
+
outputs: Task outputs
|
|
53
|
+
working_dir: Working directory
|
|
54
|
+
args: Task argument specifications
|
|
55
|
+
env: Environment name
|
|
56
|
+
deps: Dependency specifications (optional, for dependency hash)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
8-character hash of task definition
|
|
60
|
+
"""
|
|
7
61
|
data = {
|
|
8
62
|
"cmd": cmd,
|
|
9
63
|
"outputs": sorted(outputs),
|
|
10
64
|
"working_dir": working_dir,
|
|
11
|
-
"args": sorted(args),
|
|
65
|
+
"args": sorted(_normalize_choices_lists(args), key=_arg_sort_key),
|
|
12
66
|
"env": env,
|
|
13
67
|
}
|
|
14
68
|
|
|
69
|
+
# Include dependency invocation signatures if provided
|
|
70
|
+
if deps is not None:
|
|
71
|
+
# Normalize deps for hashing using JSON serialization for consistency
|
|
72
|
+
normalized_deps = []
|
|
73
|
+
for dep in deps:
|
|
74
|
+
if isinstance(dep, str):
|
|
75
|
+
# Simple string dependency
|
|
76
|
+
normalized_deps.append(dep)
|
|
77
|
+
elif isinstance(dep, dict):
|
|
78
|
+
# Dict dependency with args - normalize to canonical form
|
|
79
|
+
# Sort the dict to ensure consistent hashing
|
|
80
|
+
normalized_deps.append(dict(sorted(dep.items())))
|
|
81
|
+
else:
|
|
82
|
+
normalized_deps.append(dep)
|
|
83
|
+
# Sort using JSON serialization for consistent ordering
|
|
84
|
+
data["deps"] = sorted(normalized_deps, key=lambda x: json.dumps(x, sort_keys=True) if isinstance(x, dict) else x)
|
|
85
|
+
|
|
15
86
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
16
87
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
17
88
|
|