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 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
- 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:
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 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)
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, variables)
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
- variables: dict[str, str] = {}
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
- # Parse variables first (so they can be used in environment preambles and tasks)
884
- if data:
885
- variables = _parse_variables_section(data, file_path)
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
- for task in tasks.values():
892
- task.cmd = substitute_variables(task.cmd, variables)
893
- task.desc = substitute_variables(task.desc, variables)
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
- # Substitute variables in preamble
923
- if preamble and variables:
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, variables
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
- def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
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
- tasks, environments, default_env, variables = _parse_file_with_env(
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
- return Recipe(
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=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
 
@@ -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
@@ -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
@@ -1,8 +0,0 @@
1
- import subprocess
2
-
3
- from typing import Dict
4
-
5
-
6
- def run(task_tree: Dict) -> None:
7
- for _, params in task_tree.items():
8
- _ = subprocess.run(params["cmd"], shell=True)
@@ -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,,