tasktree 0.0.20__py3-none-any.whl → 0.0.22__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/__init__.py +4 -1
- tasktree/cli.py +198 -60
- tasktree/docker.py +105 -64
- tasktree/executor.py +427 -310
- tasktree/graph.py +138 -82
- tasktree/hasher.py +81 -25
- tasktree/parser.py +554 -344
- tasktree/state.py +50 -22
- tasktree/substitution.py +188 -117
- tasktree/types.py +80 -25
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/METADATA +147 -21
- tasktree-0.0.22.dist-info/RECORD +14 -0
- tasktree-0.0.20.dist-info/RECORD +0 -14
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/WHEEL +0 -0
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/entry_points.txt +0 -0
tasktree/state.py
CHANGED
|
@@ -5,18 +5,24 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Set
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
12
|
class TaskState:
|
|
13
|
-
"""
|
|
13
|
+
"""
|
|
14
|
+
State for a single task execution.
|
|
15
|
+
@athena: b08a937b7f2f
|
|
16
|
+
"""
|
|
14
17
|
|
|
15
18
|
last_run: float
|
|
16
19
|
input_state: dict[str, float | str] = field(default_factory=dict)
|
|
17
20
|
|
|
18
21
|
def to_dict(self) -> dict[str, Any]:
|
|
19
|
-
"""
|
|
22
|
+
"""
|
|
23
|
+
Convert to dictionary for JSON serialization.
|
|
24
|
+
@athena: 5f42efc35e77
|
|
25
|
+
"""
|
|
20
26
|
return {
|
|
21
27
|
"last_run": self.last_run,
|
|
22
28
|
"input_state": self.input_state,
|
|
@@ -24,7 +30,10 @@ class TaskState:
|
|
|
24
30
|
|
|
25
31
|
@classmethod
|
|
26
32
|
def from_dict(cls, data: dict[str, Any]) -> "TaskState":
|
|
27
|
-
"""
|
|
33
|
+
"""
|
|
34
|
+
Create from dictionary loaded from JSON.
|
|
35
|
+
@athena: d9237db7e7e7
|
|
36
|
+
"""
|
|
28
37
|
return cls(
|
|
29
38
|
last_run=data["last_run"],
|
|
30
39
|
input_state=data.get("input_state", {}),
|
|
@@ -32,15 +41,20 @@ class TaskState:
|
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
class StateManager:
|
|
35
|
-
"""
|
|
44
|
+
"""
|
|
45
|
+
Manages the .tasktree-state file.
|
|
46
|
+
@athena: 3dd3447bb53b
|
|
47
|
+
"""
|
|
36
48
|
|
|
37
49
|
STATE_FILE = ".tasktree-state"
|
|
38
50
|
|
|
39
51
|
def __init__(self, project_root: Path):
|
|
40
|
-
"""
|
|
52
|
+
"""
|
|
53
|
+
Initialize state manager.
|
|
41
54
|
|
|
42
55
|
Args:
|
|
43
|
-
|
|
56
|
+
project_root: Root directory of the project
|
|
57
|
+
@athena: a0afbd8ae591
|
|
44
58
|
"""
|
|
45
59
|
self.project_root = project_root
|
|
46
60
|
self.state_path = project_root / self.STATE_FILE
|
|
@@ -48,55 +62,66 @@ class StateManager:
|
|
|
48
62
|
self._loaded = False
|
|
49
63
|
|
|
50
64
|
def load(self) -> None:
|
|
51
|
-
"""
|
|
65
|
+
"""
|
|
66
|
+
Load state from file if it exists.
|
|
67
|
+
@athena: e0cf9097c590
|
|
68
|
+
"""
|
|
52
69
|
if self.state_path.exists():
|
|
53
70
|
try:
|
|
54
71
|
with open(self.state_path, "r") as f:
|
|
55
72
|
data = json.load(f)
|
|
56
73
|
self._state = {
|
|
57
|
-
key: TaskState.from_dict(value)
|
|
58
|
-
for key, value in data.items()
|
|
74
|
+
key: TaskState.from_dict(value) for key, value in data.items()
|
|
59
75
|
}
|
|
60
|
-
except (json.JSONDecodeError, KeyError)
|
|
76
|
+
except (json.JSONDecodeError, KeyError):
|
|
61
77
|
# If state file is corrupted, start fresh
|
|
62
78
|
self._state = {}
|
|
63
79
|
self._loaded = True
|
|
64
80
|
|
|
65
81
|
def save(self) -> None:
|
|
66
|
-
"""
|
|
82
|
+
"""
|
|
83
|
+
Save state to file.
|
|
84
|
+
@athena: 11e4a9761e4d
|
|
85
|
+
"""
|
|
67
86
|
data = {key: value.to_dict() for key, value in self._state.items()}
|
|
68
87
|
with open(self.state_path, "w") as f:
|
|
69
88
|
json.dump(data, f, indent=2)
|
|
70
89
|
|
|
71
90
|
def get(self, cache_key: str) -> TaskState | None:
|
|
72
|
-
"""
|
|
91
|
+
"""
|
|
92
|
+
Get state for a task.
|
|
73
93
|
|
|
74
94
|
Args:
|
|
75
|
-
|
|
95
|
+
cache_key: Cache key (task_hash or task_hash__args_hash)
|
|
76
96
|
|
|
77
97
|
Returns:
|
|
78
|
-
|
|
98
|
+
TaskState if found, None otherwise
|
|
99
|
+
@athena: fe5b27e855eb
|
|
79
100
|
"""
|
|
80
101
|
if not self._loaded:
|
|
81
102
|
self.load()
|
|
82
103
|
return self._state.get(cache_key)
|
|
83
104
|
|
|
84
105
|
def set(self, cache_key: str, state: TaskState) -> None:
|
|
85
|
-
"""
|
|
106
|
+
"""
|
|
107
|
+
Set state for a task.
|
|
86
108
|
|
|
87
109
|
Args:
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
cache_key: Cache key (task_hash or task_hash__args_hash)
|
|
111
|
+
state: TaskState to store
|
|
112
|
+
@athena: 244f16ea0ebc
|
|
90
113
|
"""
|
|
91
114
|
if not self._loaded:
|
|
92
115
|
self.load()
|
|
93
116
|
self._state[cache_key] = state
|
|
94
117
|
|
|
95
|
-
def prune(self, valid_task_hashes:
|
|
96
|
-
"""
|
|
118
|
+
def prune(self, valid_task_hashes: Set[str]) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Remove state entries for tasks that no longer exist.
|
|
97
121
|
|
|
98
122
|
Args:
|
|
99
|
-
|
|
123
|
+
valid_task_hashes: Set of valid task hashes from current recipe
|
|
124
|
+
@athena: 2717c6c244d3
|
|
100
125
|
"""
|
|
101
126
|
if not self._loaded:
|
|
102
127
|
self.load()
|
|
@@ -114,6 +139,9 @@ class StateManager:
|
|
|
114
139
|
del self._state[key]
|
|
115
140
|
|
|
116
141
|
def clear(self) -> None:
|
|
117
|
-
"""
|
|
142
|
+
"""
|
|
143
|
+
Clear all state (useful for testing).
|
|
144
|
+
@athena: 3a92e36d9f83
|
|
145
|
+
"""
|
|
118
146
|
self._state = {}
|
|
119
147
|
self._loaded = True
|
tasktree/substitution.py
CHANGED
|
@@ -1,45 +1,49 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Placeholder substitution for variables, arguments, and environment variables.
|
|
2
3
|
|
|
3
4
|
This module provides functions to substitute {{ var.name }}, {{ arg.name }},
|
|
4
5
|
and {{ env.NAME }} placeholders with their corresponding values.
|
|
6
|
+
@athena: f92441b6fff5
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
import re
|
|
8
|
-
from random import choice
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
|
-
|
|
12
12
|
# Pattern matches: {{ prefix.name }} with optional whitespace
|
|
13
13
|
# Groups: (1) prefix (var|arg|env|tt), (2) name (identifier)
|
|
14
14
|
PLACEHOLDER_PATTERN = re.compile(
|
|
15
|
-
r
|
|
15
|
+
r"\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*}}"
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
# Pattern matches: {{ dep.task_name.outputs.output_name }} with optional whitespace
|
|
19
19
|
# Groups: (1) task_name (can include dots for namespacing), (2) output_name (identifier)
|
|
20
20
|
DEP_OUTPUT_PATTERN = re.compile(
|
|
21
|
-
r
|
|
21
|
+
r"\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*}}"
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
# Pattern matches: {{ self.(inputs|outputs).name }} with optional whitespace
|
|
25
|
-
# Groups: (1) field (inputs|outputs), (2) name (identifier)
|
|
24
|
+
# Pattern matches: {{ self.(inputs|outputs).name }} or {{ self.(inputs|outputs).0 }} with optional whitespace
|
|
25
|
+
# Groups: (1) field (inputs|outputs), (2) name (identifier) or index (numeric)
|
|
26
26
|
SELF_REFERENCE_PATTERN = re.compile(
|
|
27
|
-
r
|
|
27
|
+
r"\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\s*}}"
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def substitute_variables(
|
|
32
|
-
|
|
31
|
+
def substitute_variables(
|
|
32
|
+
text: str | dict[str, Any], variables: dict[str, str]
|
|
33
|
+
) -> str | dict[str, Any]:
|
|
34
|
+
"""
|
|
35
|
+
Substitute {{ var.name }} placeholders with variable values.
|
|
33
36
|
|
|
34
37
|
Args:
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
text: Text containing {{ var.name }} placeholders, or an argument dict with elements to be substituted
|
|
39
|
+
variables: Dictionary mapping variable names to their string values
|
|
37
40
|
|
|
38
41
|
Returns:
|
|
39
|
-
|
|
42
|
+
Text with all {{ var.name }} placeholders replaced
|
|
40
43
|
|
|
41
44
|
Raises:
|
|
42
|
-
|
|
45
|
+
ValueError: If a referenced variable is not defined
|
|
46
|
+
@athena: 93e0fbbe447c
|
|
43
47
|
"""
|
|
44
48
|
if isinstance(text, dict):
|
|
45
49
|
# The dict will only contain a single key, the value of this key should also be a dictionary, which contains
|
|
@@ -48,13 +52,18 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
48
52
|
|
|
49
53
|
for arg_name in text.keys():
|
|
50
54
|
# Pull out and substitute the individual fields of an argument one at a time
|
|
51
|
-
for field in
|
|
55
|
+
for field in ["default", "min", "max"]:
|
|
52
56
|
if field in text[arg_name]:
|
|
53
|
-
text[arg_name][field] = substitute_variables(
|
|
57
|
+
text[arg_name][field] = substitute_variables(
|
|
58
|
+
text[arg_name][field], variables
|
|
59
|
+
)
|
|
54
60
|
|
|
55
61
|
# choices is a list of things
|
|
56
62
|
if "choices" in text[arg_name]:
|
|
57
|
-
text[arg_name]["choices"] = [
|
|
63
|
+
text[arg_name]["choices"] = [
|
|
64
|
+
substitute_variables(c, variables)
|
|
65
|
+
for c in text[arg_name]["choices"]
|
|
66
|
+
]
|
|
58
67
|
|
|
59
68
|
return text
|
|
60
69
|
else:
|
|
@@ -84,19 +93,23 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
84
93
|
return PLACEHOLDER_PATTERN.sub(replace_match, text)
|
|
85
94
|
|
|
86
95
|
|
|
87
|
-
def substitute_arguments(
|
|
88
|
-
|
|
96
|
+
def substitute_arguments(
|
|
97
|
+
text: str, args: dict[str, Any], exported_args: set[str] | None = None
|
|
98
|
+
) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Substitute {{ arg.name }} placeholders with argument values.
|
|
89
101
|
|
|
90
102
|
Args:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
text: Text containing {{ arg.name }} placeholders
|
|
104
|
+
args: Dictionary mapping argument names to their values
|
|
105
|
+
exported_args: Set of argument names that are exported (not available for substitution)
|
|
94
106
|
|
|
95
107
|
Returns:
|
|
96
|
-
|
|
108
|
+
Text with all {{ arg.name }} placeholders replaced
|
|
97
109
|
|
|
98
110
|
Raises:
|
|
99
|
-
|
|
111
|
+
ValueError: If a referenced argument is not provided or is exported
|
|
112
|
+
@athena: 39577c2b74a6
|
|
100
113
|
"""
|
|
101
114
|
# Use empty set if None for cleaner handling
|
|
102
115
|
exported_args = exported_args or set()
|
|
@@ -134,23 +147,25 @@ def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str
|
|
|
134
147
|
|
|
135
148
|
|
|
136
149
|
def substitute_environment(text: str) -> str:
|
|
137
|
-
"""
|
|
150
|
+
"""
|
|
151
|
+
Substitute {{ env.NAME }} placeholders with environment variable values.
|
|
138
152
|
|
|
139
153
|
Environment variables are read from os.environ at substitution time.
|
|
140
154
|
|
|
141
155
|
Args:
|
|
142
|
-
|
|
156
|
+
text: Text containing {{ env.NAME }} placeholders
|
|
143
157
|
|
|
144
158
|
Returns:
|
|
145
|
-
|
|
159
|
+
Text with all {{ env.NAME }} placeholders replaced
|
|
146
160
|
|
|
147
161
|
Raises:
|
|
148
|
-
|
|
162
|
+
ValueError: If a referenced environment variable is not set
|
|
149
163
|
|
|
150
164
|
Example:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
165
|
+
>>> os.environ['USER'] = 'alice'
|
|
166
|
+
>>> substitute_environment("Hello {{ env.USER }}")
|
|
167
|
+
'Hello alice'
|
|
168
|
+
@athena: 4f2afd2e0da2
|
|
154
169
|
"""
|
|
155
170
|
import os
|
|
156
171
|
|
|
@@ -164,9 +179,7 @@ def substitute_environment(text: str) -> str:
|
|
|
164
179
|
|
|
165
180
|
value = os.environ.get(name)
|
|
166
181
|
if value is None:
|
|
167
|
-
raise ValueError(
|
|
168
|
-
f"Environment variable '{name}' is not set"
|
|
169
|
-
)
|
|
182
|
+
raise ValueError(f"Environment variable '{name}' is not set")
|
|
170
183
|
|
|
171
184
|
return value
|
|
172
185
|
|
|
@@ -174,25 +187,28 @@ def substitute_environment(text: str) -> str:
|
|
|
174
187
|
|
|
175
188
|
|
|
176
189
|
def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str:
|
|
177
|
-
"""
|
|
190
|
+
"""
|
|
191
|
+
Substitute {{ tt.name }} placeholders with built-in variable values.
|
|
178
192
|
|
|
179
193
|
Built-in variables are system-provided values that tasks can reference.
|
|
180
194
|
|
|
181
195
|
Args:
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
text: Text containing {{ tt.name }} placeholders
|
|
197
|
+
builtin_vars: Dictionary mapping built-in variable names to their string values
|
|
184
198
|
|
|
185
199
|
Returns:
|
|
186
|
-
|
|
200
|
+
Text with all {{ tt.name }} placeholders replaced
|
|
187
201
|
|
|
188
202
|
Raises:
|
|
189
|
-
|
|
203
|
+
ValueError: If a referenced built-in variable is not defined
|
|
190
204
|
|
|
191
205
|
Example:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
>>> builtin_vars = {'project_root': '/home/user/project', 'task_name': 'build'}
|
|
207
|
+
>>> substitute_builtin_variables("Root: {{ tt.project_root }}", builtin_vars)
|
|
208
|
+
'Root: /home/user/project'
|
|
209
|
+
@athena: 716250e3a71f
|
|
195
210
|
"""
|
|
211
|
+
|
|
196
212
|
def replace_match(match: re.Match) -> str:
|
|
197
213
|
prefix = match.group(1)
|
|
198
214
|
name = match.group(2)
|
|
@@ -216,29 +232,31 @@ def substitute_dependency_args(
|
|
|
216
232
|
template_value: str,
|
|
217
233
|
parent_task_name: str,
|
|
218
234
|
parent_args: dict[str, Any],
|
|
219
|
-
exported_args: set[str] | None = None
|
|
235
|
+
exported_args: set[str] | None = None,
|
|
220
236
|
) -> str:
|
|
221
|
-
"""
|
|
237
|
+
"""
|
|
238
|
+
Substitute {{ arg.* }} templates in dependency argument values.
|
|
222
239
|
|
|
223
240
|
This function substitutes parent task's arguments into dependency argument
|
|
224
241
|
templates. Only {{ arg.* }} placeholders are allowed in dependency arguments.
|
|
225
242
|
|
|
226
243
|
Args:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
244
|
+
template_value: String that may contain {{ arg.* }} placeholders
|
|
245
|
+
parent_task_name: Name of parent task (for error messages)
|
|
246
|
+
parent_args: Parent task's argument values
|
|
247
|
+
exported_args: Set of parent's exported argument names
|
|
231
248
|
|
|
232
249
|
Returns:
|
|
233
|
-
|
|
250
|
+
String with {{ arg.* }} placeholders substituted
|
|
234
251
|
|
|
235
252
|
Raises:
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
ValueError: If template references undefined arg, uses exported arg,
|
|
254
|
+
or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
|
|
238
255
|
|
|
239
256
|
Example:
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
>>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
|
|
258
|
+
'debug'
|
|
259
|
+
@athena: 3d07a1b4e6bc
|
|
242
260
|
"""
|
|
243
261
|
# Check for disallowed placeholder types in dependency args
|
|
244
262
|
# Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
|
|
@@ -282,21 +300,23 @@ def substitute_dependency_args(
|
|
|
282
300
|
|
|
283
301
|
|
|
284
302
|
def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -> str:
|
|
285
|
-
"""
|
|
303
|
+
"""
|
|
304
|
+
Substitute all placeholder types: variables, arguments, environment.
|
|
286
305
|
|
|
287
306
|
Substitution order: variables → arguments → environment.
|
|
288
307
|
This allows variables to contain arg/env placeholders.
|
|
289
308
|
|
|
290
309
|
Args:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
310
|
+
text: Text containing placeholders
|
|
311
|
+
variables: Dictionary mapping variable names to their string values
|
|
312
|
+
args: Dictionary mapping argument names to their values
|
|
294
313
|
|
|
295
314
|
Returns:
|
|
296
|
-
|
|
315
|
+
Text with all placeholders replaced
|
|
297
316
|
|
|
298
317
|
Raises:
|
|
299
|
-
|
|
318
|
+
ValueError: If any referenced variable, argument, or environment variable is not defined
|
|
319
|
+
@athena: c3fe48d3df5a
|
|
300
320
|
"""
|
|
301
321
|
text = substitute_variables(text, variables)
|
|
302
322
|
text = substitute_arguments(text, args)
|
|
@@ -310,7 +330,8 @@ def substitute_dependency_outputs(
|
|
|
310
330
|
current_task_deps: list[str],
|
|
311
331
|
resolved_tasks: dict[str, Any],
|
|
312
332
|
) -> str:
|
|
313
|
-
"""
|
|
333
|
+
"""
|
|
334
|
+
Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
|
|
314
335
|
|
|
315
336
|
This function resolves references to named outputs from dependency tasks.
|
|
316
337
|
It validates that:
|
|
@@ -319,28 +340,30 @@ def substitute_dependency_outputs(
|
|
|
319
340
|
- The referenced output name exists in the dependency task
|
|
320
341
|
|
|
321
342
|
Args:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
343
|
+
text: Text containing {{ dep.*.outputs.* }} placeholders
|
|
344
|
+
current_task_name: Name of task being resolved (for error messages)
|
|
345
|
+
current_task_deps: List of dependency task names for the current task
|
|
346
|
+
resolved_tasks: Dictionary mapping task names to Task objects (already resolved)
|
|
326
347
|
|
|
327
348
|
Returns:
|
|
328
|
-
|
|
349
|
+
Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
|
|
329
350
|
|
|
330
351
|
Raises:
|
|
331
|
-
|
|
332
|
-
|
|
352
|
+
ValueError: If referenced task doesn't exist, isn't a dependency,
|
|
353
|
+
or doesn't have the named output
|
|
333
354
|
|
|
334
355
|
Example:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
356
|
+
>>> # Assuming build task has output { bundle: "dist/app.js" }
|
|
357
|
+
>>> substitute_dependency_outputs(
|
|
358
|
+
... "Deploy {{ dep.build.outputs.bundle }}",
|
|
359
|
+
... "deploy",
|
|
360
|
+
... ["build"],
|
|
361
|
+
... {"build": build_task}
|
|
362
|
+
... )
|
|
363
|
+
'Deploy dist/app.js'
|
|
364
|
+
@athena: 1e537c8d579c
|
|
343
365
|
"""
|
|
366
|
+
|
|
344
367
|
def replacer(match: re.Match) -> str:
|
|
345
368
|
dep_task_name = match.group(1)
|
|
346
369
|
output_name = match.group(2)
|
|
@@ -367,7 +390,11 @@ def substitute_dependency_outputs(
|
|
|
367
390
|
# Look up the named output
|
|
368
391
|
if output_name not in dep_task._output_map:
|
|
369
392
|
available = list(dep_task._output_map.keys())
|
|
370
|
-
available_msg =
|
|
393
|
+
available_msg = (
|
|
394
|
+
", ".join(available)
|
|
395
|
+
if available
|
|
396
|
+
else "(none - all outputs are anonymous)"
|
|
397
|
+
)
|
|
371
398
|
raise ValueError(
|
|
372
399
|
f"Task '{current_task_name}' references output '{output_name}' "
|
|
373
400
|
f"from task '{dep_task_name}', but '{dep_task_name}' has no output named '{output_name}'.\n"
|
|
@@ -385,63 +412,107 @@ def substitute_self_references(
|
|
|
385
412
|
task_name: str,
|
|
386
413
|
input_map: dict[str, str],
|
|
387
414
|
output_map: dict[str, str],
|
|
415
|
+
indexed_inputs: list[str],
|
|
416
|
+
indexed_outputs: list[str],
|
|
388
417
|
) -> str:
|
|
389
|
-
"""
|
|
418
|
+
"""
|
|
419
|
+
Substitute {{ self.inputs.name }} and {{ self.outputs.name }} placeholders.
|
|
420
|
+
|
|
421
|
+
This function resolves references to the task's own inputs and outputs.
|
|
422
|
+
Supports both named access ({{ self.inputs.name }}) and positional access
|
|
423
|
+
({{ self.inputs.0 }}, {{ self.inputs.1 }}, etc.).
|
|
424
|
+
|
|
425
|
+
Named entries use the input_map/output_map dictionaries.
|
|
426
|
+
Positional entries use indexed_inputs/indexed_outputs lists (0-based indexing,
|
|
427
|
+
following YAML declaration order).
|
|
390
428
|
|
|
391
|
-
This function resolves references to the task's own named inputs and outputs.
|
|
392
|
-
Only named entries are accessible; anonymous inputs/outputs cannot be referenced.
|
|
393
429
|
The substitution is literal string replacement - no glob expansion or path resolution.
|
|
394
430
|
|
|
395
431
|
Args:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
432
|
+
text: Text containing {{ self.* }} placeholders
|
|
433
|
+
task_name: Name of current task (for error messages)
|
|
434
|
+
input_map: Dictionary mapping input names to path strings
|
|
435
|
+
output_map: Dictionary mapping output names to path strings
|
|
436
|
+
indexed_inputs: List of all inputs in YAML order
|
|
437
|
+
indexed_outputs: List of all outputs in YAML order
|
|
400
438
|
|
|
401
439
|
Returns:
|
|
402
|
-
|
|
440
|
+
Text with all {{ self.* }} placeholders replaced with literal path strings
|
|
403
441
|
|
|
404
442
|
Raises:
|
|
405
|
-
|
|
443
|
+
ValueError: If referenced name doesn't exist or index is out of bounds
|
|
406
444
|
|
|
407
445
|
Example:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
446
|
+
>>> input_map = {"src": "*.txt"}
|
|
447
|
+
>>> output_map = {"dest": "out/result.txt"}
|
|
448
|
+
>>> indexed_inputs = ["*.txt"]
|
|
449
|
+
>>> indexed_outputs = ["out/result.txt"]
|
|
450
|
+
>>> substitute_self_references(
|
|
451
|
+
... "cp {{ self.inputs.src }} {{ self.outputs.0 }}",
|
|
452
|
+
... "copy",
|
|
453
|
+
... input_map,
|
|
454
|
+
... output_map,
|
|
455
|
+
... indexed_inputs,
|
|
456
|
+
... indexed_outputs
|
|
457
|
+
... )
|
|
458
|
+
'cp *.txt out/result.txt'
|
|
459
|
+
@athena: 9d997ff08eef
|
|
417
460
|
"""
|
|
461
|
+
|
|
418
462
|
def replacer(match: re.Match) -> str:
|
|
419
463
|
field = match.group(1) # "inputs" or "outputs"
|
|
420
|
-
|
|
464
|
+
identifier = match.group(2) # name or numeric index
|
|
421
465
|
|
|
422
|
-
#
|
|
423
|
-
if
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
field_display = "output"
|
|
429
|
-
|
|
430
|
-
# Check if name exists in map
|
|
431
|
-
if name not in name_map:
|
|
432
|
-
available = list(name_map.keys())
|
|
433
|
-
if available:
|
|
434
|
-
available_msg = ", ".join(available)
|
|
435
|
-
else:
|
|
436
|
-
available_msg = f"(none - all {field} are anonymous)"
|
|
466
|
+
# Check if identifier is a numeric index
|
|
467
|
+
if identifier.isdigit():
|
|
468
|
+
# Positional access
|
|
469
|
+
index = int(identifier)
|
|
470
|
+
indexed_list = indexed_inputs if field == "inputs" else indexed_outputs
|
|
471
|
+
field_display = "input" if field == "inputs" else "output"
|
|
437
472
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
473
|
+
# Check if list is empty
|
|
474
|
+
if len(indexed_list) == 0:
|
|
475
|
+
raise ValueError(
|
|
476
|
+
f"Task '{task_name}' references {field_display} index '{index}' "
|
|
477
|
+
f"but has no {field} defined"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Check bounds
|
|
481
|
+
if index >= len(indexed_list):
|
|
482
|
+
max_index = len(indexed_list) - 1
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"Task '{task_name}' references {field_display} index '{index}' "
|
|
485
|
+
f"but only has {len(indexed_list)} {field} (indices 0-{max_index})"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return indexed_list[index]
|
|
489
|
+
else:
|
|
490
|
+
# Named access (existing logic)
|
|
491
|
+
name = identifier
|
|
492
|
+
|
|
493
|
+
# Select appropriate map
|
|
494
|
+
if field == "inputs":
|
|
495
|
+
name_map = input_map
|
|
496
|
+
field_display = "input"
|
|
497
|
+
else: # field == "outputs"
|
|
498
|
+
name_map = output_map
|
|
499
|
+
field_display = "output"
|
|
500
|
+
|
|
501
|
+
# Check if name exists in map
|
|
502
|
+
if name not in name_map:
|
|
503
|
+
available = list(name_map.keys())
|
|
504
|
+
if available:
|
|
505
|
+
available_msg = ", ".join(available)
|
|
506
|
+
else:
|
|
507
|
+
available_msg = f"(none - all {field} are anonymous)"
|
|
508
|
+
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"Task '{task_name}' references {field_display} '{name}' "
|
|
511
|
+
f"but has no {field_display} named '{name}'.\n"
|
|
512
|
+
f"Available named {field}: {available_msg}\n"
|
|
513
|
+
f"Hint: Define named {field} like: {field}: [{{ {name}: 'path/to/file' }}]"
|
|
514
|
+
)
|
|
444
515
|
|
|
445
|
-
|
|
516
|
+
return name_map[name]
|
|
446
517
|
|
|
447
518
|
return SELF_REFERENCE_PATTERN.sub(replacer, text)
|