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/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.parser import Recipe, Task
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
- def resolve_execution_order(recipe: Recipe, target_task: str) -> list[str]:
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 task names in execution order (dependencies first)
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[str, set[str]] = {}
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 build_graph(task_name: str) -> None:
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 task_name in graph:
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
- # Add task to graph with its dependencies
52
- graph[task_name] = set(task.deps)
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 dep in task.deps:
56
- build_graph(dep)
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(target_task)
132
+ build_graph(root_node)
60
133
 
61
134
  # Use TopologicalSorter to resolve execution order
62
135
  try:
63
136
  sorter = TopologicalSorter(graph)
64
- return list(sorter.static_order())
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 dep_name in task.deps:
91
- dep_task = recipe.tasks.get(dep_name)
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 task_name in current_path:
155
- return {"name": task_name, "deps": [], "cycle": True}
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
- current_path.add(task_name)
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": task_name,
161
- "deps": [build_tree(dep) for dep in task.deps],
258
+ "name": display_name,
259
+ "deps": dep_trees,
162
260
  }
163
261
 
164
- current_path.remove(task_name)
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 hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str], env: str = "") -> str:
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