tasktree 0.0.8__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 +54 -6
- tasktree/executor.py +85 -12
- tasktree/graph.py +124 -26
- tasktree/hasher.py +38 -1
- tasktree/parser.py +264 -12
- tasktree/substitution.py +5 -2
- {tasktree-0.0.8.dist-info → tasktree-0.0.9.dist-info}/METADATA +92 -1
- tasktree-0.0.9.dist-info/RECORD +15 -0
- tasktree-0.0.8.dist-info/RECORD +0 -15
- {tasktree-0.0.8.dist-info → tasktree-0.0.9.dist-info}/WHEEL +0 -0
- {tasktree-0.0.8.dist-info → tasktree-0.0.9.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -26,6 +26,41 @@ app = typer.Typer(
|
|
|
26
26
|
console = Console()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _format_task_arguments(arg_specs: list[str | dict]) -> str:
|
|
30
|
+
"""Format task arguments for display in list output.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
arg_specs: List of argument specifications from task definition (strings or dicts)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Formatted string showing arguments with types and defaults
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
["mode", "target"] -> "mode:str target:str"
|
|
40
|
+
["mode=debug", "target=x86_64"] -> "mode:str [=debug] target:str [=x86_64]"
|
|
41
|
+
["port:int", "debug:bool=false"] -> "port:int debug:bool [=false]"
|
|
42
|
+
[{"timeout": {"type": "int", "default": 30}}] -> "timeout:int [=30]"
|
|
43
|
+
"""
|
|
44
|
+
if not arg_specs:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
formatted_parts = []
|
|
48
|
+
for spec_str in arg_specs:
|
|
49
|
+
parsed = parse_arg_spec(spec_str)
|
|
50
|
+
|
|
51
|
+
# Format: name:type or name:type [=default]
|
|
52
|
+
# Argument names in normal intensity, types and defaults in dim
|
|
53
|
+
arg_part = f"{parsed.name}[dim]:{parsed.arg_type}[/dim]"
|
|
54
|
+
|
|
55
|
+
if parsed.default is not None:
|
|
56
|
+
# Use dim styling for the default value part
|
|
57
|
+
arg_part += f" [dim]\\[={parsed.default}][/dim]"
|
|
58
|
+
|
|
59
|
+
formatted_parts.append(arg_part)
|
|
60
|
+
|
|
61
|
+
return " ".join(formatted_parts)
|
|
62
|
+
|
|
63
|
+
|
|
29
64
|
def _list_tasks(tasks_file: Optional[str] = None):
|
|
30
65
|
"""List all available tasks with descriptions."""
|
|
31
66
|
recipe = _get_recipe(tasks_file)
|
|
@@ -33,14 +68,27 @@ def _list_tasks(tasks_file: Optional[str] = None):
|
|
|
33
68
|
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
34
69
|
raise typer.Exit(1)
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
# Calculate maximum task name length for fixed-width column
|
|
72
|
+
max_task_name_len = max(len(name) for name in recipe.task_names()) if recipe.task_names() else 0
|
|
73
|
+
|
|
74
|
+
# Create borderless table with three columns
|
|
75
|
+
table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
|
|
76
|
+
|
|
77
|
+
# Command column: fixed width to accommodate longest task name
|
|
78
|
+
table.add_column("Command", style="bold cyan", no_wrap=True, width=max_task_name_len)
|
|
79
|
+
|
|
80
|
+
# Arguments column: allow wrapping with sensible max width
|
|
81
|
+
table.add_column("Arguments", style="white", max_width=60)
|
|
82
|
+
|
|
83
|
+
# Description column: allow wrapping with sensible max width
|
|
84
|
+
table.add_column("Description", style="white", max_width=80)
|
|
39
85
|
|
|
40
86
|
for task_name in sorted(recipe.task_names()):
|
|
41
87
|
task = recipe.get_task(task_name)
|
|
42
88
|
desc = task.desc if task else ""
|
|
43
|
-
|
|
89
|
+
args_formatted = _format_task_arguments(task.args) if task else ""
|
|
90
|
+
|
|
91
|
+
table.add_row(task_name, args_formatted, desc)
|
|
44
92
|
|
|
45
93
|
console.print(table)
|
|
46
94
|
|
|
@@ -351,9 +399,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
351
399
|
state.load()
|
|
352
400
|
executor = Executor(recipe, state)
|
|
353
401
|
|
|
354
|
-
# Prune state before execution (compute hashes with effective environment)
|
|
402
|
+
# Prune state before execution (compute hashes with effective environment and dependencies)
|
|
355
403
|
valid_hashes = {
|
|
356
|
-
hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t))
|
|
404
|
+
hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t), t.deps)
|
|
357
405
|
for t in recipe.tasks.values()
|
|
358
406
|
}
|
|
359
407
|
state.prune(valid_hashes)
|
tasktree/executor.py
CHANGED
|
@@ -64,6 +64,62 @@ class Executor:
|
|
|
64
64
|
self.state = state_manager
|
|
65
65
|
self.docker_manager = docker_module.DockerManager(recipe.project_root)
|
|
66
66
|
|
|
67
|
+
def _has_regular_args(self, task: Task) -> bool:
|
|
68
|
+
"""Check if a task has any regular (non-exported) arguments.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
task: Task to check
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if task has at least one regular (non-exported) argument, False otherwise
|
|
75
|
+
"""
|
|
76
|
+
if not task.args:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Check if any arg is not exported (doesn't start with $)
|
|
80
|
+
for arg_spec in task.args:
|
|
81
|
+
# Handle both string and dict arg specs
|
|
82
|
+
if isinstance(arg_spec, str):
|
|
83
|
+
# Remove default value part if present
|
|
84
|
+
arg_name = arg_spec.split('=')[0].split(':')[0].strip()
|
|
85
|
+
if not arg_name.startswith('$'):
|
|
86
|
+
return True
|
|
87
|
+
elif isinstance(arg_spec, dict):
|
|
88
|
+
# Dict format: { argname: { ... } } or { $argname: { ... } }
|
|
89
|
+
for key in arg_spec.keys():
|
|
90
|
+
if not key.startswith('$'):
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def _filter_regular_args(self, task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
"""Filter task_args to only include regular (non-exported) arguments.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
task: Task definition
|
|
100
|
+
task_args: Dictionary of all task arguments
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Dictionary containing only regular (non-exported) arguments
|
|
104
|
+
"""
|
|
105
|
+
if not task.args or not task_args:
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
# Build set of exported arg names (without the $ prefix)
|
|
109
|
+
exported_names = set()
|
|
110
|
+
for arg_spec in task.args:
|
|
111
|
+
if isinstance(arg_spec, str):
|
|
112
|
+
arg_name = arg_spec.split('=')[0].split(':')[0].strip()
|
|
113
|
+
if arg_name.startswith('$'):
|
|
114
|
+
exported_names.add(arg_name[1:]) # Remove $ prefix
|
|
115
|
+
elif isinstance(arg_spec, dict):
|
|
116
|
+
for key in arg_spec.keys():
|
|
117
|
+
if key.startswith('$'):
|
|
118
|
+
exported_names.add(key[1:]) # Remove $ prefix
|
|
119
|
+
|
|
120
|
+
# Filter out exported args
|
|
121
|
+
return {k: v for k, v in task_args.items() if k not in exported_names}
|
|
122
|
+
|
|
67
123
|
def _collect_early_builtin_variables(self, task: Task, timestamp: datetime) -> dict[str, str]:
|
|
68
124
|
"""Collect built-in variables that don't depend on working_dir.
|
|
69
125
|
|
|
@@ -277,9 +333,9 @@ class Executor:
|
|
|
277
333
|
reason="forced",
|
|
278
334
|
)
|
|
279
335
|
|
|
280
|
-
# Compute hashes (include effective environment)
|
|
336
|
+
# Compute hashes (include effective environment and dependencies)
|
|
281
337
|
effective_env = self._get_effective_env_name(task)
|
|
282
|
-
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
338
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
|
|
283
339
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
284
340
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
285
341
|
|
|
@@ -372,22 +428,39 @@ class Executor:
|
|
|
372
428
|
# Resolve execution order
|
|
373
429
|
if only:
|
|
374
430
|
# Only execute the target task, skip dependencies
|
|
375
|
-
execution_order = [task_name]
|
|
431
|
+
execution_order = [(task_name, args_dict)]
|
|
376
432
|
else:
|
|
377
433
|
# Execute task and all dependencies
|
|
378
|
-
execution_order = resolve_execution_order(self.recipe, task_name)
|
|
434
|
+
execution_order = resolve_execution_order(self.recipe, task_name, args_dict)
|
|
379
435
|
|
|
380
436
|
# Single phase: Check and execute incrementally
|
|
381
437
|
statuses: dict[str, TaskStatus] = {}
|
|
382
|
-
for name in execution_order:
|
|
438
|
+
for name, task_args in execution_order:
|
|
383
439
|
task = self.recipe.tasks[name]
|
|
384
440
|
|
|
385
|
-
#
|
|
386
|
-
|
|
441
|
+
# Convert None to {} for internal use (None is used to distinguish simple deps in graph)
|
|
442
|
+
args_dict_for_execution = task_args if task_args is not None else {}
|
|
387
443
|
|
|
388
444
|
# Check if task needs to run (based on CURRENT filesystem state)
|
|
389
|
-
status = self.check_task_status(task,
|
|
390
|
-
|
|
445
|
+
status = self.check_task_status(task, args_dict_for_execution, force=force)
|
|
446
|
+
|
|
447
|
+
# Use a key that includes args for status tracking
|
|
448
|
+
# Only include regular (non-exported) args in status key for parameterized dependencies
|
|
449
|
+
# For the root task (invoked from CLI), status key is always just the task name
|
|
450
|
+
# For dependencies with parameterized invocations, include the regular args
|
|
451
|
+
is_root_task = (name == task_name)
|
|
452
|
+
if not is_root_task and args_dict_for_execution and self._has_regular_args(task):
|
|
453
|
+
import json
|
|
454
|
+
# Filter to only include regular (non-exported) args
|
|
455
|
+
regular_args = self._filter_regular_args(task, args_dict_for_execution)
|
|
456
|
+
if regular_args:
|
|
457
|
+
args_str = json.dumps(regular_args, sort_keys=True, separators=(",", ":"))
|
|
458
|
+
status_key = f"{name}({args_str})"
|
|
459
|
+
else:
|
|
460
|
+
status_key = name
|
|
461
|
+
else:
|
|
462
|
+
status_key = name
|
|
463
|
+
statuses[status_key] = status
|
|
391
464
|
|
|
392
465
|
# Execute immediately if needed
|
|
393
466
|
if status.will_run:
|
|
@@ -399,7 +472,7 @@ class Executor:
|
|
|
399
472
|
file=sys.stderr,
|
|
400
473
|
)
|
|
401
474
|
|
|
402
|
-
self._run_task(task,
|
|
475
|
+
self._run_task(task, args_dict_for_execution)
|
|
403
476
|
|
|
404
477
|
return statuses
|
|
405
478
|
|
|
@@ -962,9 +1035,9 @@ class Executor:
|
|
|
962
1035
|
task: Task that was executed
|
|
963
1036
|
args_dict: Arguments used for execution
|
|
964
1037
|
"""
|
|
965
|
-
# Compute hashes (include effective environment)
|
|
1038
|
+
# Compute hashes (include effective environment and dependencies)
|
|
966
1039
|
effective_env = self._get_effective_env_name(task)
|
|
967
|
-
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env)
|
|
1040
|
+
task_hash = hash_task(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
|
|
968
1041
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
969
1042
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
970
1043
|
|
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
|
@@ -37,7 +37,27 @@ def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | di
|
|
|
37
37
|
return normalized_args
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def hash_task(
|
|
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
|
+
"""
|
|
41
61
|
data = {
|
|
42
62
|
"cmd": cmd,
|
|
43
63
|
"outputs": sorted(outputs),
|
|
@@ -46,6 +66,23 @@ def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str | d
|
|
|
46
66
|
"env": env,
|
|
47
67
|
}
|
|
48
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
|
+
|
|
49
86
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
50
87
|
return hashlib.sha256(serialized.encode()).hexdigest()[:8]
|
|
51
88
|
|
tasktree/parser.py
CHANGED
|
@@ -56,7 +56,7 @@ class Task:
|
|
|
56
56
|
name: str
|
|
57
57
|
cmd: str
|
|
58
58
|
desc: str = ""
|
|
59
|
-
deps: list[str] = field(default_factory=list)
|
|
59
|
+
deps: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts with args
|
|
60
60
|
inputs: list[str] = field(default_factory=list)
|
|
61
61
|
outputs: list[str] = field(default_factory=list)
|
|
62
62
|
working_dir: str = ""
|
|
@@ -76,6 +76,25 @@ class Task:
|
|
|
76
76
|
self.args = [self.args]
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
@dataclass
|
|
80
|
+
class DependencyInvocation:
|
|
81
|
+
"""Represents a task dependency invocation with optional arguments.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
task_name: Name of the dependency task
|
|
85
|
+
args: Dictionary of argument names to values (None if no args specified)
|
|
86
|
+
"""
|
|
87
|
+
task_name: str
|
|
88
|
+
args: dict[str, Any] | None = None
|
|
89
|
+
|
|
90
|
+
def __str__(self) -> str:
|
|
91
|
+
"""String representation for display."""
|
|
92
|
+
if not self.args:
|
|
93
|
+
return self.task_name
|
|
94
|
+
args_str = ", ".join(f"{k}={v}" for k, v in self.args.items())
|
|
95
|
+
return f"{self.task_name}({args_str})"
|
|
96
|
+
|
|
97
|
+
|
|
79
98
|
@dataclass
|
|
80
99
|
class ArgSpec:
|
|
81
100
|
"""Represents a parsed argument specification.
|
|
@@ -731,10 +750,16 @@ def _resolve_variable_value(
|
|
|
731
750
|
# Validate and stringify the value
|
|
732
751
|
string_value = validator.convert(raw_value, None, None)
|
|
733
752
|
|
|
753
|
+
# Convert to string (lowercase for booleans to match YAML/shell conventions)
|
|
754
|
+
if isinstance(string_value, bool):
|
|
755
|
+
string_value_str = str(string_value).lower()
|
|
756
|
+
else:
|
|
757
|
+
string_value_str = str(string_value)
|
|
758
|
+
|
|
734
759
|
# Substitute any {{ var.name }} references in the string value
|
|
735
760
|
from tasktree.substitution import substitute_variables
|
|
736
761
|
try:
|
|
737
|
-
resolved_value = substitute_variables(
|
|
762
|
+
resolved_value = substitute_variables(string_value_str, resolved)
|
|
738
763
|
except ValueError as e:
|
|
739
764
|
# Check if the undefined variable is in the resolution stack (circular reference)
|
|
740
765
|
error_msg = str(e)
|
|
@@ -1117,18 +1142,40 @@ def _parse_file(
|
|
|
1117
1142
|
# 2. It starts with a local import namespace (like "base.setup" when "base" is imported)
|
|
1118
1143
|
rewritten_deps = []
|
|
1119
1144
|
for dep in deps:
|
|
1120
|
-
if
|
|
1121
|
-
# Simple
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
# Check if it starts with a local import namespace
|
|
1125
|
-
dep_root = dep.split(".", 1)[0]
|
|
1126
|
-
if dep_root in local_import_namespaces:
|
|
1127
|
-
# Local import reference - prefix it
|
|
1145
|
+
if isinstance(dep, str):
|
|
1146
|
+
# Simple string dependency
|
|
1147
|
+
if "." not in dep:
|
|
1148
|
+
# Simple name - always prefix
|
|
1128
1149
|
rewritten_deps.append(f"{namespace}.{dep}")
|
|
1129
1150
|
else:
|
|
1130
|
-
#
|
|
1131
|
-
|
|
1151
|
+
# Check if it starts with a local import namespace
|
|
1152
|
+
dep_root = dep.split(".", 1)[0]
|
|
1153
|
+
if dep_root in local_import_namespaces:
|
|
1154
|
+
# Local import reference - prefix it
|
|
1155
|
+
rewritten_deps.append(f"{namespace}.{dep}")
|
|
1156
|
+
else:
|
|
1157
|
+
# External reference - keep as-is
|
|
1158
|
+
rewritten_deps.append(dep)
|
|
1159
|
+
elif isinstance(dep, dict):
|
|
1160
|
+
# Dict dependency with args - rewrite the task name key
|
|
1161
|
+
rewritten_dep = {}
|
|
1162
|
+
for task_name, args in dep.items():
|
|
1163
|
+
if "." not in task_name:
|
|
1164
|
+
# Simple name - prefix it
|
|
1165
|
+
rewritten_dep[f"{namespace}.{task_name}"] = args
|
|
1166
|
+
else:
|
|
1167
|
+
# Check if it starts with a local import namespace
|
|
1168
|
+
dep_root = task_name.split(".", 1)[0]
|
|
1169
|
+
if dep_root in local_import_namespaces:
|
|
1170
|
+
# Local import reference - prefix it
|
|
1171
|
+
rewritten_dep[f"{namespace}.{task_name}"] = args
|
|
1172
|
+
else:
|
|
1173
|
+
# External reference - keep as-is
|
|
1174
|
+
rewritten_dep[task_name] = args
|
|
1175
|
+
rewritten_deps.append(rewritten_dep)
|
|
1176
|
+
else:
|
|
1177
|
+
# Unknown type - keep as-is
|
|
1178
|
+
rewritten_deps.append(dep)
|
|
1132
1179
|
deps = rewritten_deps
|
|
1133
1180
|
|
|
1134
1181
|
task = Task(
|
|
@@ -1539,3 +1586,208 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
1539
1586
|
max_val=max_val,
|
|
1540
1587
|
choices=choices
|
|
1541
1588
|
)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> DependencyInvocation:
|
|
1592
|
+
"""Parse a dependency specification into a DependencyInvocation.
|
|
1593
|
+
|
|
1594
|
+
Supports three forms:
|
|
1595
|
+
1. Simple string: "task_name" -> DependencyInvocation(task_name, None)
|
|
1596
|
+
2. Positional args: {"task_name": [arg1, arg2]} -> DependencyInvocation(task_name, {name1: arg1, name2: arg2})
|
|
1597
|
+
3. Named args: {"task_name": {arg1: val1}} -> DependencyInvocation(task_name, {arg1: val1})
|
|
1598
|
+
|
|
1599
|
+
Args:
|
|
1600
|
+
dep_spec: Dependency specification (string or dict)
|
|
1601
|
+
recipe: Recipe containing task definitions (for arg normalization)
|
|
1602
|
+
|
|
1603
|
+
Returns:
|
|
1604
|
+
DependencyInvocation object with normalized args
|
|
1605
|
+
|
|
1606
|
+
Raises:
|
|
1607
|
+
ValueError: If dependency specification is invalid
|
|
1608
|
+
"""
|
|
1609
|
+
# Simple string case
|
|
1610
|
+
if isinstance(dep_spec, str):
|
|
1611
|
+
return DependencyInvocation(task_name=dep_spec, args=None)
|
|
1612
|
+
|
|
1613
|
+
# Dictionary case
|
|
1614
|
+
if not isinstance(dep_spec, dict):
|
|
1615
|
+
raise ValueError(
|
|
1616
|
+
f"Dependency must be a string or dictionary, got: {type(dep_spec).__name__}"
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
# Validate dict has exactly one key
|
|
1620
|
+
if len(dep_spec) != 1:
|
|
1621
|
+
raise ValueError(
|
|
1622
|
+
f"Dependency dictionary must have exactly one key (the task name), got: {list(dep_spec.keys())}"
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
task_name, arg_spec = next(iter(dep_spec.items()))
|
|
1626
|
+
|
|
1627
|
+
# Validate task name
|
|
1628
|
+
if not isinstance(task_name, str) or not task_name:
|
|
1629
|
+
raise ValueError(
|
|
1630
|
+
f"Dependency task name must be a non-empty string, got: {task_name!r}"
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
# Check for empty list (explicitly disallowed)
|
|
1634
|
+
if isinstance(arg_spec, list) and len(arg_spec) == 0:
|
|
1635
|
+
raise ValueError(
|
|
1636
|
+
f"Empty argument list for dependency '{task_name}' is not allowed.\n"
|
|
1637
|
+
f"Use simple string form instead: '{task_name}'"
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
# Positional args (list)
|
|
1641
|
+
if isinstance(arg_spec, list):
|
|
1642
|
+
return _parse_positional_dependency_args(task_name, arg_spec, recipe)
|
|
1643
|
+
|
|
1644
|
+
# Named args (dict)
|
|
1645
|
+
if isinstance(arg_spec, dict):
|
|
1646
|
+
return _parse_named_dependency_args(task_name, arg_spec, recipe)
|
|
1647
|
+
|
|
1648
|
+
# Invalid type
|
|
1649
|
+
raise ValueError(
|
|
1650
|
+
f"Dependency arguments for '{task_name}' must be a list (positional) or dict (named), "
|
|
1651
|
+
f"got: {type(arg_spec).__name__}"
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
|
|
1656
|
+
"""Get and validate that a task exists in the recipe.
|
|
1657
|
+
|
|
1658
|
+
Args:
|
|
1659
|
+
task_name: Name of the task to retrieve
|
|
1660
|
+
recipe: Recipe containing task definitions
|
|
1661
|
+
|
|
1662
|
+
Returns:
|
|
1663
|
+
The validated Task object
|
|
1664
|
+
|
|
1665
|
+
Raises:
|
|
1666
|
+
ValueError: If task is not found
|
|
1667
|
+
"""
|
|
1668
|
+
task = recipe.get_task(task_name)
|
|
1669
|
+
if task is None:
|
|
1670
|
+
raise ValueError(f"Dependency task not found: {task_name}")
|
|
1671
|
+
return task
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def _parse_positional_dependency_args(
|
|
1675
|
+
task_name: str, args_list: list[Any], recipe: Recipe
|
|
1676
|
+
) -> DependencyInvocation:
|
|
1677
|
+
"""Parse positional dependency arguments.
|
|
1678
|
+
|
|
1679
|
+
Args:
|
|
1680
|
+
task_name: Name of the dependency task
|
|
1681
|
+
args_list: List of positional argument values
|
|
1682
|
+
recipe: Recipe containing task definitions
|
|
1683
|
+
|
|
1684
|
+
Returns:
|
|
1685
|
+
DependencyInvocation with normalized named args
|
|
1686
|
+
|
|
1687
|
+
Raises:
|
|
1688
|
+
ValueError: If validation fails
|
|
1689
|
+
"""
|
|
1690
|
+
# Get the task to validate against
|
|
1691
|
+
task = _get_validated_task(task_name, recipe)
|
|
1692
|
+
|
|
1693
|
+
# Parse task's arg specs
|
|
1694
|
+
if not task.args:
|
|
1695
|
+
raise ValueError(
|
|
1696
|
+
f"Task '{task_name}' takes no arguments, but {len(args_list)} were provided"
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
parsed_specs = [parse_arg_spec(spec) for spec in task.args]
|
|
1700
|
+
|
|
1701
|
+
# Check positional count doesn't exceed task's arg count
|
|
1702
|
+
if len(args_list) > len(parsed_specs):
|
|
1703
|
+
raise ValueError(
|
|
1704
|
+
f"Task '{task_name}' takes {len(parsed_specs)} arguments, got {len(args_list)}"
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
# Map positional args to names with type conversion
|
|
1708
|
+
args_dict = {}
|
|
1709
|
+
for i, value in enumerate(args_list):
|
|
1710
|
+
spec = parsed_specs[i]
|
|
1711
|
+
if isinstance(value, str):
|
|
1712
|
+
# Convert string values using type validator
|
|
1713
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
1714
|
+
args_dict[spec.name] = click_type.convert(value, None, None)
|
|
1715
|
+
else:
|
|
1716
|
+
# Value is already typed (e.g., bool, int from YAML)
|
|
1717
|
+
args_dict[spec.name] = value
|
|
1718
|
+
|
|
1719
|
+
# Fill in defaults for remaining args
|
|
1720
|
+
for i in range(len(args_list), len(parsed_specs)):
|
|
1721
|
+
spec = parsed_specs[i]
|
|
1722
|
+
if spec.default is not None:
|
|
1723
|
+
# Defaults in task specs are always strings, convert them
|
|
1724
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
1725
|
+
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
1726
|
+
else:
|
|
1727
|
+
raise ValueError(
|
|
1728
|
+
f"Task '{task_name}' requires argument '{spec.name}' (no default provided)"
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
return DependencyInvocation(task_name=task_name, args=args_dict)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def _parse_named_dependency_args(
|
|
1735
|
+
task_name: str, args_dict: dict[str, Any], recipe: Recipe
|
|
1736
|
+
) -> DependencyInvocation:
|
|
1737
|
+
"""Parse named dependency arguments.
|
|
1738
|
+
|
|
1739
|
+
Args:
|
|
1740
|
+
task_name: Name of the dependency task
|
|
1741
|
+
args_dict: Dictionary of argument names to values
|
|
1742
|
+
recipe: Recipe containing task definitions
|
|
1743
|
+
|
|
1744
|
+
Returns:
|
|
1745
|
+
DependencyInvocation with normalized args (defaults filled)
|
|
1746
|
+
|
|
1747
|
+
Raises:
|
|
1748
|
+
ValueError: If validation fails
|
|
1749
|
+
"""
|
|
1750
|
+
# Get the task to validate against
|
|
1751
|
+
task = _get_validated_task(task_name, recipe)
|
|
1752
|
+
|
|
1753
|
+
# Parse task's arg specs
|
|
1754
|
+
if not task.args:
|
|
1755
|
+
if args_dict:
|
|
1756
|
+
raise ValueError(
|
|
1757
|
+
f"Task '{task_name}' takes no arguments, but {len(args_dict)} were provided"
|
|
1758
|
+
)
|
|
1759
|
+
return DependencyInvocation(task_name=task_name, args={})
|
|
1760
|
+
|
|
1761
|
+
parsed_specs = [parse_arg_spec(spec) for spec in task.args]
|
|
1762
|
+
spec_map = {spec.name: spec for spec in parsed_specs}
|
|
1763
|
+
|
|
1764
|
+
# Validate all provided arg names exist
|
|
1765
|
+
for arg_name in args_dict:
|
|
1766
|
+
if arg_name not in spec_map:
|
|
1767
|
+
raise ValueError(
|
|
1768
|
+
f"Task '{task_name}' has no argument named '{arg_name}'"
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
# Build normalized args dict with defaults
|
|
1772
|
+
normalized_args = {}
|
|
1773
|
+
for spec in parsed_specs:
|
|
1774
|
+
if spec.name in args_dict:
|
|
1775
|
+
# Use provided value with type conversion (only convert strings)
|
|
1776
|
+
value = args_dict[spec.name]
|
|
1777
|
+
if isinstance(value, str):
|
|
1778
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
1779
|
+
normalized_args[spec.name] = click_type.convert(value, None, None)
|
|
1780
|
+
else:
|
|
1781
|
+
# Value is already typed (e.g., bool, int from YAML)
|
|
1782
|
+
normalized_args[spec.name] = value
|
|
1783
|
+
elif spec.default is not None:
|
|
1784
|
+
# Use default value (defaults are always strings in task specs)
|
|
1785
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
1786
|
+
normalized_args[spec.name] = click_type.convert(spec.default, None, None)
|
|
1787
|
+
else:
|
|
1788
|
+
# Required arg not provided
|
|
1789
|
+
raise ValueError(
|
|
1790
|
+
f"Task '{task_name}' requires argument '{spec.name}' (no default provided)"
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
return DependencyInvocation(task_name=task_name, args=normalized_args)
|
tasktree/substitution.py
CHANGED
|
@@ -87,8 +87,11 @@ def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str
|
|
|
87
87
|
f"Required arguments must be provided."
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
# Convert to string
|
|
91
|
-
|
|
90
|
+
# Convert to string (lowercase for booleans to match YAML/shell conventions)
|
|
91
|
+
value = args[name]
|
|
92
|
+
if isinstance(value, bool):
|
|
93
|
+
return str(value).lower()
|
|
94
|
+
return str(value)
|
|
92
95
|
|
|
93
96
|
return PLACEHOLDER_PATTERN.sub(replace_match, text)
|
|
94
97
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tasktree
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: A task automation tool with incremental execution
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.1.0
|
|
@@ -594,6 +594,97 @@ If an exported argument with a default isn't available as an environment variabl
|
|
|
594
594
|
2. The CLI automatically applies defaults before execution
|
|
595
595
|
3. You can explicitly provide the value: `tt deploy prod-server port=8080`
|
|
596
596
|
|
|
597
|
+
### Parameterized Dependencies
|
|
598
|
+
|
|
599
|
+
Dependencies can invoke tasks with specific arguments, enabling flexible and reusable task graphs:
|
|
600
|
+
|
|
601
|
+
**Syntax:**
|
|
602
|
+
|
|
603
|
+
```yaml
|
|
604
|
+
tasks:
|
|
605
|
+
# Task with parameters
|
|
606
|
+
process:
|
|
607
|
+
args: [mode, verbose=false]
|
|
608
|
+
cmd: echo "mode={{arg.mode}} verbose={{arg.verbose}}"
|
|
609
|
+
|
|
610
|
+
# Simple dependency (uses defaults)
|
|
611
|
+
consumer1:
|
|
612
|
+
deps: [process] # Equivalent to: process(mode must be provided)
|
|
613
|
+
cmd: echo "done"
|
|
614
|
+
|
|
615
|
+
# Positional arguments
|
|
616
|
+
consumer2:
|
|
617
|
+
deps:
|
|
618
|
+
- process: [debug, true] # Maps to: mode=debug, verbose=true
|
|
619
|
+
cmd: echo "done"
|
|
620
|
+
|
|
621
|
+
# Named arguments
|
|
622
|
+
consumer3:
|
|
623
|
+
deps:
|
|
624
|
+
- process: {mode: release, verbose: false}
|
|
625
|
+
cmd: echo "done"
|
|
626
|
+
|
|
627
|
+
# Multiple invocations with different args
|
|
628
|
+
multi_build:
|
|
629
|
+
deps:
|
|
630
|
+
- process: [debug]
|
|
631
|
+
- process: [release]
|
|
632
|
+
cmd: echo "All builds complete"
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
**Key behaviors:**
|
|
636
|
+
|
|
637
|
+
- **Simple string form** (`- task_name`): Uses task defaults for all arguments. Required arguments must have defaults or task invocation fails.
|
|
638
|
+
- **Positional form** (`- task_name: [arg1, arg2]`): Arguments mapped by position. Can omit trailing args if they have defaults.
|
|
639
|
+
- **Named form** (`- task_name: {arg1: val1}`): Arguments mapped by name. Can omit any arg with a default.
|
|
640
|
+
- **Multiple invocations**: Same task with different arguments creates separate graph nodes, each executing independently.
|
|
641
|
+
- **Normalization**: All forms normalized to named arguments with defaults filled before execution.
|
|
642
|
+
- **Cache separation**: `process(debug)` and `process(release)` cache independently.
|
|
643
|
+
|
|
644
|
+
**Restrictions:**
|
|
645
|
+
|
|
646
|
+
- **No empty lists**: `- task: []` is invalid (use `- task` instead)
|
|
647
|
+
- **No mixed positional and named**: Choose one form per dependency
|
|
648
|
+
- **Single-key dicts**: `{task1: [x], task2: [y]}` is invalid (multi-key not allowed)
|
|
649
|
+
|
|
650
|
+
**Validation:**
|
|
651
|
+
|
|
652
|
+
Validation happens at graph construction time with clear error messages:
|
|
653
|
+
|
|
654
|
+
```
|
|
655
|
+
Task 'process' takes 2 arguments, got 3
|
|
656
|
+
Task 'build' has no argument named 'mode'
|
|
657
|
+
Task 'deploy' requires argument 'environment' (no default provided)
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
**Example use cases:**
|
|
661
|
+
|
|
662
|
+
```yaml
|
|
663
|
+
tasks:
|
|
664
|
+
# Compile for different platforms
|
|
665
|
+
compile:
|
|
666
|
+
args: [target]
|
|
667
|
+
cmd: cargo build --target {{arg.target}}
|
|
668
|
+
|
|
669
|
+
dist:
|
|
670
|
+
deps:
|
|
671
|
+
- compile: [x86_64-unknown-linux-gnu]
|
|
672
|
+
- compile: [aarch64-unknown-linux-gnu]
|
|
673
|
+
cmd: tar czf dist.tar.gz target/*/release/app
|
|
674
|
+
|
|
675
|
+
# Run tests with different configurations
|
|
676
|
+
test:
|
|
677
|
+
args: [config]
|
|
678
|
+
cmd: pytest --config={{arg.config}}
|
|
679
|
+
|
|
680
|
+
ci:
|
|
681
|
+
deps:
|
|
682
|
+
- test: [unit]
|
|
683
|
+
- test: [integration]
|
|
684
|
+
- test: [e2e]
|
|
685
|
+
cmd: echo "All tests passed"
|
|
686
|
+
```
|
|
687
|
+
|
|
597
688
|
## Environment Variables
|
|
598
689
|
|
|
599
690
|
Task Tree supports reading environment variables in two ways:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
+
tasktree/cli.py,sha256=H5T8wOxLBGx-ZTQEnkoJrX3srgD5b_7BLf1IWl18M2M,17597
|
|
3
|
+
tasktree/docker.py,sha256=R69NcZw4MyaxEXyJAwniYCm877iaI10jRhxlLmkA6Fs,14119
|
|
4
|
+
tasktree/executor.py,sha256=Q7Bks5B88i-IyZDpxGSps9MM3uflz0U3yn4Rtq_uHMM,42266
|
|
5
|
+
tasktree/graph.py,sha256=oXLxX0Ix4zSkVBg8_3x9K7WxSFpg136sp4MF-d2mDEQ,9682
|
|
6
|
+
tasktree/hasher.py,sha256=C8oN-K6dtL3vLHSPhKR7uu5c1d4vplGSAMZUq5M4scw,4125
|
|
7
|
+
tasktree/parser.py,sha256=DjdfsKErdBggqS8Tw_mwuMvMSavIJqq2BCdsh1O82CY,66333
|
|
8
|
+
tasktree/state.py,sha256=Cktl4D8iDZVd55aO2LqVyPrc-BnljkesxxkcMcdcfOY,3541
|
|
9
|
+
tasktree/substitution.py,sha256=M_qcP0NKJATrKcNShSqHJatneuth1RVwTk1ci8-ZuxQ,6473
|
|
10
|
+
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
11
|
+
tasktree/types.py,sha256=R_YAyO5bMLB6XZnkMRT7VAtlkA_Xx6xu0aIpzQjrBXs,4357
|
|
12
|
+
tasktree-0.0.9.dist-info/METADATA,sha256=VBgQ1ZF2hacw1CajhxcjkrGTyygnf-uWxiZm7H92AyE,37123
|
|
13
|
+
tasktree-0.0.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
tasktree-0.0.9.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
15
|
+
tasktree-0.0.9.dist-info/RECORD,,
|
tasktree-0.0.8.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=bPojvR7kS2iQomNFSykYsrOgD4Nc5XAH4XbEfdVL9qk,15736
|
|
3
|
-
tasktree/docker.py,sha256=R69NcZw4MyaxEXyJAwniYCm877iaI10jRhxlLmkA6Fs,14119
|
|
4
|
-
tasktree/executor.py,sha256=iZ_BF3pjyxhH6l2p78rLSK54Xk-V08Ae_BVF6CIi3jo,38979
|
|
5
|
-
tasktree/graph.py,sha256=lA3ExNM_ag0AlC6iW20unseCjRg5wCZXbmXs2M6TnQw,5578
|
|
6
|
-
tasktree/hasher.py,sha256=S4OKsNjf1ZnhGAvRWr6usuAudiozlQqrvcoAGYzJ_w8,2852
|
|
7
|
-
tasktree/parser.py,sha256=K83HyujCyh9NGJoBeUzozYRFQELNHPzWcJoZdPh79yE,56808
|
|
8
|
-
tasktree/state.py,sha256=Cktl4D8iDZVd55aO2LqVyPrc-BnljkesxxkcMcdcfOY,3541
|
|
9
|
-
tasktree/substitution.py,sha256=Sr8_aBdcWXtkCybkSFMHRjQyQSq-cMREtps_A9ASUgk,6320
|
|
10
|
-
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
11
|
-
tasktree/types.py,sha256=R_YAyO5bMLB6XZnkMRT7VAtlkA_Xx6xu0aIpzQjrBXs,4357
|
|
12
|
-
tasktree-0.0.8.dist-info/METADATA,sha256=JKjK-6i9ZlTq4gCVWl1dJE6W615tjtlQt64bPwi4vcU,34533
|
|
13
|
-
tasktree-0.0.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
-
tasktree-0.0.8.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
15
|
-
tasktree-0.0.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|