tasktree 0.0.14__py3-none-any.whl → 0.0.16__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 +40 -12
- tasktree/executor.py +61 -0
- tasktree/graph.py +169 -5
- tasktree/parser.py +463 -30
- tasktree/substitution.py +74 -0
- {tasktree-0.0.14.dist-info → tasktree-0.0.16.dist-info}/METADATA +1 -1
- tasktree-0.0.16.dist-info/RECORD +14 -0
- tasktree/tasks.py +0 -8
- tasktree-0.0.14.dist-info/RECORD +0 -15
- {tasktree-0.0.14.dist-info → tasktree-0.0.16.dist-info}/WHEEL +0 -0
- {tasktree-0.0.14.dist-info → tasktree-0.0.16.dist-info}/entry_points.txt +0 -0
tasktree/cli.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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:
|
tasktree/executor.py
CHANGED
|
@@ -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
|
tasktree/graph.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
115
|
-
|
|
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)
|
tasktree/parser.py
CHANGED
|
@@ -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.
|
|
@@ -140,7 +165,11 @@ class Recipe:
|
|
|
140
165
|
environments: dict[str, Environment] = field(default_factory=dict)
|
|
141
166
|
default_env: str = "" # Name of default environment
|
|
142
167
|
global_env_override: str = "" # Global environment override (set via CLI --env)
|
|
143
|
-
variables: dict[str, str] = field(default_factory=dict) # Global variables (resolved at parse time)
|
|
168
|
+
variables: dict[str, str] = field(default_factory=dict) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
|
|
169
|
+
raw_variables: dict[str, Any] = field(default_factory=dict) # Raw variable specs from YAML (not yet evaluated)
|
|
170
|
+
evaluated_variables: dict[str, str] = field(default_factory=dict) # Evaluated variable values (cached after evaluation)
|
|
171
|
+
_variables_evaluated: bool = False # Track if variables have been evaluated
|
|
172
|
+
_original_yaml_data: dict[str, Any] = field(default_factory=dict) # Store original YAML data for lazy evaluation context
|
|
144
173
|
|
|
145
174
|
def get_task(self, name: str) -> Task | None:
|
|
146
175
|
"""Get task by name.
|
|
@@ -168,6 +197,108 @@ class Recipe:
|
|
|
168
197
|
"""
|
|
169
198
|
return self.environments.get(name)
|
|
170
199
|
|
|
200
|
+
def evaluate_variables(self, root_task: str | None = None) -> None:
|
|
201
|
+
"""Evaluate variables lazily based on task reachability.
|
|
202
|
+
|
|
203
|
+
This method implements lazy variable evaluation, which only evaluates
|
|
204
|
+
variables that are actually reachable from the target task. This provides:
|
|
205
|
+
- Performance improvement: expensive { eval: ... } commands only run when needed
|
|
206
|
+
- Security improvement: sensitive { read: ... } files only accessed when needed
|
|
207
|
+
- Side-effect control: commands with side effects only execute when necessary
|
|
208
|
+
|
|
209
|
+
If root_task is provided, only variables used by reachable tasks are evaluated.
|
|
210
|
+
If root_task is None, all variables are evaluated (for --list command compatibility).
|
|
211
|
+
|
|
212
|
+
This method is idempotent - calling it multiple times is safe (uses caching).
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
root_task: Optional task name to determine reachability (None = evaluate all)
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If variable evaluation or substitution fails
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
>>> recipe = parse_recipe(path) # Variables not yet evaluated
|
|
222
|
+
>>> recipe.evaluate_variables("build") # Evaluate only reachable variables
|
|
223
|
+
>>> # Now recipe.evaluated_variables contains only vars used by "build" task
|
|
224
|
+
"""
|
|
225
|
+
if self._variables_evaluated:
|
|
226
|
+
return # Already evaluated, skip (idempotent)
|
|
227
|
+
|
|
228
|
+
# Determine which variables to evaluate
|
|
229
|
+
if root_task:
|
|
230
|
+
# Lazy path: only evaluate reachable variables
|
|
231
|
+
# If root_task doesn't exist, fall back to eager evaluation
|
|
232
|
+
# (CLI will provide its own "Task not found" error)
|
|
233
|
+
try:
|
|
234
|
+
reachable_tasks = collect_reachable_tasks(self.tasks, root_task)
|
|
235
|
+
variables_to_eval = collect_reachable_variables(self.tasks, reachable_tasks)
|
|
236
|
+
except ValueError:
|
|
237
|
+
# Root task not found - fall back to eager evaluation
|
|
238
|
+
# This allows the recipe to be parsed even with invalid task names
|
|
239
|
+
# so the CLI can provide its own error message
|
|
240
|
+
variables_to_eval = set(self.raw_variables.keys())
|
|
241
|
+
else:
|
|
242
|
+
# Eager path: evaluate all variables (for --list command)
|
|
243
|
+
variables_to_eval = set(self.raw_variables.keys())
|
|
244
|
+
|
|
245
|
+
# Evaluate the selected variables using helper function
|
|
246
|
+
self.evaluated_variables = _evaluate_variable_subset(
|
|
247
|
+
self.raw_variables,
|
|
248
|
+
variables_to_eval,
|
|
249
|
+
self.recipe_path,
|
|
250
|
+
self._original_yaml_data
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Also update the deprecated 'variables' field for backward compatibility
|
|
254
|
+
self.variables = self.evaluated_variables
|
|
255
|
+
|
|
256
|
+
# Substitute evaluated variables into all tasks
|
|
257
|
+
from tasktree.substitution import substitute_variables
|
|
258
|
+
|
|
259
|
+
for task in self.tasks.values():
|
|
260
|
+
task.cmd = substitute_variables(task.cmd, self.evaluated_variables)
|
|
261
|
+
task.desc = substitute_variables(task.desc, self.evaluated_variables)
|
|
262
|
+
task.working_dir = substitute_variables(task.working_dir, self.evaluated_variables)
|
|
263
|
+
task.inputs = [substitute_variables(inp, self.evaluated_variables) for inp in task.inputs]
|
|
264
|
+
task.outputs = [substitute_variables(out, self.evaluated_variables) for out in task.outputs]
|
|
265
|
+
# Substitute in argument default values (in arg spec strings)
|
|
266
|
+
task.args = [substitute_variables(arg, self.evaluated_variables) for arg in task.args]
|
|
267
|
+
|
|
268
|
+
# Substitute evaluated variables into all environments
|
|
269
|
+
for env in self.environments.values():
|
|
270
|
+
if env.preamble:
|
|
271
|
+
env.preamble = substitute_variables(env.preamble, self.evaluated_variables)
|
|
272
|
+
|
|
273
|
+
# Substitute in volumes
|
|
274
|
+
if env.volumes:
|
|
275
|
+
env.volumes = [substitute_variables(vol, self.evaluated_variables) for vol in env.volumes]
|
|
276
|
+
|
|
277
|
+
# Substitute in ports
|
|
278
|
+
if env.ports:
|
|
279
|
+
env.ports = [substitute_variables(port, self.evaluated_variables) for port in env.ports]
|
|
280
|
+
|
|
281
|
+
# Substitute in env_vars values
|
|
282
|
+
if env.env_vars:
|
|
283
|
+
env.env_vars = {
|
|
284
|
+
key: substitute_variables(value, self.evaluated_variables)
|
|
285
|
+
for key, value in env.env_vars.items()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Substitute in working_dir
|
|
289
|
+
if env.working_dir:
|
|
290
|
+
env.working_dir = substitute_variables(env.working_dir, self.evaluated_variables)
|
|
291
|
+
|
|
292
|
+
# Substitute in build args (dict for Docker environments)
|
|
293
|
+
if env.args and isinstance(env.args, dict):
|
|
294
|
+
env.args = {
|
|
295
|
+
key: substitute_variables(str(value), self.evaluated_variables)
|
|
296
|
+
for key, value in env.args.items()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
# Mark as evaluated
|
|
300
|
+
self._variables_evaluated = True
|
|
301
|
+
|
|
171
302
|
|
|
172
303
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
173
304
|
"""Find recipe file in current or parent directories.
|
|
@@ -800,7 +931,6 @@ def _resolve_variable_value(
|
|
|
800
931
|
error_msg = str(e)
|
|
801
932
|
if "not defined" in error_msg:
|
|
802
933
|
# Extract the variable name from the error message
|
|
803
|
-
import re
|
|
804
934
|
match = re.search(r"Variable '(\w+)' is not defined", error_msg)
|
|
805
935
|
if match:
|
|
806
936
|
undefined_var = match.group(1)
|
|
@@ -850,12 +980,145 @@ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
|
|
|
850
980
|
return resolved
|
|
851
981
|
|
|
852
982
|
|
|
983
|
+
def _expand_variable_dependencies(
|
|
984
|
+
variable_names: set[str],
|
|
985
|
+
raw_variables: dict[str, Any]
|
|
986
|
+
) -> set[str]:
|
|
987
|
+
"""Expand variable set to include all transitively referenced variables.
|
|
988
|
+
|
|
989
|
+
If variable A references variable B, and B references C, then requesting A
|
|
990
|
+
should also evaluate B and C.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
variable_names: Initial set of variable names
|
|
994
|
+
raw_variables: Raw variable definitions from YAML
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Expanded set including all transitively referenced variables
|
|
998
|
+
|
|
999
|
+
Example:
|
|
1000
|
+
>>> raw_vars = {
|
|
1001
|
+
... "a": "{{ var.b }}",
|
|
1002
|
+
... "b": "{{ var.c }}",
|
|
1003
|
+
... "c": "value"
|
|
1004
|
+
... }
|
|
1005
|
+
>>> _expand_variable_dependencies({"a"}, raw_vars)
|
|
1006
|
+
{"a", "b", "c"}
|
|
1007
|
+
"""
|
|
1008
|
+
expanded = set(variable_names)
|
|
1009
|
+
to_process = list(variable_names)
|
|
1010
|
+
pattern = re.compile(r'\{\{\s*var\.(\w+)\s*\}\}')
|
|
1011
|
+
|
|
1012
|
+
while to_process:
|
|
1013
|
+
var_name = to_process.pop(0)
|
|
1014
|
+
|
|
1015
|
+
if var_name not in raw_variables:
|
|
1016
|
+
continue
|
|
1017
|
+
|
|
1018
|
+
raw_value = raw_variables[var_name]
|
|
1019
|
+
|
|
1020
|
+
# Extract referenced variables from the raw value
|
|
1021
|
+
# Handle string values with {{ var.* }} patterns
|
|
1022
|
+
if isinstance(raw_value, str):
|
|
1023
|
+
for match in pattern.finditer(raw_value):
|
|
1024
|
+
referenced_var = match.group(1)
|
|
1025
|
+
if referenced_var not in expanded:
|
|
1026
|
+
expanded.add(referenced_var)
|
|
1027
|
+
to_process.append(referenced_var)
|
|
1028
|
+
# Handle { read: filepath } variables - check file contents for variable references
|
|
1029
|
+
elif isinstance(raw_value, dict) and 'read' in raw_value:
|
|
1030
|
+
filepath = raw_value['read']
|
|
1031
|
+
# For dependency expansion, we speculatively read files to find variable references
|
|
1032
|
+
# This is acceptable because file reads are relatively cheap compared to eval commands
|
|
1033
|
+
try:
|
|
1034
|
+
# Try to read the file (may not exist yet, which is fine for dependency tracking)
|
|
1035
|
+
# Skip if filepath is None or empty (validation error will be caught during evaluation)
|
|
1036
|
+
if filepath and isinstance(filepath, str):
|
|
1037
|
+
from pathlib import Path
|
|
1038
|
+
if Path(filepath).exists():
|
|
1039
|
+
file_content = Path(filepath).read_text()
|
|
1040
|
+
# Extract variable references from file content
|
|
1041
|
+
for match in pattern.finditer(file_content):
|
|
1042
|
+
referenced_var = match.group(1)
|
|
1043
|
+
if referenced_var not in expanded:
|
|
1044
|
+
expanded.add(referenced_var)
|
|
1045
|
+
to_process.append(referenced_var)
|
|
1046
|
+
except (IOError, OSError, TypeError):
|
|
1047
|
+
# If file can't be read during expansion, that's okay
|
|
1048
|
+
# The error will be caught during actual evaluation
|
|
1049
|
+
pass
|
|
1050
|
+
# Handle { env: VAR, default: ... } variables - check default value for variable references
|
|
1051
|
+
elif isinstance(raw_value, dict) and 'env' in raw_value and 'default' in raw_value:
|
|
1052
|
+
default_value = raw_value['default']
|
|
1053
|
+
# Check if default value contains variable references
|
|
1054
|
+
if isinstance(default_value, str):
|
|
1055
|
+
for match in pattern.finditer(default_value):
|
|
1056
|
+
referenced_var = match.group(1)
|
|
1057
|
+
if referenced_var not in expanded:
|
|
1058
|
+
expanded.add(referenced_var)
|
|
1059
|
+
to_process.append(referenced_var)
|
|
1060
|
+
|
|
1061
|
+
return expanded
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _evaluate_variable_subset(
|
|
1065
|
+
raw_variables: dict[str, Any],
|
|
1066
|
+
variable_names: set[str],
|
|
1067
|
+
file_path: Path,
|
|
1068
|
+
data: dict
|
|
1069
|
+
) -> dict[str, str]:
|
|
1070
|
+
"""Evaluate only specified variables from raw specs (for lazy evaluation).
|
|
1071
|
+
|
|
1072
|
+
This function is similar to _parse_variables_section but only evaluates
|
|
1073
|
+
a subset of variables. This enables lazy evaluation where only reachable
|
|
1074
|
+
variables are evaluated, improving performance and security.
|
|
1075
|
+
|
|
1076
|
+
Transitive dependencies are automatically included: if variable A references
|
|
1077
|
+
variable B, both will be evaluated even if only A was explicitly requested.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
raw_variables: Raw variable definitions from YAML (not yet evaluated)
|
|
1081
|
+
variable_names: Set of variable names to evaluate
|
|
1082
|
+
file_path: Recipe file path (for relative file resolution)
|
|
1083
|
+
data: Full YAML data (for context in _resolve_variable_value)
|
|
1084
|
+
|
|
1085
|
+
Returns:
|
|
1086
|
+
Dictionary of evaluated variable values (for specified variables and their dependencies)
|
|
1087
|
+
|
|
1088
|
+
Raises:
|
|
1089
|
+
ValueError: For validation errors, undefined refs, or circular refs
|
|
1090
|
+
|
|
1091
|
+
Example:
|
|
1092
|
+
>>> raw_vars = {"a": "{{ var.b }}", "b": "value", "c": "unused"}
|
|
1093
|
+
>>> _evaluate_variable_subset(raw_vars, {"a"}, path, data)
|
|
1094
|
+
{"a": "value", "b": "value"} # "a" and its dependency "b", but not "c"
|
|
1095
|
+
"""
|
|
1096
|
+
if not isinstance(raw_variables, dict):
|
|
1097
|
+
raise ValueError("'variables' must be a dictionary")
|
|
1098
|
+
|
|
1099
|
+
# Expand variable set to include transitive dependencies
|
|
1100
|
+
variables_to_eval = _expand_variable_dependencies(variable_names, raw_variables)
|
|
1101
|
+
|
|
1102
|
+
resolved = {} # name -> resolved string value
|
|
1103
|
+
resolution_stack = [] # For circular detection
|
|
1104
|
+
|
|
1105
|
+
# Evaluate variables in order (to handle references between variables)
|
|
1106
|
+
for var_name, raw_value in raw_variables.items():
|
|
1107
|
+
if var_name in variables_to_eval:
|
|
1108
|
+
_validate_variable_name(var_name)
|
|
1109
|
+
resolved[var_name] = _resolve_variable_value(
|
|
1110
|
+
var_name, raw_value, resolved, resolution_stack, file_path, data
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
return resolved
|
|
1114
|
+
|
|
1115
|
+
|
|
853
1116
|
def _parse_file_with_env(
|
|
854
1117
|
file_path: Path,
|
|
855
1118
|
namespace: str | None,
|
|
856
1119
|
project_root: Path,
|
|
857
1120
|
import_stack: list[Path] | None = None,
|
|
858
|
-
) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, str]]:
|
|
1121
|
+
) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]]:
|
|
859
1122
|
"""Parse file and extract tasks, environments, and variables.
|
|
860
1123
|
|
|
861
1124
|
Args:
|
|
@@ -865,7 +1128,8 @@ def _parse_file_with_env(
|
|
|
865
1128
|
import_stack: Stack of files being imported (for circular detection)
|
|
866
1129
|
|
|
867
1130
|
Returns:
|
|
868
|
-
Tuple of (tasks, environments, default_env_name,
|
|
1131
|
+
Tuple of (tasks, environments, default_env_name, raw_variables, yaml_data)
|
|
1132
|
+
Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
|
|
869
1133
|
"""
|
|
870
1134
|
# Parse tasks normally
|
|
871
1135
|
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
@@ -873,29 +1137,23 @@ def _parse_file_with_env(
|
|
|
873
1137
|
# Load YAML again to extract environments and variables (only from root file)
|
|
874
1138
|
environments: dict[str, Environment] = {}
|
|
875
1139
|
default_env = ""
|
|
876
|
-
|
|
1140
|
+
raw_variables: dict[str, Any] = {}
|
|
1141
|
+
yaml_data: dict[str, Any] = {}
|
|
877
1142
|
|
|
878
1143
|
# Only parse environments and variables from the root file (namespace is None)
|
|
879
1144
|
if namespace is None:
|
|
880
1145
|
with open(file_path, "r") as f:
|
|
881
1146
|
data = yaml.safe_load(f)
|
|
1147
|
+
yaml_data = data or {}
|
|
882
1148
|
|
|
883
|
-
#
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
# Apply variable substitution to all tasks
|
|
888
|
-
if variables:
|
|
889
|
-
from tasktree.substitution import substitute_variables
|
|
1149
|
+
# Store raw variable specs WITHOUT evaluating them (lazy evaluation)
|
|
1150
|
+
# Variable evaluation will happen later in Recipe.evaluate_variables()
|
|
1151
|
+
if data and "variables" in data:
|
|
1152
|
+
raw_variables = data["variables"]
|
|
890
1153
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
task.working_dir = substitute_variables(task.working_dir, variables)
|
|
895
|
-
task.inputs = [substitute_variables(inp, variables) for inp in task.inputs]
|
|
896
|
-
task.outputs = [substitute_variables(out, variables) for out in task.outputs]
|
|
897
|
-
# Substitute in argument default values (in arg spec strings)
|
|
898
|
-
task.args = [substitute_variables(arg, variables) for arg in task.args]
|
|
1154
|
+
# SKIP variable substitution here - defer to lazy evaluation phase
|
|
1155
|
+
# Tasks and environments will contain {{ var.* }} placeholders until evaluation
|
|
1156
|
+
# This allows us to only evaluate variables that are actually reachable from the target task
|
|
899
1157
|
|
|
900
1158
|
if data and "environments" in data:
|
|
901
1159
|
env_data = data["environments"]
|
|
@@ -919,10 +1177,8 @@ def _parse_file_with_env(
|
|
|
919
1177
|
preamble = env_config.get("preamble", "")
|
|
920
1178
|
working_dir = env_config.get("working_dir", "")
|
|
921
1179
|
|
|
922
|
-
#
|
|
923
|
-
|
|
924
|
-
from tasktree.substitution import substitute_variables
|
|
925
|
-
preamble = substitute_variables(preamble, variables)
|
|
1180
|
+
# SKIP variable substitution in preamble - defer to lazy evaluation
|
|
1181
|
+
# preamble may contain {{ var.* }} placeholders
|
|
926
1182
|
|
|
927
1183
|
# Parse Docker-specific fields
|
|
928
1184
|
dockerfile = env_config.get("dockerfile", "")
|
|
@@ -933,6 +1189,9 @@ def _parse_file_with_env(
|
|
|
933
1189
|
extra_args = env_config.get("extra_args", [])
|
|
934
1190
|
run_as_root = env_config.get("run_as_root", False)
|
|
935
1191
|
|
|
1192
|
+
# SKIP variable substitution in environment fields - defer to lazy evaluation
|
|
1193
|
+
# Environment fields may contain {{ var.* }} placeholders
|
|
1194
|
+
|
|
936
1195
|
# Validate environment type
|
|
937
1196
|
if not shell and not dockerfile:
|
|
938
1197
|
raise ValueError(
|
|
@@ -989,19 +1248,180 @@ def _parse_file_with_env(
|
|
|
989
1248
|
run_as_root=run_as_root
|
|
990
1249
|
)
|
|
991
1250
|
|
|
992
|
-
return tasks, environments, default_env,
|
|
1251
|
+
return tasks, environments, default_env, raw_variables, yaml_data
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
|
|
1255
|
+
"""Collect all tasks reachable from the root task via dependencies.
|
|
993
1256
|
|
|
1257
|
+
Uses BFS to traverse the dependency graph and collect all task names
|
|
1258
|
+
that could potentially be executed when running the root task.
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1262
|
+
root_task: Name of the root task to start traversal from
|
|
1263
|
+
|
|
1264
|
+
Returns:
|
|
1265
|
+
Set of task names reachable from root_task (includes root_task itself)
|
|
1266
|
+
|
|
1267
|
+
Raises:
|
|
1268
|
+
ValueError: If root_task doesn't exist
|
|
994
1269
|
|
|
995
|
-
|
|
1270
|
+
Example:
|
|
1271
|
+
>>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
|
|
1272
|
+
>>> collect_reachable_tasks(tasks, "a")
|
|
1273
|
+
{"a", "b"}
|
|
1274
|
+
"""
|
|
1275
|
+
if root_task not in tasks:
|
|
1276
|
+
raise ValueError(f"Root task '{root_task}' not found in recipe")
|
|
1277
|
+
|
|
1278
|
+
reachable = set()
|
|
1279
|
+
queue = [root_task]
|
|
1280
|
+
|
|
1281
|
+
while queue:
|
|
1282
|
+
task_name = queue.pop(0)
|
|
1283
|
+
|
|
1284
|
+
if task_name in reachable:
|
|
1285
|
+
continue # Already processed
|
|
1286
|
+
|
|
1287
|
+
reachable.add(task_name)
|
|
1288
|
+
|
|
1289
|
+
# Get task and process its dependencies
|
|
1290
|
+
task = tasks.get(task_name)
|
|
1291
|
+
if task is None:
|
|
1292
|
+
# Task not found - will be caught during graph construction
|
|
1293
|
+
continue
|
|
1294
|
+
|
|
1295
|
+
# Add dependency task names to queue
|
|
1296
|
+
for dep_spec in task.deps:
|
|
1297
|
+
# Extract task name from dependency specification
|
|
1298
|
+
if isinstance(dep_spec, str):
|
|
1299
|
+
dep_name = dep_spec
|
|
1300
|
+
elif isinstance(dep_spec, dict) and len(dep_spec) == 1:
|
|
1301
|
+
dep_name = next(iter(dep_spec.keys()))
|
|
1302
|
+
else:
|
|
1303
|
+
# Invalid format - will be caught during graph construction
|
|
1304
|
+
continue
|
|
1305
|
+
|
|
1306
|
+
if dep_name not in reachable:
|
|
1307
|
+
queue.append(dep_name)
|
|
1308
|
+
|
|
1309
|
+
return reachable
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def collect_reachable_variables(
|
|
1313
|
+
tasks: dict[str, Task],
|
|
1314
|
+
reachable_task_names: set[str]
|
|
1315
|
+
) -> set[str]:
|
|
1316
|
+
"""Extract variable names used by reachable tasks.
|
|
1317
|
+
|
|
1318
|
+
Searches for {{ var.* }} placeholders in task definitions to determine
|
|
1319
|
+
which variables are actually needed for execution.
|
|
1320
|
+
|
|
1321
|
+
Args:
|
|
1322
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1323
|
+
reachable_task_names: Set of task names that will be executed
|
|
1324
|
+
|
|
1325
|
+
Returns:
|
|
1326
|
+
Set of variable names referenced by reachable tasks
|
|
1327
|
+
|
|
1328
|
+
Example:
|
|
1329
|
+
>>> task = Task("build", cmd="echo {{ var.version }}")
|
|
1330
|
+
>>> collect_reachable_variables({"build": task}, {"build"})
|
|
1331
|
+
{"version"}
|
|
1332
|
+
"""
|
|
1333
|
+
import re
|
|
1334
|
+
|
|
1335
|
+
# Pattern to match {{ var.name }}
|
|
1336
|
+
var_pattern = re.compile(r'\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}')
|
|
1337
|
+
|
|
1338
|
+
variables = set()
|
|
1339
|
+
|
|
1340
|
+
for task_name in reachable_task_names:
|
|
1341
|
+
task = tasks.get(task_name)
|
|
1342
|
+
if task is None:
|
|
1343
|
+
continue
|
|
1344
|
+
|
|
1345
|
+
# Search in command
|
|
1346
|
+
if task.cmd:
|
|
1347
|
+
for match in var_pattern.finditer(task.cmd):
|
|
1348
|
+
variables.add(match.group(1))
|
|
1349
|
+
|
|
1350
|
+
# Search in description
|
|
1351
|
+
if task.desc:
|
|
1352
|
+
for match in var_pattern.finditer(task.desc):
|
|
1353
|
+
variables.add(match.group(1))
|
|
1354
|
+
|
|
1355
|
+
# Search in working_dir
|
|
1356
|
+
if task.working_dir:
|
|
1357
|
+
for match in var_pattern.finditer(task.working_dir):
|
|
1358
|
+
variables.add(match.group(1))
|
|
1359
|
+
|
|
1360
|
+
# Search in inputs
|
|
1361
|
+
if task.inputs:
|
|
1362
|
+
for input_pattern in task.inputs:
|
|
1363
|
+
for match in var_pattern.finditer(input_pattern):
|
|
1364
|
+
variables.add(match.group(1))
|
|
1365
|
+
|
|
1366
|
+
# Search in outputs
|
|
1367
|
+
if task.outputs:
|
|
1368
|
+
for output_pattern in task.outputs:
|
|
1369
|
+
for match in var_pattern.finditer(output_pattern):
|
|
1370
|
+
variables.add(match.group(1))
|
|
1371
|
+
|
|
1372
|
+
# Search in argument defaults
|
|
1373
|
+
if task.args:
|
|
1374
|
+
for arg_spec in task.args:
|
|
1375
|
+
if isinstance(arg_spec, dict):
|
|
1376
|
+
for arg_dict in arg_spec.values():
|
|
1377
|
+
if isinstance(arg_dict, dict) and "default" in arg_dict:
|
|
1378
|
+
default = arg_dict["default"]
|
|
1379
|
+
if isinstance(default, str):
|
|
1380
|
+
for match in var_pattern.finditer(default):
|
|
1381
|
+
variables.add(match.group(1))
|
|
1382
|
+
|
|
1383
|
+
# Search in dependency argument templates
|
|
1384
|
+
if task.deps:
|
|
1385
|
+
for dep_spec in task.deps:
|
|
1386
|
+
if isinstance(dep_spec, dict):
|
|
1387
|
+
for arg_spec in dep_spec.values():
|
|
1388
|
+
# Positional args (list)
|
|
1389
|
+
if isinstance(arg_spec, list):
|
|
1390
|
+
for val in arg_spec:
|
|
1391
|
+
if isinstance(val, str):
|
|
1392
|
+
for match in var_pattern.finditer(val):
|
|
1393
|
+
variables.add(match.group(1))
|
|
1394
|
+
# Named args (dict)
|
|
1395
|
+
elif isinstance(arg_spec, dict):
|
|
1396
|
+
for val in arg_spec.values():
|
|
1397
|
+
if isinstance(val, str):
|
|
1398
|
+
for match in var_pattern.finditer(val):
|
|
1399
|
+
variables.add(match.group(1))
|
|
1400
|
+
|
|
1401
|
+
return variables
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def parse_recipe(
|
|
1405
|
+
recipe_path: Path,
|
|
1406
|
+
project_root: Path | None = None,
|
|
1407
|
+
root_task: str | None = None
|
|
1408
|
+
) -> Recipe:
|
|
996
1409
|
"""Parse a recipe file and handle imports recursively.
|
|
997
1410
|
|
|
1411
|
+
This function now implements lazy variable evaluation: if root_task is provided,
|
|
1412
|
+
only variables reachable from that task will be evaluated. This provides significant
|
|
1413
|
+
performance and security benefits for recipes with many variables.
|
|
1414
|
+
|
|
998
1415
|
Args:
|
|
999
1416
|
recipe_path: Path to the main recipe file
|
|
1000
1417
|
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
1001
1418
|
When using --tasks option, this should be the current working directory.
|
|
1419
|
+
root_task: Optional root task for lazy variable evaluation. If provided, only variables
|
|
1420
|
+
used by tasks reachable from root_task will be evaluated (optimization).
|
|
1421
|
+
If None, all variables will be evaluated (for --list command compatibility).
|
|
1002
1422
|
|
|
1003
1423
|
Returns:
|
|
1004
|
-
Recipe object with all tasks (including recursively imported tasks)
|
|
1424
|
+
Recipe object with all tasks (including recursively imported tasks) and evaluated variables
|
|
1005
1425
|
|
|
1006
1426
|
Raises:
|
|
1007
1427
|
FileNotFoundError: If recipe file doesn't exist
|
|
@@ -1017,19 +1437,32 @@ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
|
1017
1437
|
project_root = recipe_path.parent
|
|
1018
1438
|
|
|
1019
1439
|
# Parse main file - it will recursively handle all imports
|
|
1020
|
-
|
|
1440
|
+
# Variables are NOT evaluated here (lazy evaluation)
|
|
1441
|
+
tasks, environments, default_env, raw_variables, yaml_data = _parse_file_with_env(
|
|
1021
1442
|
recipe_path, namespace=None, project_root=project_root
|
|
1022
1443
|
)
|
|
1023
1444
|
|
|
1024
|
-
|
|
1445
|
+
# Create recipe with raw (unevaluated) variables
|
|
1446
|
+
recipe = Recipe(
|
|
1025
1447
|
tasks=tasks,
|
|
1026
1448
|
project_root=project_root,
|
|
1027
1449
|
recipe_path=recipe_path,
|
|
1028
1450
|
environments=environments,
|
|
1029
1451
|
default_env=default_env,
|
|
1030
|
-
variables=
|
|
1452
|
+
variables={}, # Empty initially (deprecated field)
|
|
1453
|
+
raw_variables=raw_variables,
|
|
1454
|
+
evaluated_variables={}, # Empty initially
|
|
1455
|
+
_variables_evaluated=False,
|
|
1456
|
+
_original_yaml_data=yaml_data
|
|
1031
1457
|
)
|
|
1032
1458
|
|
|
1459
|
+
# Trigger lazy variable evaluation
|
|
1460
|
+
# If root_task is provided: evaluate only reachable variables
|
|
1461
|
+
# If root_task is None: evaluate all variables (for --list)
|
|
1462
|
+
recipe.evaluate_variables(root_task)
|
|
1463
|
+
|
|
1464
|
+
return recipe
|
|
1465
|
+
|
|
1033
1466
|
|
|
1034
1467
|
def _parse_file(
|
|
1035
1468
|
file_path: Path,
|
tasktree/substitution.py
CHANGED
|
@@ -48,6 +48,11 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
48
48
|
else:
|
|
49
49
|
raise ValueError("Empty arg dictionary")
|
|
50
50
|
else:
|
|
51
|
+
# If not a string (e.g., int, float, bool, None), return unchanged
|
|
52
|
+
# This handles cases like: default: 5, min: 0, choices: [1, 2, 3]
|
|
53
|
+
if not isinstance(text, str):
|
|
54
|
+
return text
|
|
55
|
+
|
|
51
56
|
def replace_match(match: re.Match) -> str:
|
|
52
57
|
prefix = match.group(1)
|
|
53
58
|
name = match.group(2)
|
|
@@ -195,6 +200,75 @@ def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str
|
|
|
195
200
|
return PLACEHOLDER_PATTERN.sub(replace_match, text)
|
|
196
201
|
|
|
197
202
|
|
|
203
|
+
def substitute_dependency_args(
|
|
204
|
+
template_value: str,
|
|
205
|
+
parent_task_name: str,
|
|
206
|
+
parent_args: dict[str, Any],
|
|
207
|
+
exported_args: set[str] | None = None
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Substitute {{ arg.* }} templates in dependency argument values.
|
|
210
|
+
|
|
211
|
+
This function substitutes parent task's arguments into dependency argument
|
|
212
|
+
templates. Only {{ arg.* }} placeholders are allowed in dependency arguments.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
template_value: String that may contain {{ arg.* }} placeholders
|
|
216
|
+
parent_task_name: Name of parent task (for error messages)
|
|
217
|
+
parent_args: Parent task's argument values
|
|
218
|
+
exported_args: Set of parent's exported argument names
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
String with {{ arg.* }} placeholders substituted
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If template references undefined arg, uses exported arg,
|
|
225
|
+
or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
|
|
229
|
+
'debug'
|
|
230
|
+
"""
|
|
231
|
+
# Check for disallowed placeholder types in dependency args
|
|
232
|
+
# Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
|
|
233
|
+
for match in PLACEHOLDER_PATTERN.finditer(template_value):
|
|
234
|
+
prefix = match.group(1)
|
|
235
|
+
name = match.group(2)
|
|
236
|
+
|
|
237
|
+
if prefix == "var":
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"Task '{parent_task_name}': dependency argument contains {{ var.{name} }}\n"
|
|
240
|
+
f"Template: {template_value}\n\n"
|
|
241
|
+
f"Variables ({{ var.* }}) are not allowed in dependency arguments.\n"
|
|
242
|
+
f"Variables are substituted at parse time, use them directly in task definitions.\n"
|
|
243
|
+
f"In dependency arguments, only {{ arg.* }} templates are supported."
|
|
244
|
+
)
|
|
245
|
+
elif prefix == "env":
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"Task '{parent_task_name}': dependency argument contains {{ env.{name} }}\n"
|
|
248
|
+
f"Template: {template_value}\n\n"
|
|
249
|
+
f"Environment variables ({{ env.* }}) are not allowed in dependency arguments.\n"
|
|
250
|
+
f"In dependency arguments, only {{ arg.* }} templates are supported."
|
|
251
|
+
)
|
|
252
|
+
elif prefix == "tt":
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"Task '{parent_task_name}': dependency argument contains {{ tt.{name} }}\n"
|
|
255
|
+
f"Template: {template_value}\n\n"
|
|
256
|
+
f"Built-in variables ({{ tt.* }}) are not allowed in dependency arguments.\n"
|
|
257
|
+
f"In dependency arguments, only {{ arg.* }} templates are supported."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Substitute {{ arg.* }} using parent's arguments
|
|
261
|
+
try:
|
|
262
|
+
return substitute_arguments(template_value, parent_args, exported_args)
|
|
263
|
+
except ValueError as e:
|
|
264
|
+
# Re-raise with more context
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Task '{parent_task_name}': error in dependency argument substitution\n"
|
|
267
|
+
f"Template: {template_value}\n"
|
|
268
|
+
f"Error: {str(e)}"
|
|
269
|
+
) from e
|
|
270
|
+
|
|
271
|
+
|
|
198
272
|
def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -> str:
|
|
199
273
|
"""Substitute all placeholder types: variables, arguments, environment.
|
|
200
274
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
+
tasktree/cli.py,sha256=Uhv0RNFrogjvqxBYKYIfxEPd0SdYAIpXH7SPGIxQnmk,20136
|
|
3
|
+
tasktree/docker.py,sha256=qvja8G63uAcC73YMVY739egda1_CcBtoqzm0qIJU_Q8,14443
|
|
4
|
+
tasktree/executor.py,sha256=QQcABThmof0MLTtwpJKpyqh80hr3YRIqqs3NZ-Ry-Bk,44873
|
|
5
|
+
tasktree/graph.py,sha256=yITp71RfhJ7sdC-2zRf89SHYZqQyF3XVAnaqX-XnMdE,15821
|
|
6
|
+
tasktree/hasher.py,sha256=0GrnCfwAXnwq_kpnHFFb12B5_2VFNXx6Ng7hTdcCyXo,4415
|
|
7
|
+
tasktree/parser.py,sha256=yOe6G1Fg2FqmEjaPD73FD2aiYP9sZisTx1DZPofx9wc,87049
|
|
8
|
+
tasktree/state.py,sha256=Cktl4D8iDZVd55aO2LqVyPrc-BnljkesxxkcMcdcfOY,3541
|
|
9
|
+
tasktree/substitution.py,sha256=qG7SyEHn1PAKteWA0AgA1dUNbJfwQTupCLRq9FvOBD0,10724
|
|
10
|
+
tasktree/types.py,sha256=R_YAyO5bMLB6XZnkMRT7VAtlkA_Xx6xu0aIpzQjrBXs,4357
|
|
11
|
+
tasktree-0.0.16.dist-info/METADATA,sha256=ssKlllByyOeVMGDuHa11XLDhcGoxPfepRRoO776A_Ig,37234
|
|
12
|
+
tasktree-0.0.16.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
tasktree-0.0.16.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
14
|
+
tasktree-0.0.16.dist-info/RECORD,,
|
tasktree/tasks.py
DELETED
tasktree-0.0.14.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
tasktree/__init__.py,sha256=MVmdvKb3JdqLlo0x2_TPGMfgFC0HsDnP79HAzGnFnjI,1081
|
|
2
|
-
tasktree/cli.py,sha256=ywaLXEehOHcMsBioL4RxPQ1vTWIG1V7Hb0TZ1YI2F_g,18820
|
|
3
|
-
tasktree/docker.py,sha256=qvja8G63uAcC73YMVY739egda1_CcBtoqzm0qIJU_Q8,14443
|
|
4
|
-
tasktree/executor.py,sha256=Q7Bks5B88i-IyZDpxGSps9MM3uflz0U3yn4Rtq_uHMM,42266
|
|
5
|
-
tasktree/graph.py,sha256=oXLxX0Ix4zSkVBg8_3x9K7WxSFpg136sp4MF-d2mDEQ,9682
|
|
6
|
-
tasktree/hasher.py,sha256=0GrnCfwAXnwq_kpnHFFb12B5_2VFNXx6Ng7hTdcCyXo,4415
|
|
7
|
-
tasktree/parser.py,sha256=XdFuELqrrhHc45HeMpo6-gflopZM7kYTqO1lQcFwtFk,68782
|
|
8
|
-
tasktree/state.py,sha256=Cktl4D8iDZVd55aO2LqVyPrc-BnljkesxxkcMcdcfOY,3541
|
|
9
|
-
tasktree/substitution.py,sha256=UAq69YO4uGkvFfd5mtGy6CwFZ_uxZGf4HGpnvCBVBuc,7499
|
|
10
|
-
tasktree/tasks.py,sha256=2QdQZtJAX2rSGbyXKG1z9VF_siz1DUzdvzCgPkykxtU,173
|
|
11
|
-
tasktree/types.py,sha256=R_YAyO5bMLB6XZnkMRT7VAtlkA_Xx6xu0aIpzQjrBXs,4357
|
|
12
|
-
tasktree-0.0.14.dist-info/METADATA,sha256=JnlgocAOtAq_Q-vWrOkYM6mofrCeH8xiGFKYqaQmX4M,37234
|
|
13
|
-
tasktree-0.0.14.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
-
tasktree-0.0.14.dist-info/entry_points.txt,sha256=lQINlvRYnimvteBbnhH84A9clTg8NnpEjCWqWkqg8KE,40
|
|
15
|
-
tasktree-0.0.14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|