tasktree 0.0.13__tar.gz → 0.0.15__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 (76) hide show
  1. {tasktree-0.0.13 → tasktree-0.0.15}/.github/workflows/validate-pipx-install.yml +13 -0
  2. {tasktree-0.0.13 → tasktree-0.0.15}/PKG-INFO +1 -1
  3. {tasktree-0.0.13 → tasktree-0.0.15}/example/tasktree.yaml +5 -1
  4. {tasktree-0.0.13 → tasktree-0.0.15}/pyproject.toml +1 -1
  5. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/cli.py +30 -7
  6. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/graph.py +169 -5
  7. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/parser.py +192 -1
  8. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/substitution.py +104 -15
  9. tasktree-0.0.15/tests/integration/test_parameterized_deps_templates.py +232 -0
  10. tasktree-0.0.13/requirements/implemented/01-basic-variables.md +0 -142
  11. tasktree-0.0.13/requirements/implemented/02-env-variable-type.md +0 -174
  12. tasktree-0.0.13/requirements/implemented/03-direct-env-substitution.md +0 -180
  13. tasktree-0.0.13/requirements/implemented/04-file-read-variables.md +0 -240
  14. tasktree-0.0.13/requirements/implemented/bug-report-dependency-triggering.md +0 -222
  15. tasktree-0.0.13/requirements/implemented/docker-task-environments.md +0 -1002
  16. tasktree-0.0.13/requirements/implemented/shell-environment-requirements.md +0 -393
  17. tasktree-0.0.13/src/tasktree/tasks.py +0 -8
  18. tasktree-0.0.13/tests/unit/test_tasks.py +0 -18
  19. {tasktree-0.0.13 → tasktree-0.0.15}/.claude/settings.local.json +0 -0
  20. {tasktree-0.0.13 → tasktree-0.0.15}/.github/workflows/claude-code-review.yml +0 -0
  21. {tasktree-0.0.13 → tasktree-0.0.15}/.github/workflows/claude.yml +0 -0
  22. {tasktree-0.0.13 → tasktree-0.0.15}/.github/workflows/release.yml +0 -0
  23. {tasktree-0.0.13 → tasktree-0.0.15}/.github/workflows/test.yml +0 -0
  24. {tasktree-0.0.13 → tasktree-0.0.15}/.gitignore +0 -0
  25. {tasktree-0.0.13 → tasktree-0.0.15}/.python-version +0 -0
  26. {tasktree-0.0.13 → tasktree-0.0.15}/CLAUDE.md +0 -0
  27. {tasktree-0.0.13 → tasktree-0.0.15}/README.md +0 -0
  28. {tasktree-0.0.13 → tasktree-0.0.15}/example/source.txt +0 -0
  29. {tasktree-0.0.13 → tasktree-0.0.15}/schema/README.md +0 -0
  30. {tasktree-0.0.13 → tasktree-0.0.15}/schema/tasktree-schema.json +0 -0
  31. {tasktree-0.0.13 → tasktree-0.0.15}/schema/vscode-settings-snippet.json +0 -0
  32. {tasktree-0.0.13 → tasktree-0.0.15}/src/__init__.py +0 -0
  33. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/__init__.py +0 -0
  34. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/docker.py +0 -0
  35. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/executor.py +0 -0
  36. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/hasher.py +0 -0
  37. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/state.py +0 -0
  38. {tasktree-0.0.13 → tasktree-0.0.15}/src/tasktree/types.py +0 -0
  39. {tasktree-0.0.13 → tasktree-0.0.15}/tasktree.yaml +0 -0
  40. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/__init__.py +0 -0
  41. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/test_docker_basic.py +0 -0
  42. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/test_docker_environment.py +0 -0
  43. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/test_docker_ownership.py +0 -0
  44. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/test_docker_volumes.py +0 -0
  45. {tasktree-0.0.13 → tasktree-0.0.15}/tests/e2e/test_non_docker.py +0 -0
  46. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_arg_choices.py +0 -0
  47. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_arg_min_max.py +0 -0
  48. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_builtin_variables.py +0 -0
  49. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_clean_state.py +0 -0
  50. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_cli_options.py +0 -0
  51. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_dependency_execution.py +0 -0
  52. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_docker_build_args.py +0 -0
  53. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_end_to_end.py +0 -0
  54. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_exported_args.py +0 -0
  55. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_input_detection.py +0 -0
  56. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_missing_outputs.py +0 -0
  57. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_nested_imports.py +0 -0
  58. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_parameterized_dependencies.yaml +0 -0
  59. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_parameterized_deps_execution.py +0 -0
  60. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_state_persistence.py +0 -0
  61. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_variables.py +0 -0
  62. {tasktree-0.0.13 → tasktree-0.0.15}/tests/integration/test_working_directory.py +0 -0
  63. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_cli.py +0 -0
  64. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_dependency_parsing.py +0 -0
  65. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_docker.py +0 -0
  66. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_environment_tracking.py +0 -0
  67. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_executor.py +0 -0
  68. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_graph.py +0 -0
  69. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_hasher.py +0 -0
  70. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_list_formatting.py +0 -0
  71. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_parameterized_graph.py +0 -0
  72. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_parser.py +0 -0
  73. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_state.py +0 -0
  74. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_substitution.py +0 -0
  75. {tasktree-0.0.13 → tasktree-0.0.15}/tests/unit/test_types.py +0 -0
  76. {tasktree-0.0.13 → tasktree-0.0.15}/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.13
