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 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
- max_task_name_len = max(len(name) for name in recipe.task_names()) if recipe.task_names() else 0
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
- console.print(f" - {task_name}")
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
- console.print(f" - {name}")
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
- all_inputs = list(task.inputs)
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
- for pattern in task.outputs:
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
- implicit_inputs.extend(dep_task.outputs)
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": sorted(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,