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