3
+ Version: 0.0.15
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,9 +1,13 @@
1
+ variables:
2
+ log_level: { env: LOG_LEVEL, default: "info" }
3
+
1
4
  tasks:
2
5
  build:
3
6
  desc: Building outputs (imagine this is a call to Cargo, or gcc, or something)
4
7
  args:
5
8
  - build_type:
6
- choices: [ "debug", "release" ]
9
+ choices: [ "debug", "release", "{{ var.log_level }}" ]
10
+ default: "{{ var.log_level }}"
7
11
  - target:
8
12
  type: str
9
13
  choices:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tasktree"
3
- version = "0.0.13"
3
+ version = "0.0.15"
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
@@ -443,11 +443,34 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
443
443
  state.load()
444
444
  executor = Executor(recipe, state)
445
445
 
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
- }
446
+ # Resolve execution order to determine which tasks will actually run
447
+ # This is important for correct state pruning after template substitution
448
+ execution_order = resolve_execution_order(recipe, task_name, args_dict)
449
+
450
+ # Prune state based on tasks that will actually execute (with their specific arguments)
451
+ # This ensures template-substituted dependencies are handled correctly
452
+ valid_hashes = set()
453
+ for exec_task_name, exec_task_args in execution_order:
454
+ task = recipe.tasks[exec_task_name]
455
+ # Compute base task hash
456
+ task_hash = hash_task(
457
+ task.cmd,
458
+ task.outputs,
459
+ task.working_dir,
460
+ task.args,
461
+ executor._get_effective_env_name(task),
462
+ task.deps
463
+ )
464
+
465
+ # If task has arguments, append args hash to create unique cache key
466
+ if exec_task_args:
467
+ args_hash = hash_args(exec_task_args)
468
+ cache_key = f"{task_hash}__{args_hash}"
469
+ else:
470
+ cache_key = task_hash
471
+
472
+ valid_hashes.add(cache_key)
473
+
451
474
  state.prune(valid_hashes)
452
475
  state.save()
453
476
  try:
@@ -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)
@@ -89,6 +89,31 @@ class Task:
89
89
  )
90
90
 
91
91
 
92
+ @dataclass
93
+ class DependencySpec:
94
+ """Parsed dependency specification with potential template placeholders.
95
+
96
+ This represents a dependency as defined in the recipe file, before template
97
+ substitution. Argument values may contain {{ arg.* }} templates that will be
98
+ substituted with parent task's argument values during graph construction.
99
+
100
+ Attributes:
101
+ task_name: Name of the dependency task
102
+ arg_templates: Dictionary mapping argument names to string templates
103
+ (None if no args specified). All values are strings, even
104
+ for numeric types, to preserve template placeholders.
105
+ """
106
+ task_name: str
107
+ arg_templates: dict[str, str] | None = None
108
+
109
+ def __str__(self) -> str:
110
+ """String representation for display."""
111
+ if not self.arg_templates:
112
+ return self.task_name
113
+ args_str = ", ".join(f"{k}={v}" for k, v in self.arg_templates.items())
114
+ return f"{self.task_name}({args_str})"
115
+
116
+
92
117
  @dataclass
93
118
  class DependencyInvocation:
