tasktree 0.0.19__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 +6 -1
- tasktree/cli.py +119 -33
- tasktree/docker.py +87 -53
- tasktree/executor.py +208 -131
- tasktree/graph.py +176 -64
- tasktree/hasher.py +68 -19
- tasktree/parser.py +408 -233
- tasktree/state.py +46 -17
- tasktree/substitution.py +193 -61
- tasktree/types.py +50 -12
- {tasktree-0.0.19.dist-info → tasktree-0.0.21.dist-info}/METADATA +364 -1
- tasktree-0.0.21.dist-info/RECORD +14 -0
- tasktree-0.0.19.dist-info/RECORD +0 -14
- {tasktree-0.0.19.dist-info → tasktree-0.0.21.dist-info}/WHEEL +0 -0
- {tasktree-0.0.19.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
|
|
@@ -12,28 +14,36 @@ from typing import Any
|
|
|
12
14
|
# Pattern matches: {{ prefix.name }} with optional whitespace
|
|
13
15
|
# Groups: (1) prefix (var|arg|env|tt), (2) name (identifier)
|
|
14
16
|
PLACEHOLDER_PATTERN = re.compile(
|
|
15
|
-
r'\{\{\s*(var|arg|env|tt)
|
|
17
|
+
r'\{\{\s*(var|arg|env|tt)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
# Pattern matches: {{ dep.task_name.outputs.output_name }} with optional whitespace
|
|
19
21
|
# Groups: (1) task_name (can include dots for namespacing), (2) output_name (identifier)
|
|
20
22
|
DEP_OUTPUT_PATTERN = re.compile(
|
|
21
|
-
r'\{\{\s*dep
|
|
23
|
+
r'\{\{\s*dep\.([a-zA-Z_][a-zA-Z0-9_.-]*)\.outputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
|
|
24
|
+
)
|
|
25
|
+
|
|
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)
|
|
28
|
+
SELF_REFERENCE_PATTERN = re.compile(
|
|
29
|
+
r'\{\{\s*self\.(inputs|outputs)\.([a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\s*\}\}'
|
|
22
30
|
)
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
def substitute_variables(text: str | dict[str, Any], variables: dict[str, str]) -> str | dict[str, Any]:
|
|
26
|
-
"""
|
|
34
|
+
"""
|
|
35
|
+
Substitute {{ var.name }} placeholders with variable values.
|
|
27
36
|
|
|
28
37
|
Args:
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
40
|
|
|
32
41
|
Returns:
|
|
33
|
-
|
|
42
|
+
Text with all {{ var.name }} placeholders replaced
|
|
34
43
|
|
|
35
44
|
Raises:
|
|
36
|
-
|
|
45
|
+
ValueError: If a referenced variable is not defined
|
|
46
|
+
@athena: 93e0fbbe447c
|
|
37
47
|
"""
|
|
38
48
|
if isinstance(text, dict):
|
|
39
49
|
# The dict will only contain a single key, the value of this key should also be a dictionary, which contains
|
|
@@ -79,18 +89,20 @@ def substitute_variables(text: str | dict[str, Any], variables: dict[str, str])
|
|
|
79
89
|
|
|
80
90
|
|
|
81
91
|
def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str] | None = None) -> str:
|
|
82
|
-
"""
|
|
92
|
+
"""
|
|
93
|
+
Substitute {{ arg.name }} placeholders with argument values.
|
|
83
94
|
|
|
84
95
|
Args:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
88
99
|
|
|
89
100
|
Returns:
|
|
90
|
-
|
|
101
|
+
Text with all {{ arg.name }} placeholders replaced
|
|
91
102
|
|
|
92
103
|
Raises:
|
|
93
|
-
|
|
104
|
+
ValueError: If a referenced argument is not provided or is exported
|
|
105
|
+
@athena: 39577c2b74a6
|
|
94
106
|
"""
|
|
95
107
|
# Use empty set if None for cleaner handling
|
|
96
108
|
exported_args = exported_args or set()
|
|
@@ -128,23 +140,25 @@ def substitute_arguments(text: str, args: dict[str, Any], exported_args: set[str
|
|
|
128
140
|
|
|
129
141
|
|
|
130
142
|
def substitute_environment(text: str) -> str:
|
|
131
|
-
"""
|
|
143
|
+
"""
|
|
144
|
+
Substitute {{ env.NAME }} placeholders with environment variable values.
|
|
132
145
|
|
|
133
146
|
Environment variables are read from os.environ at substitution time.
|
|
134
147
|
|
|
135
148
|
Args:
|
|
136
|
-
|
|
149
|
+
text: Text containing {{ env.NAME }} placeholders
|
|
137
150
|
|
|
138
151
|
Returns:
|
|
139
|
-
|
|
152
|
+
Text with all {{ env.NAME }} placeholders replaced
|
|
140
153
|
|
|
141
154
|
Raises:
|
|
142
|
-
|
|
155
|
+
ValueError: If a referenced environment variable is not set
|
|
143
156
|
|
|
144
157
|
Example:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
>>> os.environ['USER'] = 'alice'
|
|
159
|
+
>>> substitute_environment("Hello {{ env.USER }}")
|
|
160
|
+
'Hello alice'
|
|
161
|
+
@athena: 4f2afd2e0da2
|
|
148
162
|
"""
|
|
149
163
|
import os
|
|
150
164
|
|
|
@@ -168,24 +182,26 @@ def substitute_environment(text: str) -> str:
|
|
|
168
182
|
|
|
169
183
|
|
|
170
184
|
def substitute_builtin_variables(text: str, builtin_vars: dict[str, str]) -> str:
|
|
171
|
-
"""
|
|
185
|
+
"""
|
|
186
|
+
Substitute {{ tt.name }} placeholders with built-in variable values.
|
|
172
187
|
|
|
173
188
|
Built-in variables are system-provided values that tasks can reference.
|
|
174
189
|
|
|
175
190
|
Args:
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
text: Text containing {{ tt.name }} placeholders
|
|
192
|
+
builtin_vars: Dictionary mapping built-in variable names to their string values
|
|
178
193
|
|
|
179
194
|
Returns:
|
|
180
|
-
|
|
195
|
+
Text with all {{ tt.name }} placeholders replaced
|
|
181
196
|
|
|
182
197
|
Raises:
|
|
183
|
-
|
|
198
|
+
ValueError: If a referenced built-in variable is not defined
|
|
184
199
|
|
|
185
200
|
Example:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
205
|
"""
|
|
190
206
|
def replace_match(match: re.Match) -> str:
|
|
191
207
|
prefix = match.group(1)
|
|
@@ -212,27 +228,29 @@ def substitute_dependency_args(
|
|
|
212
228
|
parent_args: dict[str, Any],
|
|
213
229
|
exported_args: set[str] | None = None
|
|
214
230
|
) -> str:
|
|
215
|
-
"""
|
|
231
|
+
"""
|
|
232
|
+
Substitute {{ arg.* }} templates in dependency argument values.
|
|
216
233
|
|
|
217
234
|
This function substitutes parent task's arguments into dependency argument
|
|
218
235
|
templates. Only {{ arg.* }} placeholders are allowed in dependency arguments.
|
|
219
236
|
|
|
220
237
|
Args:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
225
242
|
|
|
226
243
|
Returns:
|
|
227
|
-
|
|
244
|
+
String with {{ arg.* }} placeholders substituted
|
|
228
245
|
|
|
229
246
|
Raises:
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
ValueError: If template references undefined arg, uses exported arg,
|
|
248
|
+
or contains non-arg placeholders ({{ var.* }}, {{ env.* }}, {{ tt.* }})
|
|
232
249
|
|
|
233
250
|
Example:
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
>>> substitute_dependency_args("{{ arg.mode }}", "build", {"mode": "debug"})
|
|
252
|
+
'debug'
|
|
253
|
+
@athena: 3d07a1b4e6bc
|
|
236
254
|
"""
|
|
237
255
|
# Check for disallowed placeholder types in dependency args
|
|
238
256
|
# Only {{ arg.* }} is allowed, not {{ var.* }}, {{ env.* }}, or {{ tt.* }}
|
|
@@ -276,21 +294,23 @@ def substitute_dependency_args(
|
|
|
276
294
|
|
|
277
295
|
|
|
278
296
|
def substitute_all(text: str, variables: dict[str, str], args: dict[str, Any]) -> str:
|
|
279
|
-
"""
|
|
297
|
+
"""
|
|
298
|
+
Substitute all placeholder types: variables, arguments, environment.
|
|
280
299
|
|
|
281
300
|
Substitution order: variables → arguments → environment.
|
|
282
301
|
This allows variables to contain arg/env placeholders.
|
|
283
302
|
|
|
284
303
|
Args:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
304
|
+
text: Text containing placeholders
|
|
305
|
+
variables: Dictionary mapping variable names to their string values
|
|
306
|
+
args: Dictionary mapping argument names to their values
|
|
288
307
|
|
|
289
308
|
Returns:
|
|
290
|
-
|
|
309
|
+
Text with all placeholders replaced
|
|
291
310
|
|
|
292
311
|
Raises:
|
|
293
|
-
|
|
312
|
+
ValueError: If any referenced variable, argument, or environment variable is not defined
|
|
313
|
+
@athena: c3fe48d3df5a
|
|
294
314
|
"""
|
|
295
315
|
text = substitute_variables(text, variables)
|
|
296
316
|
text = substitute_arguments(text, args)
|
|
@@ -304,7 +324,8 @@ def substitute_dependency_outputs(
|
|
|
304
324
|
current_task_deps: list[str],
|
|
305
325
|
resolved_tasks: dict[str, Any],
|
|
306
326
|
) -> str:
|
|
307
|
-
"""
|
|
327
|
+
"""
|
|
328
|
+
Substitute {{ dep.<task>.outputs.<name> }} placeholders with dependency output paths.
|
|
308
329
|
|
|
309
330
|
This function resolves references to named outputs from dependency tasks.
|
|
310
331
|
It validates that:
|
|
@@ -313,27 +334,28 @@ def substitute_dependency_outputs(
|
|
|
313
334
|
- The referenced output name exists in the dependency task
|
|
314
335
|
|
|
315
336
|
Args:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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)
|
|
320
341
|
|
|
321
342
|
Returns:
|
|
322
|
-
|
|
343
|
+
Text with all {{ dep.*.outputs.* }} placeholders replaced with output paths
|
|
323
344
|
|
|
324
345
|
Raises:
|
|
325
|
-
|
|
326
|
-
|
|
346
|
+
ValueError: If referenced task doesn't exist, isn't a dependency,
|
|
347
|
+
or doesn't have the named output
|
|
327
348
|
|
|
328
349
|
Example:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
337
359
|
"""
|
|
338
360
|
def replacer(match: re.Match) -> str:
|
|
339
361
|
dep_task_name = match.group(1)
|
|
@@ -372,3 +394,113 @@ def substitute_dependency_outputs(
|
|
|
372
394
|
return dep_task._output_map[output_name]
|
|
373
395
|
|
|
374
396
|
return DEP_OUTPUT_PATTERN.sub(replacer, text)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def substitute_self_references(
|
|
400
|
+
text: str,
|
|
401
|
+
task_name: str,
|
|
402
|
+
input_map: dict[str, str],
|
|
403
|
+
output_map: dict[str, str],
|
|
404
|
+
indexed_inputs: list[str],
|
|
405
|
+
indexed_outputs: list[str],
|
|
406
|
+
) -> str:
|
|
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).
|
|
417
|
+
|
|
418
|
+
The substitution is literal string replacement - no glob expansion or path resolution.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
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
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Text with all {{ self.* }} placeholders replaced with literal path strings
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
ValueError: If referenced name doesn't exist or index is out of bounds
|
|
433
|
+
|
|
434
|
+
Example:
|
|
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
|
|
449
|
+
"""
|
|
450
|
+
def replacer(match: re.Match) -> str:
|
|
451
|
+
field = match.group(1) # "inputs" or "outputs"
|
|
452
|
+
identifier = match.group(2) # name or numeric index
|
|
453
|
+
|
|
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"
|
|
460
|
+
|
|
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
|
+
)
|
|
503
|
+
|
|
504
|
+
return name_map[name]
|
|
505
|
+
|
|
506
|
+
return SELF_REFERENCE_PATTERN.sub(replacer, text)
|
tasktree/types.py
CHANGED
|
@@ -10,7 +10,10 @@ import click
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class HostnameType(click.ParamType):
|
|
13
|
-
"""
|
|
13
|
+
"""
|
|
14
|
+
Validates hostname format (not DNS resolution).
|
|
15
|
+
@athena: 84a721c40458
|
|
16
|
+
"""
|
|
14
17
|
|
|
15
18
|
name = "hostname"
|
|
16
19
|
|
|
@@ -20,6 +23,9 @@ class HostnameType(click.ParamType):
|
|
|
20
23
|
)
|
|
21
24
|
|
|
22
25
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
26
|
+
"""
|
|
27
|
+
@athena: 8d921e52bcf2
|
|
28
|
+
"""
|
|
23
29
|
if isinstance(value, str):
|
|
24
30
|
if self.HOSTNAME_PATTERN.match(value):
|
|
25
31
|
return value
|
|
@@ -27,7 +33,10 @@ class HostnameType(click.ParamType):
|
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
class EmailType(click.ParamType):
|
|
30
|
-
"""
|
|
36
|
+
"""
|
|
37
|
+
Validates email format (not deliverability).
|
|
38
|
+
@athena: 95cfacc3f4cd
|
|
39
|
+
"""
|
|
31
40
|
|
|
32
41
|
name = "email"
|
|
33
42
|
|
|
@@ -37,6 +46,9 @@ class EmailType(click.ParamType):
|
|
|
37
46
|
)
|
|
38
47
|
|
|
39
48
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
49
|
+
"""
|
|
50
|
+
@athena: 25046aeb6e6f
|
|
51
|
+
"""
|
|
40
52
|
if isinstance(value, str):
|
|
41
53
|
if self.EMAIL_PATTERN.match(value):
|
|
42
54
|
return value
|
|
@@ -44,11 +56,17 @@ class EmailType(click.ParamType):
|
|
|
44
56
|
|
|
45
57
|
|
|
46
58
|
class IPType(click.ParamType):
|
|
47
|
-
"""
|
|
59
|
+
"""
|
|
60
|
+
Validates IP address (IPv4 or IPv6).
|
|
61
|
+
@athena: dac837bf4894
|
|
62
|
+
"""
|
|
48
63
|
|
|
49
64
|
name = "ip"
|
|
50
65
|
|
|
51
66
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
67
|
+
"""
|
|
68
|
+
@athena: d57618e5ad89
|
|
69
|
+
"""
|
|
52
70
|
try:
|
|
53
71
|
ip_address(value)
|
|
54
72
|
return str(value)
|
|
@@ -57,11 +75,17 @@ class IPType(click.ParamType):
|
|
|
57
75
|
|
|
58
76
|
|
|
59
77
|
class IPv4Type(click.ParamType):
|
|
60
|
-
"""
|
|
78
|
+
"""
|
|
79
|
+
Validates IPv4 address.
|
|
80
|
+
@athena: ea5957643fe5
|
|
81
|
+
"""
|
|
61
82
|
|
|
62
83
|
name = "ipv4"
|
|
63
84
|
|
|
64
85
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
86
|
+
"""
|
|
87
|
+
@athena: 7ed2d17d1f1a
|
|
88
|
+
"""
|
|
65
89
|
try:
|
|
66
90
|
IPv4Address(value)
|
|
67
91
|
return str(value)
|
|
@@ -70,11 +94,17 @@ class IPv4Type(click.ParamType):
|
|
|
70
94
|
|
|
71
95
|
|
|
72
96
|
class IPv6Type(click.ParamType):
|
|
73
|
-
"""
|
|
97
|
+
"""
|
|
98
|
+
Validates IPv6 address.
|
|
99
|
+
@athena: 9bc5b38d4f23
|
|
100
|
+
"""
|
|
74
101
|
|
|
75
102
|
name = "ipv6"
|
|
76
103
|
|
|
77
104
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
105
|
+
"""
|
|
106
|
+
@athena: 4b101e4d54cf
|
|
107
|
+
"""
|
|
78
108
|
try:
|
|
79
109
|
IPv6Address(value)
|
|
80
110
|
return str(value)
|
|
@@ -83,11 +113,17 @@ class IPv6Type(click.ParamType):
|
|
|
83
113
|
|
|
84
114
|
|
|
85
115
|
class DateTimeType(click.ParamType):
|
|
86
|
-
"""
|
|
116
|
+
"""
|
|
117
|
+
Validates datetime in format YYYY-MM-DDTHH:MM:SS.
|
|
118
|
+
@athena: c3bafa3e22e3
|
|
119
|
+
"""
|
|
87
120
|
|
|
88
121
|
name = "datetime"
|
|
89
122
|
|
|
90
123
|
def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> str:
|
|
124
|
+
"""
|
|
125
|
+
@athena: 13fa66adfe94
|
|
126
|
+
"""
|
|
91
127
|
if isinstance(value, str):
|
|
92
128
|
try:
|
|
93
129
|
datetime.fromisoformat(value)
|
|
@@ -114,18 +150,20 @@ TYPE_MAPPING = {
|
|
|
114
150
|
|
|
115
151
|
|
|
116
152
|
def get_click_type(type_name: str, min_val: int | float | None = None, max_val: int | float | None = None) -> click.ParamType:
|
|
117
|
-
"""
|
|
153
|
+
"""
|
|
154
|
+
Get Click parameter type by name with optional range constraints.
|
|
118
155
|
|
|
119
156
|
Args:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
type_name: Type name from task definition (e.g., 'str', 'int', 'hostname')
|
|
158
|
+
min_val: Optional minimum value for numeric types
|
|
159
|
+
max_val: Optional maximum value for numeric types
|
|
123
160
|
|
|
124
161
|
Returns:
|
|
125
|
-
|
|
162
|
+
Click parameter type instance
|
|
126
163
|
|
|
127
164
|
Raises:
|
|
128
|
-
|
|
165
|
+
ValueError: If type_name is not recognized
|
|
166
|
+
@athena: d0912868676f
|
|
129
167
|
"""
|
|
130
168
|
if type_name not in TYPE_MAPPING:
|
|
131
169
|
raise ValueError(f"Unknown type: {type_name}")
|