tasktree 0.0.14__tar.gz → 0.0.16__tar.gz

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.
Files changed (77) hide show
  1. {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/validate-pipx-install.yml +13 -0
  2. {tasktree-0.0.14 → tasktree-0.0.16}/PKG-INFO +1 -1
  3. {tasktree-0.0.14 → tasktree-0.0.16}/pyproject.toml +1 -1
  4. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/cli.py +40 -12
  5. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/executor.py +61 -0
  6. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/graph.py +169 -5
  7. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/parser.py +463 -30
  8. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/substitution.py +74 -0
  9. tasktree-0.0.16/tests/integration/test_builtin_variables.py +573 -0
  10. tasktree-0.0.16/tests/integration/test_parameterized_deps_templates.py +232 -0
  11. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_variables.py +3 -3
  12. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_docker.py +139 -0
  13. tasktree-0.0.14/requirements/implemented/01-basic-variables.md +0 -142
  14. tasktree-0.0.14/requirements/implemented/02-env-variable-type.md +0 -174
  15. tasktree-0.0.14/requirements/implemented/03-direct-env-substitution.md +0 -180
  16. tasktree-0.0.14/requirements/implemented/04-file-read-variables.md +0 -240
  17. tasktree-0.0.14/requirements/implemented/bug-report-dependency-triggering.md +0 -222
  18. tasktree-0.0.14/requirements/implemented/docker-task-environments.md +0 -1002
  19. tasktree-0.0.14/requirements/implemented/shell-environment-requirements.md +0 -393
  20. tasktree-0.0.14/src/tasktree/tasks.py +0 -8
  21. tasktree-0.0.14/tests/integration/test_builtin_variables.py +0 -268
  22. tasktree-0.0.14/tests/unit/test_tasks.py +0 -18
  23. {tasktree-0.0.14 → tasktree-0.0.16}/.claude/settings.local.json +0 -0
  24. {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/claude-code-review.yml +0 -0
  25. {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/claude.yml +0 -0
  26. {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/release.yml +0 -0
  27. {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/test.yml +0 -0
  28. {tasktree-0.0.14 → tasktree-0.0.16}/.gitignore +0 -0
  29. {tasktree-0.0.14 → tasktree-0.0.16}/.python-version +0 -0
  30. {tasktree-0.0.14 → tasktree-0.0.16}/CLAUDE.md +0 -0
  31. {tasktree-0.0.14 → tasktree-0.0.16}/README.md +0 -0
  32. {tasktree-0.0.14 → tasktree-0.0.16}/example/source.txt +0 -0
  33. {tasktree-0.0.14 → tasktree-0.0.16}/example/tasktree.yaml +0 -0
  34. {tasktree-0.0.14 → tasktree-0.0.16}/schema/README.md +0 -0
  35. {tasktree-0.0.14 → tasktree-0.0.16}/schema/tasktree-schema.json +0 -0
  36. {tasktree-0.0.14 → tasktree-0.0.16}/schema/vscode-settings-snippet.json +0 -0
  37. {tasktree-0.0.14 → tasktree-0.0.16}/src/__init__.py +0 -0
  38. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/__init__.py +0 -0
  39. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/docker.py +0 -0
  40. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/hasher.py +0 -0
  41. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/state.py +0 -0
  42. {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/types.py +0 -0
  43. {tasktree-0.0.14 → tasktree-0.0.16}/tasktree.yaml +0 -0
  44. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/__init__.py +0 -0
  45. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_basic.py +0 -0
  46. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_environment.py +0 -0
  47. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_ownership.py +0 -0
  48. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_volumes.py +0 -0
  49. {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_non_docker.py +0 -0
  50. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_arg_choices.py +0 -0
  51. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_arg_min_max.py +0 -0
  52. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_clean_state.py +0 -0
  53. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_cli_options.py +0 -0
  54. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_dependency_execution.py +0 -0
  55. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_docker_build_args.py +0 -0
  56. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_end_to_end.py +0 -0
  57. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_exported_args.py +0 -0
  58. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_input_detection.py +0 -0
  59. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_missing_outputs.py +0 -0
  60. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_nested_imports.py +0 -0
  61. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_parameterized_dependencies.yaml +0 -0
  62. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_parameterized_deps_execution.py +0 -0
  63. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_state_persistence.py +0 -0
  64. {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_working_directory.py +0 -0
  65. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_cli.py +0 -0
  66. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_dependency_parsing.py +0 -0
  67. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_environment_tracking.py +0 -0
  68. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_executor.py +0 -0
  69. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_graph.py +0 -0
  70. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_hasher.py +0 -0
  71. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_list_formatting.py +0 -0
  72. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_parameterized_graph.py +0 -0
  73. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_parser.py +0 -0
  74. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_state.py +0 -0
  75. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_substitution.py +0 -0
  76. {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_types.py +0 -0
  77. {tasktree-0.0.14 → tasktree-0.0.16}/uv.lock +0 -0
@@ -50,6 +50,12 @@ jobs:
50
50
  python -m pipx install tasktree
51
51
  shell: bash
52
52
 
53
+ - name: Get installed tt version
54
+ working-directory: example
55
+ run: |
56
+ tt --version
57
+ shell: bash
58
+
53
59
  - name: Test tt --list command
54
60
  working-directory: example
55
61
  run: |
@@ -103,6 +109,13 @@ jobs:
103
109
  pipx install --python python3.12 tasktree
104
110
  shell: bash
105
111
 
112
+ - name: Get installed tt version
113
+ working-directory: example
114
+ run: |
115
+ export PATH="$HOME/.local/bin:$PATH"
116
+ tt --version
117
+ shell: bash
118
+
106
119
  - name: Test tt --list command
107
120
  working-directory: example
108
121
  run: |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasktree
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: A task automation tool with incremental execution
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.14"
3
+ version = "0.0.16"
4
4
  description = "A task automation tool with incremental execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -14,8 +14,8 @@ 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
18
- from tasktree.hasher import hash_task
17
+ from tasktree.graph import build_dependency_tree, resolve_execution_order
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
21
21
  from tasktree.types import get_click_type
@@ -137,7 +137,8 @@ def _list_tasks(tasks_file: Optional[str] = None):
137
137
 
138
138
  def _show_task(task_name: str, tasks_file: Optional[str] = None):
139
139
  """Show task definition with syntax highlighting."""
140
- recipe = _get_recipe(tasks_file)
140
+ # Pass task_name as root_task for lazy variable evaluation
141
+ recipe = _get_recipe(tasks_file, root_task=task_name)
141
142
  if recipe is None:
142
143
  console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
143
144
  raise typer.Exit(1)
@@ -186,7 +187,8 @@ def _show_task(task_name: str, tasks_file: Optional[str] = None):
186
187
 
187
188
  def _show_tree(task_name: str, tasks_file: Optional[str] = None):
188
189
  """Show dependency tree structure."""
189
- recipe = _get_recipe(tasks_file)
190
+ # Pass task_name as root_task for lazy variable evaluation
191
+ recipe = _get_recipe(tasks_file, root_task=task_name)
190
192
  if recipe is None:
191
193
  console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
192
194
  raise typer.Exit(1)
@@ -372,11 +374,13 @@ def _clean_state(tasks_file: Optional[str] = None) -> None:
372
374
  console.print(f"[yellow]No state file found at {state_path}[/yellow]")
373
375
 
374
376
 
375
- def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
377
+ def _get_recipe(recipe_file: Optional[str] = None, root_task: Optional[str] = None) -> Optional[Recipe]:
376
378
  """Get parsed recipe or None if not found.
377
379
 
378
380
  Args:
379
381
  recipe_file: Optional path to recipe file. If not provided, searches for recipe file.
382
+ root_task: Optional root task for lazy variable evaluation. If provided, only variables
383
+ reachable from this task will be evaluated (performance optimization).
380
384
  """
381
385
  if recipe_file:
382
386
  recipe_path = Path(recipe_file)
@@ -398,7 +402,7 @@ def _get_recipe(recipe_file: Optional[str] = None) -> Optional[Recipe]:
398
402
  project_root = None
399
403
 
400
404
  try:
401
- return parse_recipe(recipe_path, project_root)
405
+ return parse_recipe(recipe_path, project_root, root_task)
402
406
  except Exception as e:
403
407
  console.print(f"[red]Error parsing recipe: {e}[/red]")
404
408
  raise typer.Exit(1)
@@ -411,7 +415,8 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
411
415
  task_name = args[0]
412
416
  task_args = args[1:]
413
417
 
414
- recipe = _get_recipe(tasks_file)
418
+ # Pass task_name as root_task for lazy variable evaluation
419
+ recipe = _get_recipe(tasks_file, root_task=task_name)
415
420
  if recipe is None:
416
421
  console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
417
422
  raise typer.Exit(1)
@@ -443,11 +448,34 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
443
448
  state.load()
444
449
  executor = Executor(recipe, state)
445
450
 
446
- # Prune state before execution (compute hashes with effective environment and dependencies)
447
- valid_hashes = {
448
- hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t), t.deps)
449
- for t in recipe.tasks.values()
450
- }
451
+ # Resolve execution order to determine which tasks will actually run
452
+ # This is important for correct state pruning after template substitution
453
+ execution_order = resolve_execution_order(recipe, task_name, args_dict)
454
+
455
+ # Prune state based on tasks that will actually execute (with their specific arguments)
456
+ # This ensures template-substituted dependencies are handled correctly
457
+ valid_hashes = set()
458
+ for exec_task_name, exec_task_args in execution_order:
459
+ task = recipe.tasks[exec_task_name]
460
+ # Compute base task hash
461
+ task_hash = hash_task(
462
+ task.cmd,
463
+ task.outputs,
464
+ task.working_dir,
465
+ task.args,
466
+ executor._get_effective_env_name(task),
467
+ task.deps
468
+ )
469
+
470
+ # If task has arguments, append args hash to create unique cache key
471
+ if exec_task_args:
472
+ args_hash = hash_args(exec_task_args)
473
+ cache_key = f"{task_hash}__{args_hash}"
474
+ else:
475
+ cache_key = task_hash
476
+
477
+ valid_hashes.add(cache_key)
478
+
451
479
  state.prune(valid_hashes)
452
480
  state.save()
453
481
  try:
@@ -662,6 +662,60 @@ class Executor:
662
662
  except OSError:
663
663
  pass # Ignore cleanup errors
664
664
 
665
+ def _substitute_builtin_in_environment(self, env: Environment, builtin_vars: dict[str, str]) -> Environment:
666
+ """Substitute builtin and environment variables in environment fields.
667
+
668
+ Args:
669
+ env: Environment to process
670
+ builtin_vars: Built-in variable values
671
+
672
+ Returns:
673
+ New Environment with builtin and environment variables substituted
674
+
675
+ Raises:
676
+ ValueError: If builtin variable or environment variable is not defined
677
+ """
678
+ from dataclasses import replace
679
+
680
+ # Substitute in volumes (builtin vars first, then env vars)
681
+ substituted_volumes = [
682
+ self._substitute_env(self._substitute_builtin(vol, builtin_vars)) for vol in env.volumes
683
+ ] if env.volumes else []
684
+
685
+ # Substitute in env_vars values (builtin vars first, then env vars)
686
+ substituted_env_vars = {
687
+ key: self._substitute_env(self._substitute_builtin(value, builtin_vars))
688
+ for key, value in env.env_vars.items()
689
+ } if env.env_vars else {}
690
+
691
+ # Substitute in ports (builtin vars first, then env vars)
692
+ substituted_ports = [
693
+ self._substitute_env(self._substitute_builtin(port, builtin_vars)) for port in env.ports
694
+ ] if env.ports else []
695
+
696
+ # Substitute in working_dir (builtin vars first, then env vars)
697
+ substituted_working_dir = self._substitute_env(self._substitute_builtin(env.working_dir, builtin_vars)) if env.working_dir else ""
698
+
699
+ # Substitute in build args (for Docker environments, args is a dict)
700
+ # Apply builtin vars first, then env vars
701
+ if isinstance(env.args, dict):
702
+ substituted_args = {
703
+ key: self._substitute_env(self._substitute_builtin(str(value), builtin_vars))
704
+ for key, value in env.args.items()
705
+ }
706
+ else:
707
+ substituted_args = env.args
708
+
709
+ # Create new environment with substituted values
710
+ return replace(
711
+ env,
712
+ volumes=substituted_volumes,
713
+ env_vars=substituted_env_vars,
714
+ ports=substituted_ports,
715
+ working_dir=substituted_working_dir,
716
+ args=substituted_args
717
+ )
718
+
665
719
  def _run_task_in_docker(
666
720
  self, task: Task, env: Any, cmd: str, working_dir: Path,
667
721
  exported_env_vars: dict[str, str] | None = None
@@ -678,6 +732,13 @@ class Executor:
678
732
  Raises:
679
733
  ExecutionError: If Docker execution fails
680
734
  """
735
+ # Get builtin variables for substitution in environment fields
736
+ task_start_time = datetime.now(timezone.utc)
737
+ builtin_vars = self._collect_builtin_variables(task, working_dir, task_start_time)
738
+
739
+ # Substitute builtin variables in environment fields (volumes, env_vars, etc.)
740
+ env = self._substitute_builtin_in_environment(env, builtin_vars)
741
+
681
742
  # Resolve container working directory
682
743
  container_working_dir = docker_module.resolve_container_working_dir(
683
744
  env.working_dir, task.working_dir
@@ -5,7 +5,161 @@ from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from tasktree.hasher import hash_args
8
- from tasktree.parser import Recipe, Task, DependencyInvocation, parse_dependency_spec
8
+ from tasktree.parser import (
9
+ Recipe,
10
+ Task,
11
+ DependencyInvocation,
12
+ parse_dependency_spec,
13
+ parse_arg_spec,
14
+ )
15
+ from tasktree.substitution import substitute_dependency_args
16
+
17
+
18
+ def _get_exported_arg_names(task: Task) -> set[str]:
19
+ """Extract names of exported arguments from a task.
20
+
21
+ Exported arguments are identified by the '$' prefix in their definition.
22
+
23
+ Args:
24
+ task: Task to extract exported arg names from
25
+
26
+ Returns:
27
+ Set of exported argument names (without the '$' prefix)
28
+
29
+ Example:
30
+ Task with args: ["$server", "port"]
31
+ Returns: {"server"}
32
+ """
33
+ if not task.args:
34
+ return set()
35
+
36
+ exported = set()
37
+ for arg_spec in task.args:
38
+ if isinstance(arg_spec, str):
39
+ # Simple string format: "$argname" or "argname"
40
+ if arg_spec.startswith("$"):
41
+ exported.add(arg_spec[1:]) # Remove '$' prefix
42
+ elif isinstance(arg_spec, dict):
43
+ # Dictionary format: {"$argname": {...}} or {"argname": {...}}
44
+ for arg_name in arg_spec.keys():
45
+ if arg_name.startswith("$"):
46
+ exported.add(arg_name[1:]) # Remove '$' prefix
47
+
48
+ return exported
49
+
50
+
51
+ def resolve_dependency_invocation(
52
+ dep_spec: str | dict[str, Any],
53
+ parent_task_name: str,
54
+ parent_args: dict[str, Any],
55
+ parent_exported_args: set[str],
56
+ recipe: Recipe
57
+ ) -> DependencyInvocation:
58
+ """Parse dependency specification and substitute parent argument templates.
59
+
60
+ This function handles template substitution in dependency arguments. It:
61
+ 1. Checks if dependency arguments contain {{ arg.* }} templates
62
+ 2. Substitutes templates using parent task's arguments
63
+ 3. Delegates to parse_dependency_spec for type conversion and validation
64
+
65
+ Args:
66
+ dep_spec: Dependency specification (str or dict with task name and args)
67
+ parent_task_name: Name of the parent task (for error messages)
68
+ parent_args: Parent task's argument values (for template substitution)
69
+ parent_exported_args: Set of parent's exported argument names
70
+ recipe: Recipe containing task definitions
71
+
72
+ Returns:
73
+ DependencyInvocation with typed, validated arguments
74
+
75
+ Raises:
76
+ ValueError: If template substitution fails, argument validation fails,
77
+ or dependency task doesn't exist
78
+
79
+ Examples:
80
+ Simple string (no templates):
81
+ >>> resolve_dependency_invocation("build", "test", {}, set(), recipe)
82
+ DependencyInvocation("build", None)
83
+
84
+ Literal arguments (no templates):
85
+ >>> resolve_dependency_invocation({"build": ["debug"]}, "test", {}, set(), recipe)
86
+ DependencyInvocation("build", {"mode": "debug"})
87
+
88
+ Template substitution:
89
+ >>> resolve_dependency_invocation(
90
+ ... {"build": ["{{ arg.env }}"]},
91
+ ... "test",
92
+ ... {"env": "production"},
93
+ ... set(),
94
+ ... recipe
95
+ ... )
96
+ DependencyInvocation("build", {"mode": "production"})
97
+ """
98
+ # Simple string case - no args to substitute
99
+ if isinstance(dep_spec, str):
100
+ return parse_dependency_spec(dep_spec, recipe)
101
+
102
+ # Dictionary case: {"task_name": args_spec}
103
+ if not isinstance(dep_spec, dict) or len(dep_spec) != 1:
104
+ # Invalid format, let parse_dependency_spec handle the error
105
+ return parse_dependency_spec(dep_spec, recipe)
106
+
107
+ task_name, arg_spec = next(iter(dep_spec.items()))
108
+
109
+ # Check if any argument values contain templates
110
+ has_templates = False
111
+ if isinstance(arg_spec, list):
112
+ # Positional args: check each value
113
+ for val in arg_spec:
114
+ if isinstance(val, str) and "{{ arg." in val:
115
+ has_templates = True
116
+ break
117
+ elif isinstance(arg_spec, dict):
118
+ # Named args: check each value
119
+ for val in arg_spec.values():
120
+ if isinstance(val, str) and "{{ arg." in val:
121
+ has_templates = True
122
+ break
123
+
124
+ # If no templates, use existing parser (fast path for backward compatibility)
125
+ if not has_templates:
126
+ return parse_dependency_spec(dep_spec, recipe)
127
+
128
+ # Template substitution path
129
+ # Substitute {{ arg.* }} in argument values
130
+ substituted_arg_spec: list[Any] | dict[str, Any]
131
+
132
+ if isinstance(arg_spec, list):
133
+ # Positional args: substitute each value that's a string
134
+ substituted_arg_spec = []
135
+ for val in arg_spec:
136
+ if isinstance(val, str):
137
+ substituted_val = substitute_dependency_args(
138
+ val, parent_task_name, parent_args, parent_exported_args
139
+ )
140
+ substituted_arg_spec.append(substituted_val)
141
+ else:
142
+ # Non-string values (bool, int, etc.) pass through unchanged
143
+ substituted_arg_spec.append(val)
144
+ elif isinstance(arg_spec, dict):
145
+ # Named args: substitute each string value
146
+ substituted_arg_spec = {}
147
+ for arg_name, val in arg_spec.items():
148
+ if isinstance(val, str):
149
+ substituted_val = substitute_dependency_args(
150
+ val, parent_task_name, parent_args, parent_exported_args
151
+ )
152
+ substituted_arg_spec[arg_name] = substituted_val
153
+ else:
154
+ # Non-string values pass through unchanged
155
+ substituted_arg_spec[arg_name] = val
156
+ else:
157
+ # Invalid format, let parse_dependency_spec handle it
158
+ return parse_dependency_spec(dep_spec, recipe)
159
+
160
+ # Create new dep_spec with substituted values and parse it
161
+ substituted_dep_spec = {task_name: substituted_arg_spec}
162
+ return parse_dependency_spec(substituted_dep_spec, recipe)
9
163
 
10
164
 
11
165
  class CycleError(Exception):
@@ -99,7 +253,7 @@ def resolve_execution_order(
99
253
  return seen_invocations[key]
100
254
 
101
255
  def build_graph(node: TaskNode) -> None:
102
- """Recursively build dependency graph."""
256
+ """Recursively build dependency graph with template substitution."""
103
257
  if node in graph:
104
258
  # Already processed
105
259
  return
@@ -108,11 +262,21 @@ def resolve_execution_order(
108
262
  if task is None:
109
263
  raise TaskNotFoundError(f"Task not found: {node.task_name}")
110
264
 
111
- # Parse and normalize dependencies
265
+ # Get parent task's exported argument names
266
+ parent_exported_args = _get_exported_arg_names(task)
267
+
268
+ # Parse and normalize dependencies with template substitution
112
269
  dep_nodes = set()
113
270
  for dep_spec in task.deps:
114
- # Parse dependency specification
115
- dep_inv = parse_dependency_spec(dep_spec, recipe)
271
+ # Resolve dependency specification with parent context
272
+ # This handles template substitution if {{ arg.* }} is present
273
+ dep_inv = resolve_dependency_invocation(
274
+ dep_spec,
275
+ parent_task_name=node.task_name,
276
+ parent_args=node.args or {},
277
+ parent_exported_args=parent_exported_args,
278
+ recipe=recipe
279
+ )
116
280
 
117
281
  # Create or get node for this dependency invocation
118
282
  dep_node = get_or_create_node(dep_inv.task_name, dep_inv.args)