94
119
  """Represents a task dependency invocation with optional arguments.
@@ -992,13 +1017,171 @@ def _parse_file_with_env(
992
1017
  return tasks, environments, default_env, variables
993
1018
 
994
1019
 
995
- def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
1020
+ def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
1021
+ """Collect all tasks reachable from the root task via dependencies.
1022
+
1023
+ Uses BFS to traverse the dependency graph and collect all task names
1024
+ that could potentially be executed when running the root task.
1025
+
1026
+ Args:
1027
+ tasks: Dictionary mapping task names to Task objects
1028
+ root_task: Name of the root task to start traversal from
1029
+
1030
+ Returns:
1031
+ Set of task names reachable from root_task (includes root_task itself)
1032
+
1033
+ Raises:
1034
+ ValueError: If root_task doesn't exist
1035
+
1036
+ Example:
1037
+ >>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
1038
+ >>> collect_reachable_tasks(tasks, "a")
1039
+ {"a", "b"}
1040
+ """
1041
+ if root_task not in tasks:
1042
+ raise ValueError(f"Root task '{root_task}' not found in recipe")
1043
+
1044
+ reachable = set()
1045
+ queue = [root_task]
1046
+
1047
+ while queue:
1048
+ task_name = queue.pop(0)
1049
+
1050
+ if task_name in reachable:
1051
+ continue # Already processed
1052
+
1053
+ reachable.add(task_name)
1054
+
1055
+ # Get task and process its dependencies
1056
+ task = tasks.get(task_name)
1057
+ if task is None:
1058
+ # Task not found - will be caught during graph construction
1059
+ continue
1060
+
1061
+ # Add dependency task names to queue
1062
+ for dep_spec in task.deps:
1063
+ # Extract task name from dependency specification
1064
+ if isinstance(dep_spec, str):
1065
+ dep_name = dep_spec
1066
+ elif isinstance(dep_spec, dict) and len(dep_spec) == 1:
1067
+ dep_name = next(iter(dep_spec.keys()))
1068
+ else:
1069
+ # Invalid format - will be caught during graph construction
1070
+ continue
1071
+
1072
+ if dep_name not in reachable:
1073
+ queue.append(dep_name)
1074
+
1075
+ return reachable
1076
+
1077
+
1078
+ def collect_reachable_variables(
1079
+ tasks: dict[str, Task],
1080
+ reachable_task_names: set[str]
1081
+ ) -> set[str]:
1082
+ """Extract variable names used by reachable tasks.
1083
+
1084
+ Searches for {{ var.* }} placeholders in task definitions to determine
1085
+ which variables are actually needed for execution.
1086
+
1087
+ Args:
1088
+ tasks: Dictionary mapping task names to Task objects
1089
+ reachable_task_names: Set of task names that will be executed
1090
+
1091
+ Returns:
1092
+ Set of variable names referenced by reachable tasks
1093
+
1094
+ Example:
1095
+ >>> task = Task("build", cmd="echo {{ var.version }}")
1096
+ >>> collect_reachable_variables({"build": task}, {"build"})
1097
+ {"version"}
1098
+ """
1099
+ import re
1100
+
1101
+ # Pattern to match {{ var.name }}
1102
+ var_pattern = re.compile(r'\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}')
1103
+
1104
+ variables = set()
1105
+
1106
+ for task_name in reachable_task_names:
1107
+ task = tasks.get(task_name)
1108
+ if task is None:
1109
+ continue
1110
+
1111
+ # Search in command
1112
+ if task.cmd:
1113
+ for match in var_pattern.finditer(task.cmd):
1114
+ variables.add(match.group(1))
1115
+
1116
+ # Search in description
1117
+ if task.desc:
1118
+ for match in var_pattern.finditer(task.desc):
1119
+ variables.add(match.group(1))
1120
+
1121
+ # Search in working_dir
1122
+ if task.working_dir:
1123
+ for match in var_pattern.finditer(task.working_dir):
1124
+ variables.add(match.group(1))
1125
+
1126
+ # Search in inputs
1127
+ if task.inputs:
1128
+ for input_pattern in task.inputs:
1129
+ for match in var_pattern.finditer(input_pattern):
1130
+ variables.add(match.group(1))
1131
+
1132
+ # Search in outputs
1133
+ if task.outputs:
1134
+ for output_pattern in task.outputs:
1135
+ for match in var_pattern.finditer(output_pattern):
1136
+ variables.add(match.group(1))
1137
+
1138
+ # Search in argument defaults
1139
+ if task.args:
1140
+ for arg_spec in task.args:
1141
+ if isinstance(arg_spec, dict):
1142
+ for arg_dict in arg_spec.values():
1143
+ if isinstance(arg_dict, dict) and "default" in arg_dict:
1144
+ default = arg_dict["default"]
1145
+ if isinstance(default, str):
1146
+ for match in var_pattern.finditer(default):
1147
+ variables.add(match.group(1))
1148
+
1149
+ # Search in dependency argument templates
1150
+ if task.deps:
1151
+ for dep_spec in task.deps:
1152
+ if isinstance(dep_spec, dict):
1153
+ for arg_spec in dep_spec.values():
1154
+ # Positional args (list)
1155
+ if isinstance(arg_spec, list):
1156
+ for val in arg_spec:
1157
+ if isinstance(val, str):
1158
+ for match in var_pattern.finditer(val):
1159
+ variables.add(match.group(1))
1160
+ # Named args (dict)
1161
+ elif isinstance(arg_spec, dict):
1162
+ for val in arg_spec.values():
1163
+ if isinstance(val, str):
1164
+ for match in var_pattern.finditer(val):
1165
+ variables.add(match.group(1))
1166
+
1167
+ return variables
1168
+
1169
+
1170
+ def parse_recipe(
1171
+ recipe_path: Path,
1172
+ project_root: Path | None = None,
1173
+ root_task: str | None = None
1174
+ ) -> Recipe:
996
1175
  """Parse a recipe file and handle imports recursively.
