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/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
- """State for a single task execution."""
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
- """Convert to dictionary for JSON serialization."""
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
- """Create from dictionary loaded from JSON."""
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
- """Manages the .tasktree-state file."""
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
- """Initialize state manager.
52
+ """
53
+ Initialize state manager.
41
54
 
42
55
  Args:
43
- project_root: Root directory of the project
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
- """Load state from file if it exists."""
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) as e:
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
- """Save state to file."""
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
- """Get state for a task.
91
+ """
92
+ Get state for a task.
73
93
 
74
94
  Args:
75
- cache_key: Cache key (task_hash or task_hash__args_hash)
95
+ cache_key: Cache key (task_hash or task_hash__args_hash)
76
96
 
77
97
  Returns:
78
- TaskState if found, None otherwise
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
- """Set state for a task.
106
+ """
107
+ Set state for a task.
86
108
 
87
109
  Args:
88
- cache_key: Cache key (task_hash or task_hash__args_hash)
89
- state: TaskState to store
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: set[str]) -> None:
96
- """Remove state entries for tasks that no longer exist.
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
- valid_task_hashes: Set of valid task hashes from current recipe
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
- """Clear all state (useful for testing)."""
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
- """Placeholder substitution for variables, arguments, and environment variables.
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'\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
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'\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
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'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
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(text: str | dict[str, Any], variables: dict[str, str]) -> str | dict[str, Any]:
32
- """Substitute {{ var.name }} placeholders with variable values.
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
- text: Text containing {{ var.name }} placeholders, or an argument dict with elements to be substituted
36
- variables: Dictionary mapping variable names to their string values
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
- Text with all {{ var.name }} placeholders replaced
42
+ Text with all {{ var.name }} placeholders replaced
40
43
 
41
44
  Raises:
42
- ValueError: If a referenced variable is not defined
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 [ "default", "min", "max" ]:
55
+ for field in ["default", "min", "max"]:
52
56
  if field in text[arg_name]:
53
- text[arg_name][field] = substitute_variables(text[arg_name][field], 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"] = [substitute_variables(c, variables) for c in 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(text: str, args: dict[str, Any], exported_args: set[str] | None = None) -> str:
88
- """Substitute {{ arg.name }} placeholders with argument values.
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
- text: Text containing {{ arg.name }} placeholders
92
- args: Dictionary mapping argument names to their values
93
- exported_args: Set of argument names that are exported (not available for substitution)
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
- Text with all {{ arg.name }} placeholders replaced
108
+ Text with all {{ arg.name }} placeholders replaced
97
109
 
98
110
  Raises:
99
- ValueError: If a referenced argument is not provided or is exported
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
- """Substitute {{ env.NAME }} placeholders with environment variable values.
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
- text: Text containing {{ env.NAME }} placeholders
156
+ text: Text containing {{ env.NAME }} placeholders
143
157
 
144
158
  Returns:
145
- Text with all {{ env.NAME }} placeholders replaced
159
+ Text with all {{ env.NAME }} placeholders replaced
146
160
 
147
161
  Raises:
148
- ValueError: If a referenced environment variable is not set
162
+ ValueError: If a referenced environment variable is not set
149
163
 
150
164
  Example:
151
- >>> os.environ['USER'] = 'alice'
152
- >>> substitute_environment("Hello {{ env.USER }}")
153
- 'Hello alice'
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
- """Substitute {{ tt.name }} placeholders with built-in variable values.
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
- text: Text containing {{ tt.name }} placeholders
183
- builtin_vars: Dictionary mapping built-in variable names to their string values
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
- Text with all {{ tt.name }} placeholders replaced
200
+ Text with all {{ tt.name }} placeholders replaced
187
201
 
188
202
  Raises:
189
- ValueError: If a referenced built-in variable is not defined
203
+ ValueError: If a referenced built-in variable is not defined
190
204
 
191
205
  Example:
192
- >>> builtin_vars = {'project_root': '/home/user/project', 'task_name': 'build'}
193
- >>> substitute_builtin_variables("Root: {{ tt.project_root }}", builtin_vars)
194
- 'Root: /home/user/project'
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
- """Substitute {{ arg.* }} templates in dependency argument values.
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
- template_value: String that may contain {{ arg.* }} placeholders
228
- parent_task_name: Name of parent task (for error messages)
229
- parent_args: Parent task's argument values
230
- exported_args: Set of parent's exported argument names
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
- String with {{ arg.* }} placeholders substituted
250
+ String with {{ arg.* }} placeholders substituted
234
251
 
235
252
  Raises:
236
- ValueError: If template references undefined arg, uses exported arg,
237
- or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
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
- >>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
241
- 'debug'
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
- """Substitute all placeholder types: variables, arguments, environment.
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
- text: Text containing placeholders
292
- variables: Dictionary mapping variable names to their string values
293
- args: Dictionary mapping argument names to their values
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
- Text with all placeholders replaced
315
+ Text with all placeholders replaced
297
316
 
298
317
  Raises:
299
- ValueError: If any referenced variable, argument, or environment variable is not defined
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
- """Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
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
- text: Text containing {{ dep.*.outputs.* }} placeholders
323
- current_task_name: Name of task being resolved (for error messages)
324
- current_task_deps: List of dependency task names for the current task
325
- resolved_tasks: Dictionary mapping task names to Task objects (already resolved)
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
- Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
349
+ Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
329
350
 
330
351
  Raises:
331
- ValueError: If referenced task doesn't exist, isn't a dependency,
332
- or doesn't have the named output
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
- >>> # Assuming build task has output { bundle: "dist/app.js" }
336
- >>> substitute_dependency_outputs(
337
- ... "Deploy {{ dep.build.outputs.bundle }}",
338
- ... "deploy",
339
- ... ["build"],
340
- ... {"build": build_task}
341
- ... )
342
- 'Deploy dist/app.js'
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 = ", ".join(available) if available else "(none - all outputs are anonymous)"
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
- """Substitute {{ self.inputs.name }} and {{ self.outputs.name }} placeholders.
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
- text: Text containing {{ self.* }} placeholders
397
- task_name: Name of current task (for error messages)
398
- input_map: Dictionary mapping input names to path strings
399
- output_map: Dictionary mapping output names to path strings
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
- Text with all {{ self.* }} placeholders replaced with literal path strings
440
+ Text with all {{ self.* }} placeholders replaced with literal path strings
403
441
 
404
442
  Raises:
405
- ValueError: If referenced name doesn't exist in input_map or output_map
443
+ ValueError: If referenced name doesn't exist or index is out of bounds
406
444
 
407
445
  Example:
408
- >>> input_map = {"src": "*.txt"}
409
- >>> output_map = {"dest": "out/result.txt"}
410
- >>> substitute_self_references(
411
- ... "cp {{ self.inputs.src }} {{ self.outputs.dest }}",
412
- ... "copy",
413
- ... input_map,
414
- ... output_map
415
- ... )
416
- 'cp *.txt out/result.txt'
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
- name = match.group(2)
464
+ identifier = match.group(2) # name or numeric index
421
465
 
422
- # Select appropriate map
423
- if field == "inputs":
424
- name_map = input_map
425
- field_display = "input"
426
- else: # field == "outputs"
427
- name_map = output_map
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
- raise ValueError(
439
- f"Task '{task_name}' references {field_display} '{name}' "
440
- f"but has no {field_display} named '{name}'.\n"
441
- f"Available named {field}: {available_msg}\n"
442
- f"Hint: Define named {field} like: {field}: [{{ {name}: 'path/to/file' }}]"
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
- return name_map[name]
516
+ return name_map[name]
446
517
 
447
518
  return SELF_REFERENCE_PATTERN.sub(replacer, text)