tasktree 0.0.18__py3-none-any.whl → 0.0.20__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/__init__.py +4 -0
- tasktree/cli.py +29 -5
- tasktree/executor.py +41 -3
- tasktree/graph.py +177 -1
- tasktree/hasher.py +32 -3
- tasktree/parser.py +196 -18
- tasktree/substitution.py +156 -1
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/METADATA +452 -1
- tasktree-0.0.20.dist-info/RECORD +14 -0
- tasktree-0.0.18.dist-info/RECORD +0 -14
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/WHEEL +0 -0
- {tasktree-0.0.18.dist-info → tasktree-0.0.20.dist-info}/entry_points.txt +0 -0
tasktree/__init__.py
CHANGED
|
@@ -13,7 +13,9 @@ from tasktree.graph import (
|
|
|
13
13
|
TaskNotFoundError,
|
|
14
14
|
build_dependency_tree,
|
|
15
15
|
get_implicit_inputs,
|
|
16
|
+
resolve_dependency_output_references,
|
|
16
17
|
resolve_execution_order,
|
|
18
|
+
resolve_self_references,
|
|
17
19
|
)
|
|
18
20
|
from tasktree.hasher import hash_args, hash_task, make_cache_key
|
|
19
21
|
from tasktree.parser import Recipe, Task, find_recipe_file, parse_arg_spec, parse_recipe
|
|
@@ -28,7 +30,9 @@ __all__ = [
|
|
|
28
30
|
"TaskNotFoundError",
|
|
29
31
|
"build_dependency_tree",
|
|
30
32
|
"get_implicit_inputs",
|
|
33
|
+
"resolve_dependency_output_references",
|
|
31
34
|
"resolve_execution_order",
|
|
35
|
+
"resolve_self_references",
|
|
32
36
|
"hash_args",
|
|
33
37
|
"hash_task",
|
|
34
38
|
"make_cache_key",
|
tasktree/cli.py
CHANGED
|
@@ -14,7 +14,7 @@ from rich.tree import Tree
|
|
|
14
14
|
|
|
15
15
|
from tasktree import __version__
|
|
16
16
|
from tasktree.executor import Executor
|
|
17
|
-
from tasktree.graph import build_dependency_tree, resolve_execution_order
|
|
17
|
+
from tasktree.graph import build_dependency_tree, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
|
|
18
18
|
from tasktree.hasher import hash_task, hash_args
|
|
19
19
|
from tasktree.parser import Recipe, find_recipe_file, parse_arg_spec, parse_recipe
|
|
20
20
|
from tasktree.state import StateManager
|
|
@@ -110,8 +110,13 @@ def _list_tasks(tasks_file: Optional[str] = None):
|
|
|
110
110
|
console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
|
|
111
111
|
raise typer.Exit(1)
|
|
112
112
|
|
|
113
|
-
# Calculate maximum task name length for fixed-width column
|
|
114
|
-
|
|
113
|
+
# Calculate maximum task name length for fixed-width column (only visible tasks)
|
|
114
|
+
visible_task_names = []
|
|
115
|
+
for name in recipe.task_names():
|
|
116
|
+
task = recipe.get_task(name)
|
|
117
|
+
if task and not task.private:
|
|
118
|
+
visible_task_names.append(name)
|
|
119
|
+
max_task_name_len = max(len(name) for name in visible_task_names) if visible_task_names else 0
|
|
115
120
|
|
|
116
121
|
# Create borderless table with three columns
|
|
117
122
|
table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
|
|
@@ -127,6 +132,9 @@ def _list_tasks(tasks_file: Optional[str] = None):
|
|
|
127
132
|
|
|
128
133
|
for task_name in sorted(recipe.task_names()):
|
|
129
134
|
task = recipe.get_task(task_name)
|
|
135
|
+
# Skip private tasks in list output
|
|
136
|
+
if task and task.private:
|
|
137
|
+
continue
|
|
130
138
|
desc = task.desc if task else ""
|
|
131
139
|
args_formatted = _format_task_arguments(task.args) if task else ""
|
|
132
140
|
|
|
@@ -344,7 +352,9 @@ def main(
|
|
|
344
352
|
|
|
345
353
|
console.print("[bold]Available tasks:[/bold]")
|
|
346
354
|
for task_name in sorted(recipe.task_names()):
|
|
347
|
-
|
|
355
|
+
task = recipe.get_task(task_name)
|
|
356
|
+
if task and not task.private:
|
|
357
|
+
console.print(f" - {task_name}")
|
|
348
358
|
console.print("\nUse [cyan]tt --list[/cyan] for detailed information")
|
|
349
359
|
console.print("Use [cyan]tt <task-name>[/cyan] to run a task")
|
|
350
360
|
|
|
@@ -437,7 +447,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
437
447
|
console.print(f"[red]Task not found: {task_name}[/red]")
|
|
438
448
|
console.print("\nAvailable tasks:")
|
|
439
449
|
for name in sorted(recipe.task_names()):
|
|
440
|
-
|
|
450
|
+
task = recipe.get_task(name)
|
|
451
|
+
if task and not task.private:
|
|
452
|
+
console.print(f" - {name}")
|
|
441
453
|
raise typer.Exit(1)
|
|
442
454
|
|
|
443
455
|
# Parse task arguments
|
|
@@ -452,6 +464,18 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
|
|
|
452
464
|
# This is important for correct state pruning after template substitution
|
|
453
465
|
execution_order = resolve_execution_order(recipe, task_name, args_dict)
|
|
454
466
|
|
|
467
|
+
try:
|
|
468
|
+
# Resolve dependency output references in topological order
|
|
469
|
+
# This substitutes {{ dep.*.outputs.* }} templates before execution
|
|
470
|
+
resolve_dependency_output_references(recipe, execution_order)
|
|
471
|
+
|
|
472
|
+
# Resolve self-references in topological order
|
|
473
|
+
# This substitutes {{ self.inputs.* }} and {{ self.outputs.* }} templates
|
|
474
|
+
resolve_self_references(recipe, execution_order)
|
|
475
|
+
except ValueError as e:
|
|
476
|
+
console.print(f"[red]Error in task template: {e}[/red]")
|
|
477
|
+
raise typer.Exit(1)
|
|
478
|
+
|
|
455
479
|
# Prune state based on tasks that will actually execute (with their specific arguments)
|
|
456
480
|
# This ensures template-substituted dependencies are handled correctly
|
|
457
481
|
valid_hashes = set()
|
tasktree/executor.py
CHANGED
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from tasktree import docker as docker_module
|
|
17
|
-
from tasktree.graph import get_implicit_inputs, resolve_execution_order
|
|
17
|
+
from tasktree.graph import get_implicit_inputs, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
|
|
18
18
|
from tasktree.hasher import hash_args, hash_task, make_cache_key
|
|
19
19
|
from tasktree.parser import Recipe, Task, Environment
|
|
20
20
|
from tasktree.state import StateManager, TaskState
|
|
@@ -433,6 +433,14 @@ class Executor:
|
|
|
433
433
|
# Execute task and all dependencies
|
|
434
434
|
execution_order = resolve_execution_order(self.recipe, task_name, args_dict)
|
|
435
435
|
|
|
436
|
+
# Resolve dependency output references in topological order
|
|
437
|
+
# This substitutes {{ dep.*.outputs.* }} templates before execution
|
|
438
|
+
resolve_dependency_output_references(self.recipe, execution_order)
|
|
439
|
+
|
|
440
|
+
# Resolve self-references in topological order
|
|
441
|
+
# This substitutes {{ self.inputs.* }} and {{ self.outputs.* }} templates
|
|
442
|
+
resolve_self_references(self.recipe, execution_order)
|
|
443
|
+
|
|
436
444
|
# Single phase: Check and execute incrementally
|
|
437
445
|
statuses: dict[str, TaskStatus] = {}
|
|
438
446
|
for name, task_args in execution_order:
|
|
@@ -857,7 +865,15 @@ class Executor:
|
|
|
857
865
|
Returns:
|
|
858
866
|
List of input glob patterns
|
|
859
867
|
"""
|
|
860
|
-
|
|
868
|
+
# Extract paths from inputs (handle both anonymous strings and named dicts)
|
|
869
|
+
all_inputs = []
|
|
870
|
+
for inp in task.inputs:
|
|
871
|
+
if isinstance(inp, str):
|
|
872
|
+
all_inputs.append(inp)
|
|
873
|
+
elif isinstance(inp, dict):
|
|
874
|
+
# Named input - extract the path value(s)
|
|
875
|
+
all_inputs.extend(inp.values())
|
|
876
|
+
|
|
861
877
|
implicit_inputs = get_implicit_inputs(self.recipe, task)
|
|
862
878
|
all_inputs.extend(implicit_inputs)
|
|
863
879
|
return all_inputs
|
|
@@ -1042,6 +1058,25 @@ class Executor:
|
|
|
1042
1058
|
|
|
1043
1059
|
return changed_files
|
|
1044
1060
|
|
|
1061
|
+
def _expand_output_paths(self, task: Task) -> list[str]:
|
|
1062
|
+
"""Extract all output paths from task outputs (both named and anonymous).
|
|
1063
|
+
|
|
1064
|
+
Args:
|
|
1065
|
+
task: Task with outputs to extract
|
|
1066
|
+
|
|
1067
|
+
Returns:
|
|
1068
|
+
List of output path patterns (glob patterns as strings)
|
|
1069
|
+
"""
|
|
1070
|
+
paths = []
|
|
1071
|
+
for output in task.outputs:
|
|
1072
|
+
if isinstance(output, str):
|
|
1073
|
+
# Anonymous output: just the path string
|
|
1074
|
+
paths.append(output)
|
|
1075
|
+
elif isinstance(output, dict):
|
|
1076
|
+
# Named output: extract the path value
|
|
1077
|
+
paths.extend(output.values())
|
|
1078
|
+
return paths
|
|
1079
|
+
|
|
1045
1080
|
def _check_outputs_missing(self, task: Task) -> list[str]:
|
|
1046
1081
|
"""Check if any declared outputs are missing.
|
|
1047
1082
|
|
|
@@ -1057,7 +1092,10 @@ class Executor:
|
|
|
1057
1092
|
missing_patterns = []
|
|
1058
1093
|
base_path = self.recipe.project_root / task.working_dir
|
|
1059
1094
|
|
|
1060
|
-
|
|
1095
|
+
# Expand outputs to paths (handles both named and anonymous)
|
|
1096
|
+
output_paths = self._expand_output_paths(task)
|
|
1097
|
+
|
|
1098
|
+
for pattern in output_paths:
|
|
1061
1099
|
# Check if pattern has any matches
|
|
1062
1100
|
matches = list(base_path.glob(pattern))
|
|
1063
1101
|
if not matches:
|
tasktree/graph.py
CHANGED
|
@@ -306,6 +306,175 @@ def resolve_execution_order(
|
|
|
306
306
|
raise CycleError(f"Dependency cycle detected: {e}")
|
|
307
307
|
|
|
308
308
|
|
|
309
|
+
def resolve_dependency_output_references(
|
|
310
|
+
recipe: Recipe,
|
|
311
|
+
ordered_tasks: list[tuple[str, dict[str, Any]]],
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Resolve {{ dep.<task>.outputs.<name> }} references in topological order.
|
|
314
|
+
|
|
315
|
+
This function walks through tasks in dependency order (dependencies first) and
|
|
316
|
+
resolves any references to dependency outputs in task fields. Templates are
|
|
317
|
+
resolved in place, modifying the Task objects in the recipe.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
recipe: Recipe containing task definitions
|
|
321
|
+
ordered_tasks: List of (task_name, args) tuples in topological order
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
ValueError: If template references cannot be resolved (missing task,
|
|
325
|
+
missing output, task not in dependencies, etc.)
|
|
326
|
+
|
|
327
|
+
Example:
|
|
328
|
+
Given tasks in topological order: [('build', {}), ('deploy', {})]
|
|
329
|
+
If deploy.cmd contains "{{ dep.build.outputs.bundle }}", it will be
|
|
330
|
+
resolved to the actual output path from the build task.
|
|
331
|
+
"""
|
|
332
|
+
from tasktree.substitution import substitute_dependency_outputs
|
|
333
|
+
|
|
334
|
+
# Track which tasks have been resolved (for validation)
|
|
335
|
+
resolved_tasks = {}
|
|
336
|
+
|
|
337
|
+
for task_name, task_args in ordered_tasks:
|
|
338
|
+
task = recipe.tasks.get(task_name)
|
|
339
|
+
if task is None:
|
|
340
|
+
continue # Skip if task doesn't exist (shouldn't happen)
|
|
341
|
+
|
|
342
|
+
# Get list of dependency task names for this task
|
|
343
|
+
dep_task_names = []
|
|
344
|
+
for dep_spec in task.deps:
|
|
345
|
+
# Handle both string and dict dependency specs
|
|
346
|
+
if isinstance(dep_spec, str):
|
|
347
|
+
dep_task_names.append(dep_spec)
|
|
348
|
+
elif isinstance(dep_spec, dict):
|
|
349
|
+
# Dict spec: {"task_name": [args]}
|
|
350
|
+
dep_task_names.append(list(dep_spec.keys())[0])
|
|
351
|
+
|
|
352
|
+
# Resolve output references in command
|
|
353
|
+
if task.cmd:
|
|
354
|
+
task.cmd = substitute_dependency_outputs(
|
|
355
|
+
task.cmd,
|
|
356
|
+
task_name,
|
|
357
|
+
dep_task_names,
|
|
358
|
+
resolved_tasks,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Resolve output references in working_dir
|
|
362
|
+
if task.working_dir:
|
|
363
|
+
task.working_dir = substitute_dependency_outputs(
|
|
364
|
+
task.working_dir,
|
|
365
|
+
task_name,
|
|
366
|
+
dep_task_names,
|
|
367
|
+
resolved_tasks,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Resolve output references in outputs
|
|
371
|
+
resolved_outputs = []
|
|
372
|
+
for output in task.outputs:
|
|
373
|
+
if isinstance(output, str):
|
|
374
|
+
resolved_outputs.append(
|
|
375
|
+
substitute_dependency_outputs(
|
|
376
|
+
output,
|
|
377
|
+
task_name,
|
|
378
|
+
dep_task_names,
|
|
379
|
+
resolved_tasks,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
elif isinstance(output, dict):
|
|
383
|
+
# Named output: resolve the path value
|
|
384
|
+
resolved_dict = {}
|
|
385
|
+
for name, path in output.items():
|
|
386
|
+
resolved_dict[name] = substitute_dependency_outputs(
|
|
387
|
+
path,
|
|
388
|
+
task_name,
|
|
389
|
+
dep_task_names,
|
|
390
|
+
resolved_tasks,
|
|
391
|
+
)
|
|
392
|
+
resolved_outputs.append(resolved_dict)
|
|
393
|
+
task.outputs = resolved_outputs
|
|
394
|
+
|
|
395
|
+
# Rebuild output maps after resolution
|
|
396
|
+
task.__post_init__()
|
|
397
|
+
|
|
398
|
+
# Resolve output references in argument defaults
|
|
399
|
+
if task.args:
|
|
400
|
+
for arg_spec in task.args:
|
|
401
|
+
if isinstance(arg_spec, dict):
|
|
402
|
+
# Get arg name and details
|
|
403
|
+
for arg_name, arg_details in arg_spec.items():
|
|
404
|
+
if isinstance(arg_details, dict) and "default" in arg_details:
|
|
405
|
+
if isinstance(arg_details["default"], str):
|
|
406
|
+
arg_details["default"] = substitute_dependency_outputs(
|
|
407
|
+
arg_details["default"],
|
|
408
|
+
task_name,
|
|
409
|
+
dep_task_names,
|
|
410
|
+
resolved_tasks,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Mark this task as resolved for future references
|
|
414
|
+
resolved_tasks[task_name] = task
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def resolve_self_references(
|
|
418
|
+
recipe: Recipe,
|
|
419
|
+
ordered_tasks: list[tuple[str, dict[str, Any]]],
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Resolve {{ self.inputs.name }} and {{ self.outputs.name }} references.
|
|
422
|
+
|
|
423
|
+
This function walks through tasks and resolves self-references to task's own
|
|
424
|
+
inputs/outputs. Must be called AFTER resolve_dependency_output_references()
|
|
425
|
+
so that dependency outputs are already resolved in output paths.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
recipe: Recipe containing task definitions
|
|
429
|
+
ordered_tasks: List of (task_name, args) tuples in topological order
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
ValueError: If self-reference cannot be resolved (missing name, etc.)
|
|
433
|
+
|
|
434
|
+
Example:
|
|
435
|
+
If task.cmd contains "{{ self.inputs.src }}" and task has input {src: "*.txt"},
|
|
436
|
+
it will be resolved to "*.txt" (literal string, no glob expansion).
|
|
437
|
+
"""
|
|
438
|
+
from tasktree.substitution import substitute_self_references
|
|
439
|
+
|
|
440
|
+
for task_name, task_args in ordered_tasks:
|
|
441
|
+
task = recipe.tasks.get(task_name)
|
|
442
|
+
if task is None:
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
# Resolve self-references in command
|
|
446
|
+
if task.cmd:
|
|
447
|
+
task.cmd = substitute_self_references(
|
|
448
|
+
task.cmd,
|
|
449
|
+
task_name,
|
|
450
|
+
task._input_map,
|
|
451
|
+
task._output_map,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Resolve self-references in working_dir
|
|
455
|
+
if task.working_dir:
|
|
456
|
+
task.working_dir = substitute_self_references(
|
|
457
|
+
task.working_dir,
|
|
458
|
+
task_name,
|
|
459
|
+
task._input_map,
|
|
460
|
+
task._output_map,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Resolve self-references in argument defaults
|
|
464
|
+
if task.args:
|
|
465
|
+
for arg_spec in task.args:
|
|
466
|
+
if isinstance(arg_spec, dict):
|
|
467
|
+
for arg_name, arg_details in arg_spec.items():
|
|
468
|
+
if isinstance(arg_details, dict) and "default" in arg_details:
|
|
469
|
+
if isinstance(arg_details["default"], str):
|
|
470
|
+
arg_details["default"] = substitute_self_references(
|
|
471
|
+
arg_details["default"],
|
|
472
|
+
task_name,
|
|
473
|
+
task._input_map,
|
|
474
|
+
task._output_map,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
309
478
|
def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
|
|
310
479
|
"""Get implicit inputs for a task based on its dependencies.
|
|
311
480
|
|
|
@@ -336,7 +505,14 @@ def get_implicit_inputs(recipe: Recipe, task: Task) -> list[str]:
|
|
|
336
505
|
|
|
337
506
|
# If dependency has outputs, inherit them
|
|
338
507
|
if dep_task.outputs:
|
|
339
|
-
|
|
508
|
+
# Extract paths from both named and anonymous outputs
|
|
509
|
+
for output in dep_task.outputs:
|
|
510
|
+
if isinstance(output, str):
|
|
511
|
+
# Anonymous output: just the path
|
|
512
|
+
implicit_inputs.append(output)
|
|
513
|
+
elif isinstance(output, dict):
|
|
514
|
+
# Named output: extract path values
|
|
515
|
+
implicit_inputs.extend(output.values())
|
|
340
516
|
# If dependency has no outputs, inherit its inputs
|
|
341
517
|
elif dep_task.inputs:
|
|
342
518
|
implicit_inputs.extend(dep_task.inputs)
|
tasktree/hasher.py
CHANGED
|
@@ -37,9 +37,38 @@ def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | di
|
|
|
37
37
|
return normalized_args
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _serialize_outputs_for_hash(outputs: list[str | dict[str, str]]) -> list[str]:
|
|
41
|
+
"""Serialize outputs to consistent list of strings for hashing.
|
|
42
|
+
|
|
43
|
+
Converts both named outputs (dicts) and anonymous outputs (strings)
|
|
44
|
+
into a consistent, sortable format.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
outputs: List of output specifications (strings or dicts)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of serialized output strings in sorted order
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> _serialize_outputs_for_hash(["file.txt", {"bundle": "app.js"}])
|
|
54
|
+
['bundle:app.js', 'file.txt']
|
|
55
|
+
"""
|
|
56
|
+
serialized = []
|
|
57
|
+
for output in outputs:
|
|
58
|
+
if isinstance(output, str):
|
|
59
|
+
# Anonymous output: just the path
|
|
60
|
+
serialized.append(output)
|
|
61
|
+
elif isinstance(output, dict):
|
|
62
|
+
# Named output: serialize as "name:path" for each entry
|
|
63
|
+
# Sort dict items for consistent ordering
|
|
64
|
+
for name, path in sorted(output.items()):
|
|
65
|
+
serialized.append(f"{name}:{path}")
|
|
66
|
+
return sorted(serialized)
|
|
67
|
+
|
|
68
|
+
|
|
40
69
|
def hash_task(
|
|
41
70
|
cmd: str,
|
|
42
|
-
outputs: list[str],
|
|
71
|
+
outputs: list[str | dict[str, str]],
|
|
43
72
|
working_dir: str,
|
|
44
73
|
args: list[str | dict[str, Any]],
|
|
45
74
|
env: str = "",
|
|
@@ -49,7 +78,7 @@ def hash_task(
|
|
|
49
78
|
|
|
50
79
|
Args:
|
|
51
80
|
cmd: Task command
|
|
52
|
-
outputs: Task outputs
|
|
81
|
+
outputs: Task outputs (strings or named dicts)
|
|
53
82
|
working_dir: Working directory
|
|
54
83
|
args: Task argument specifications
|
|
55
84
|
env: Environment name
|
|
@@ -60,7 +89,7 @@ def hash_task(
|
|
|
60
89
|
"""
|
|
61
90
|
data = {
|
|
62
91
|
"cmd": cmd,
|
|
63
|
-
"outputs":
|
|
92
|
+
"outputs": _serialize_outputs_for_hash(outputs),
|
|
64
93
|
"working_dir": working_dir,
|
|
65
94
|
"args": sorted(_normalize_choices_lists(args), key=_arg_sort_key),
|
|
66
95
|
"env": env,
|