997
1176
 
998
1177
  Args:
999
1178
  recipe_path: Path to the main recipe file
1000
1179
  project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
1001
1180
  When using --tasks option, this should be the current working directory.
1181
+ root_task: Optional root task for lazy variable evaluation. If provided, only variables
1182
+ used by tasks reachable from root_task will be evaluated (optimization).
1183
+ NOTE: Currently this parameter is accepted but lazy evaluation is not yet
1184
+ implemented - all variables are still evaluated for backward compatibility.
1002
1185
 
1003
1186
  Returns:
1004
1187
  Recipe object with all tasks (including recursively imported tasks)
@@ -1021,6 +1204,14 @@ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
1021
1204
  recipe_path, namespace=None, project_root=project_root
1022
1205
  )
1023
1206
 
1207
+ # TODO: Implement lazy variable evaluation when root_task is provided
1208
+ # This would require:
1209
+ # 1. Deferring variable evaluation until after task parsing
1210
+ # 2. Collecting reachable tasks and variables
1211
+ # 3. Evaluating only reachable variables
1212
+ # 4. Re-substituting variables into task definitions
1213
+ # For now, we evaluate all variables eagerly (current behavior)
1214
+
1024
1215
  return Recipe(
1025
1216
  tasks=tasks,
1026
1217
  project_root=project_root,
@@ -5,6 +5,7 @@ and {{ env.NAME }} placeholders with their corresponding values.
5
5
  """
6
6
 
7
7
  import re
8
+ from random import choice
8
9
  from typing import Any
9
10
 
10
11
 
@@ -15,11 +16,11 @@ PLACEHOLDER_PATTERN = re.compile(
15
16
  )
16
17
 
17
18
 
18
- def substitute_variables(text: str, variables: dict[str, str]) -> str:
19
+ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str]) -> str | dict[str, Any]:
19
20
  """Substitute {{ var.name }} placeholders with variable values.
20
21
 
21
22
  Args:
22
- text: Text containing {{ var.name }} placeholders
23
+ text: Text containing {{ var.name }} placeholders, or an argument dict with elements to be substituted
23
24
  variables: Dictionary mapping variable names to their string values
24
25
 
25
26
  Returns:
@@ -28,23 +29,42 @@ def substitute_variables(text: str, variables: dict[str, str]) -> str:
28
29
  Raises:
29
30
  ValueError: If a referenced variable is not defined
30
31
  """
31
- def replace_match(match: re.Match) -> str:
32
- prefix = match.group(1)
33
- name = match.group(2)
32
+ if isinstance(text, dict):
33
+ # The dict will only contain a single key, the value of this key should also be a dictionary, which contains
34
+ # the actual details of the argument.
35
+ assert len(text.keys()) == 1
34
36
 
35
- # Only substitute var: placeholders
36
- if prefix != "var":
37
- return match.group(0) # Return unchanged
37
+ for arg_name in text.keys():
38
+ # Pull out and substitute the individual fields of an argument one at a time
39
+ for field in [ "default", "min", "max" ]:
40
+ if field in text[arg_name]:
41
+ text[arg_name][field] = substitute_variables(text[arg_name][field], variables)
38
42
 
39
- if name not in variables:
40
- raise ValueError(
41
- f"Variable '{name}' is not defined. "
42
- f"Variables must be defined before use."
43
- )
43
+ # choices is a list of things
44
+ if "choices" in text[arg_name]:
45
+ text[arg_name]["choices"] = [substitute_variables(c, variables) for c in text[arg_name]["choices"]]
44
46
 
45
- return variables[name]
47
+ return text
48
+ else:
49
+ raise ValueError("Empty arg dictionary")
50
+ else:
51
+ def replace_match(match: re.Match) -> str:
52
+ prefix = match.group(1)
53
+ name = match.group(2)
46
54
 
47
- return PLACEHOLDER_PATTERN.sub(replace_match, text)
55
+ # Only substitute var: placeholders
56
+ if prefix != "var":
57
+ return match.group(0) # Return unchanged
58
+
59
+ if name not in variables:
60
+ raise ValueError(
61
+ f"Variable '{name}' is not defined. "
62
+ f"Variables must be defined before use."
63
+ )
64
+
65
+ return variables[name]
66
+
67
+ return PLACEHOLDER_PATTERN.sub(replace_match, text)
48
68
 
49
69
 
50
70
  def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str] | None = None) -> str:
@@ -175,6 +195,75 @@ def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str
175
195
  return PLACEHOLDER_PATTERN.sub(replace_match, text)
176
196
 
177
197
 
198
+ def substitute_dependency_args(
199
+ template_value: str,
200
+ parent_task_name: str,
201
+ parent_args: dict[str, Any],
202
+ exported_args: set[str] | None = None
203
+ ) -> str:
204
+ """Substitute {{ arg.* }} templates in dependency argument values.
205
+
206
+ This function substitutes parent task's arguments into dependency argument
207
+ templates. Only {{ arg.* }} placeholders are allowed in dependency arguments.
208
+
209
+ Args:
210
+ template_value: String that may contain {{ arg.* }} placeholders
211
+ parent_task_name: Name of parent task (for error messages)
212
+ parent_args: Parent task's argument values
213
+ exported_args: Set of parent's exported argument names
214
+
215
+ Returns:
216
+ String with {{ arg.* }} placeholders substituted
217
+
218
+ Raises:
219
+ ValueError: If template references undefined arg, uses exported arg,
220
+ or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
221
+
222
+ Example:
223
+ >>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
224
+ 'debug'
225
+ """
226
+ # Check for disallowed placeholder types in dependency args
227
+ # Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
228
+ for match in PLACEHOLDER_PATTERN.finditer(template_value):
229
+ prefix = match.group(1)
230
+ name = match.group(2)
231
+
232
+ if prefix == "var":
233
+ raise ValueError(
234
+ f"Task '{parent_task_name}': dependency argument contains {{ var.{name} }}\n"
235
+ f"Template: {template_value}\n\n"
236
+ f"Variables ({{ var.* }}) are not allowed in dependency arguments.\n"
237
+ f"Variables are substituted at parse time, use them directly in task definitions.\n"
238
+ f"In dependency arguments, only {{ arg.* }} templates are supported."
239
+ )
240
+ elif prefix == "env":
241
+ raise ValueError(
242
+ f"Task '{parent_task_name}': dependency argument contains {{ env.{name} }}\n"
243
+ f"Template: {template_value}\n\n"
244
+ f"Environment variables ({{ env.* }}) are not allowed in dependency arguments.\n"
245
+ f"In dependency arguments, only {{ arg.* }} templates are supported."
246
+ )
247
+ elif prefix == "tt":
248
+ raise ValueError(
249
+ f"Task '{parent_task_name}': dependency argument contains {{ tt.{name} }}\n"
250
+ f"Template: {template_value}\n\n"
251
+ f"Built-in variables ({{ tt.* }}) are not allowed in dependency arguments.\n"
252
+ f"In dependency arguments, only {{ arg.* }} templates are supported."
253
+ )
254
+
255
+ # Substitute {{ arg.* }} using parent's arguments
256
+ try:
257
+ return substitute_arguments(template_value, parent_args, exported_args)
258
+ except ValueError as e:
259
+ # Re-raise with more context
260
+ raise ValueError(
261
+ f"Task '{parent_task_name}': error in dependency argument substitution\n"
262
+ f"Template: {template_value}\n"
263
+ f"Error: {str(e)}"
264
+ ) from e
265
+
266
+
178
267
  def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -> str:
179
268
  """Substitute all placeholder types: variables, arguments, environment.
180
269