tasktree 0.0.14__tar.gz → 0.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/validate-pipx-install.yml +13 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/PKG-INFO +1 -1
- {tasktree-0.0.14 → tasktree-0.0.16}/pyproject.toml +1 -1
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/cli.py +40 -12
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/executor.py +61 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/graph.py +169 -5
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/parser.py +463 -30
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/substitution.py +74 -0
- tasktree-0.0.16/tests/integration/test_builtin_variables.py +573 -0
- tasktree-0.0.16/tests/integration/test_parameterized_deps_templates.py +232 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_variables.py +3 -3
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_docker.py +139 -0
- tasktree-0.0.14/requirements/implemented/01-basic-variables.md +0 -142
- tasktree-0.0.14/requirements/implemented/02-env-variable-type.md +0 -174
- tasktree-0.0.14/requirements/implemented/03-direct-env-substitution.md +0 -180
- tasktree-0.0.14/requirements/implemented/04-file-read-variables.md +0 -240
- tasktree-0.0.14/requirements/implemented/bug-report-dependency-triggering.md +0 -222
- tasktree-0.0.14/requirements/implemented/docker-task-environments.md +0 -1002
- tasktree-0.0.14/requirements/implemented/shell-environment-requirements.md +0 -393
- tasktree-0.0.14/src/tasktree/tasks.py +0 -8
- tasktree-0.0.14/tests/integration/test_builtin_variables.py +0 -268
- tasktree-0.0.14/tests/unit/test_tasks.py +0 -18
- {tasktree-0.0.14 → tasktree-0.0.16}/.claude/settings.local.json +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/claude-code-review.yml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/claude.yml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/release.yml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.github/workflows/test.yml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.gitignore +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/.python-version +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/CLAUDE.md +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/README.md +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/example/source.txt +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/example/tasktree.yaml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/schema/README.md +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/schema/tasktree-schema.json +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/schema/vscode-settings-snippet.json +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/__init__.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/__init__.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/docker.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/hasher.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/state.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/src/tasktree/types.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tasktree.yaml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/__init__.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_basic.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_environment.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_ownership.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_docker_volumes.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/e2e/test_non_docker.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_arg_choices.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_arg_min_max.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_clean_state.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_cli_options.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_dependency_execution.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_docker_build_args.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_end_to_end.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_exported_args.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_input_detection.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_missing_outputs.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_nested_imports.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_parameterized_dependencies.yaml +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_parameterized_deps_execution.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_state_persistence.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/integration/test_working_directory.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_cli.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_dependency_parsing.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_environment_tracking.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_executor.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_graph.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_hasher.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_list_formatting.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_parameterized_graph.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_parser.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_state.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_substitution.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/tests/unit/test_types.py +0 -0
- {tasktree-0.0.14 → tasktree-0.0.16}/uv.lock +0 -0
|
@@ -50,6 +50,12 @@ jobs:
|
|
|
50
50
|
python -m pipx install tasktree
|
|
51
51
|
shell: bash
|
|
52
52
|
|
|
53
|
+
- name: Get installed tt version
|
|
54
|
+
working-directory: example
|
|
55
|
+
run: |
|
|
56
|
+
tt --version
|
|
57
|
+
shell: bash
|
|
58
|
+
|
|
53
59
|
- name: Test tt --list command
|
|
54
60
|
working-directory: example
|
|
55
61
|
run: |
|
|
@@ -103,6 +109,13 @@ jobs:
|
|
|
103
109
|
pipx install --python python3.12 tasktree
|
|
104
110
|
shell: bash
|
|
105
111
|
|
|
112
|
+
- name: Get installed tt version
|
|
113
|
+
working-directory: example
|
|
114
|
+
run: |
|
|
115
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
116
|
+
tt --version
|
|
117
|
+
shell: bash
|
|
118
|
+
|
|
106
119
|
- name: Test tt --list command
|
|
107
120
|
working-directory: example
|
|
108
121
|
run: |
|
|
@@ -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:
|
|
@@ -662,6 +662,60 @@ class Executor:
|
|
|
662
662
|
except OSError:
|
|
663
663
|
pass # Ignore cleanup errors
|
|
664
664
|
|
|
665
|
+
def _substitute_builtin_in_environment(self, env: Environment, builtin_vars: dict[str, str]) -> Environment:
|
|
666
|
+
"""Substitute builtin and environment variables in environment fields.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
env: Environment to process
|
|
670
|
+
builtin_vars: Built-in variable values
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
New Environment with builtin and environment variables substituted
|
|
674
|
+
|
|
675
|
+
Raises:
|
|
676
|
+
ValueError: If builtin variable or environment variable is not defined
|
|
677
|
+
"""
|
|
678
|
+
from dataclasses import replace
|
|
679
|
+
|
|
680
|
+
# Substitute in volumes (builtin vars first, then env vars)
|
|
681
|
+
substituted_volumes = [
|
|
682
|
+
self._substitute_env(self._substitute_builtin(vol, builtin_vars)) for vol in env.volumes
|
|
683
|
+
] if env.volumes else []
|
|
684
|
+
|
|
685
|
+
# Substitute in env_vars values (builtin vars first, then env vars)
|
|
686
|
+
substituted_env_vars = {
|
|
687
|
+
key: self._substitute_env(self._substitute_builtin(value, builtin_vars))
|
|
688
|
+
for key, value in env.env_vars.items()
|
|
689
|
+
} if env.env_vars else {}
|
|
690
|
+
|
|
691
|
+
# Substitute in ports (builtin vars first, then env vars)
|
|
692
|
+
substituted_ports = [
|
|
693
|
+
self._substitute_env(self._substitute_builtin(port, builtin_vars)) for port in env.ports
|
|
694
|
+
] if env.ports else []
|
|
695
|
+
|
|
696
|
+
# Substitute in working_dir (builtin vars first, then env vars)
|
|
697
|
+
substituted_working_dir = self._substitute_env(self._substitute_builtin(env.working_dir, builtin_vars)) if env.working_dir else ""
|
|
698
|
+
|
|
699
|
+
# Substitute in build args (for Docker environments, args is a dict)
|
|
700
|
+
# Apply builtin vars first, then env vars
|
|
701
|
+
if isinstance(env.args, dict):
|
|
702
|
+
substituted_args = {
|
|
703
|
+
key: self._substitute_env(self._substitute_builtin(str(value), builtin_vars))
|
|
704
|
+
for key, value in env.args.items()
|
|
705
|
+
}
|
|
706
|
+
else:
|
|
707
|
+
substituted_args = env.args
|
|
708
|
+
|
|
709
|
+
# Create new environment with substituted values
|
|
710
|
+
return replace(
|
|
711
|
+
env,
|
|
712
|
+
volumes=substituted_volumes,
|
|
713
|
+
env_vars=substituted_env_vars,
|
|
714
|
+
ports=substituted_ports,
|
|
715
|
+
working_dir=substituted_working_dir,
|
|
716
|
+
args=substituted_args
|
|
717
|
+
)
|
|
718
|
+
|
|
665
719
|
def _run_task_in_docker(
|
|
666
720
|
self, task: Task, env: Any, cmd: str, working_dir: Path,
|
|
667
721
|
exported_env_vars: dict[str, str] | None = None
|
|
@@ -678,6 +732,13 @@ class Executor:
|
|
|
678
732
|
Raises:
|
|
679
733
|
ExecutionError: If Docker execution fails
|
|
680
734
|
"""
|
|
735
|
+
# Get builtin variables for substitution in environment fields
|
|
736
|
+
task_start_time = datetime.now(timezone.utc)
|
|
737
|
+
builtin_vars = self._collect_builtin_variables(task, working_dir, task_start_time)
|
|
738
|
+
|
|
739
|
+
# Substitute builtin variables in environment fields (volumes, env_vars, etc.)
|
|
740
|
+
env = self._substitute_builtin_in_environment(env, builtin_vars)
|
|
741
|
+
|
|
681
742
|
# Resolve container working directory
|
|
682
743
|
container_working_dir = docker_module.resolve_container_working_dir(
|
|
683
744
|
env.working_dir, task.working_dir
|
|
@@ -5,7 +5,161 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from tasktree.hasher import hash_args
|
|
8
|
-
from tasktree.parser import
|
|
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)
|