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