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/parser.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Parse recipe YAML files and handle imports.
|
|
3
|
+
@athena: ec0087b16df9
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
from __future__ import annotations
|
|
4
7
|
|
|
@@ -16,17 +19,22 @@ from tasktree.types import get_click_type
|
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
class CircularImportError(Exception):
|
|
19
|
-
"""
|
|
22
|
+
"""
|
|
23
|
+
Raised when a circular import is detected.
|
|
24
|
+
@athena: 935d53bc7d05
|
|
25
|
+
"""
|
|
20
26
|
pass
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
@dataclass
|
|
24
30
|
class Environment:
|
|
25
|
-
"""
|
|
31
|
+
"""
|
|
32
|
+
Represents an execution environment configuration.
|
|
26
33
|
|
|
27
34
|
Can be either a shell environment or a Docker environment:
|
|
28
35
|
- Shell environment: has 'shell' field, executes directly on host
|
|
29
36
|
- Docker environment: has 'dockerfile' field, executes in container
|
|
37
|
+
@athena: cfe3f8754968
|
|
30
38
|
"""
|
|
31
39
|
|
|
32
40
|
name: str
|
|
@@ -44,14 +52,20 @@ class Environment:
|
|
|
44
52
|
run_as_root: bool = False # If True, skip user mapping (run as root in container)
|
|
45
53
|
|
|
46
54
|
def __post_init__(self):
|
|
47
|
-
"""
|
|
55
|
+
"""
|
|
56
|
+
Ensure args is in the correct format.
|
|
57
|
+
@athena: a4292f3f4150
|
|
58
|
+
"""
|
|
48
59
|
if isinstance(self.args, str):
|
|
49
60
|
self.args = [self.args]
|
|
50
61
|
|
|
51
62
|
|
|
52
63
|
@dataclass
|
|
53
64
|
class Task:
|
|
54
|
-
"""
|
|
65
|
+
"""
|
|
66
|
+
Represents a task definition.
|
|
67
|
+
@athena: f516b5ae61c5
|
|
68
|
+
"""
|
|
55
69
|
|
|
56
70
|
name: str
|
|
57
71
|
cmd: str
|
|
@@ -73,8 +87,15 @@ class Task:
|
|
|
73
87
|
_input_map: dict[str, str] = field(init=False, default_factory=dict, repr=False) # name → path mapping
|
|
74
88
|
_anonymous_inputs: list[str] = field(init=False, default_factory=list, repr=False) # unnamed inputs
|
|
75
89
|
|
|
90
|
+
# Internal fields for positional input/output access (built in __post_init__)
|
|
91
|
+
_indexed_inputs: list[str] = field(init=False, default_factory=list, repr=False) # all inputs in YAML order
|
|
92
|
+
_indexed_outputs: list[str] = field(init=False, default_factory=list, repr=False) # all outputs in YAML order
|
|
93
|
+
|
|
76
94
|
def __post_init__(self):
|
|
77
|
-
"""
|
|
95
|
+
"""
|
|
96
|
+
Ensure lists are always lists and build input/output maps and indexed lists.
|
|
97
|
+
@athena: a48b1eba81cd
|
|
98
|
+
"""
|
|
78
99
|
if isinstance(self.deps, str):
|
|
79
100
|
self.deps = [self.deps]
|
|
80
101
|
if isinstance(self.inputs, str):
|
|
@@ -100,6 +121,7 @@ class Task:
|
|
|
100
121
|
# Build output maps for efficient lookup
|
|
101
122
|
self._output_map = {}
|
|
102
123
|
self._anonymous_outputs = []
|
|
124
|
+
self._indexed_outputs = []
|
|
103
125
|
|
|
104
126
|
for idx, output in enumerate(self.outputs):
|
|
105
127
|
if isinstance(output, dict):
|
|
@@ -128,9 +150,11 @@ class Task:
|
|
|
128
150
|
)
|
|
129
151
|
|
|
130
152
|
self._output_map[name] = path
|
|
153
|
+
self._indexed_outputs.append(path)
|
|
131
154
|
elif isinstance(output, str):
|
|
132
155
|
# Anonymous output: just store
|
|
133
156
|
self._anonymous_outputs.append(output)
|
|
157
|
+
self._indexed_outputs.append(output)
|
|
134
158
|
else:
|
|
135
159
|
raise ValueError(
|
|
136
160
|
f"Task '{self.name}': Output at index {idx} must be a string or dict, got {type(output).__name__}: {output}"
|
|
@@ -139,6 +163,7 @@ class Task:
|
|
|
139
163
|
# Build input maps for efficient lookup
|
|
140
164
|
self._input_map = {}
|
|
141
165
|
self._anonymous_inputs = []
|
|
166
|
+
self._indexed_inputs = []
|
|
142
167
|
|
|
143
168
|
for idx, input_item in enumerate(self.inputs):
|
|
144
169
|
if isinstance(input_item, dict):
|
|
@@ -167,9 +192,11 @@ class Task:
|
|
|
167
192
|
)
|
|
168
193
|
|
|
169
194
|
self._input_map[name] = path
|
|
195
|
+
self._indexed_inputs.append(path)
|
|
170
196
|
elif isinstance(input_item, str):
|
|
171
197
|
# Anonymous input: just store
|
|
172
198
|
self._anonymous_inputs.append(input_item)
|
|
199
|
+
self._indexed_inputs.append(input_item)
|
|
173
200
|
else:
|
|
174
201
|
raise ValueError(
|
|
175
202
|
f"Task '{self.name}': Input at index {idx} must be a string or dict, got {type(input_item).__name__}: {input_item}"
|
|
@@ -178,23 +205,28 @@ class Task:
|
|
|
178
205
|
|
|
179
206
|
@dataclass
|
|
180
207
|
class DependencySpec:
|
|
181
|
-
"""
|
|
208
|
+
"""
|
|
209
|
+
Parsed dependency specification with potential template placeholders.
|
|
182
210
|
|
|
183
211
|
This represents a dependency as defined in the recipe file, before template
|
|
184
212
|
substitution. Argument values may contain {{ arg.* }} templates that will be
|
|
185
213
|
substituted with parent task's argument values during graph construction.
|
|
186
214
|
|
|
187
215
|
Attributes:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
task_name: Name of the dependency task
|
|
217
|
+
arg_templates: Dictionary mapping argument names to string templates
|
|
218
|
+
(None if no args specified). All values are strings, even
|
|
219
|
+
for numeric types, to preserve template placeholders.
|
|
220
|
+
@athena: 7b2f8a15d312
|
|
192
221
|
"""
|
|
193
222
|
task_name: str
|
|
194
223
|
arg_templates: dict[str, str] | None = None
|
|
195
224
|
|
|
196
225
|
def __str__(self) -> str:
|
|
197
|
-
"""
|
|
226
|
+
"""
|
|
227
|
+
String representation for display.
|
|
228
|
+
@athena: e5669be6329b
|
|
229
|
+
"""
|
|
198
230
|
if not self.arg_templates:
|
|
199
231
|
return self.task_name
|
|
200
232
|
args_str = ", ".join(f"{k}={v}" for k, v in self.arg_templates.items())
|
|
@@ -203,17 +235,22 @@ class DependencySpec:
|
|
|
203
235
|
|
|
204
236
|
@dataclass
|
|
205
237
|
class DependencyInvocation:
|
|
206
|
-
"""
|
|
238
|
+
"""
|
|
239
|
+
Represents a task dependency invocation with optional arguments.
|
|
207
240
|
|
|
208
241
|
Attributes:
|
|
209
|
-
|
|
210
|
-
|
|
242
|
+
task_name: Name of the dependency task
|
|
243
|
+
args: Dictionary of argument names to values (None if no args specified)
|
|
244
|
+
@athena: 0c023366160b
|
|
211
245
|
"""
|
|
212
246
|
task_name: str
|
|
213
247
|
args: dict[str, Any] | None = None
|
|
214
248
|
|
|
215
249
|
def __str__(self) -> str:
|
|
216
|
-
"""
|
|
250
|
+
"""
|
|
251
|
+
String representation for display.
|
|
252
|
+
@athena: 22fc0502192b
|
|
253
|
+
"""
|
|
217
254
|
if not self.args:
|
|
218
255
|
return self.task_name
|
|
219
256
|
args_str = ", ".join(f"{k}={v}" for k, v in self.args.items())
|
|
@@ -222,16 +259,18 @@ class DependencyInvocation:
|
|
|
222
259
|
|
|
223
260
|
@dataclass
|
|
224
261
|
class ArgSpec:
|
|
225
|
-
"""
|
|
262
|
+
"""
|
|
263
|
+
Represents a parsed argument specification.
|
|
226
264
|
|
|
227
265
|
Attributes:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
266
|
+
name: Argument name
|
|
267
|
+
arg_type: Type of the argument (str, int, float, bool, path)
|
|
268
|
+
default: Default value as a string (None if no default)
|
|
269
|
+
is_exported: Whether the argument is exported as an environment variable
|
|
270
|
+
min_val: Minimum value for numeric arguments (None if not specified)
|
|
271
|
+
max_val: Maximum value for numeric arguments (None if not specified)
|
|
272
|
+
choices: List of valid choices for the argument (None if not specified)
|
|
273
|
+
@athena: fcaf20fb1ca2
|
|
235
274
|
"""
|
|
236
275
|
name: str
|
|
237
276
|
arg_type: str
|
|
@@ -244,7 +283,10 @@ class ArgSpec:
|
|
|
244
283
|
|
|
245
284
|
@dataclass
|
|
246
285
|
class Recipe:
|
|
247
|
-
"""
|
|
286
|
+
"""
|
|
287
|
+
Represents a parsed recipe file with all tasks.
|
|
288
|
+
@athena: 47f568c77013
|
|
289
|
+
"""
|
|
248
290
|
|
|
249
291
|
tasks: dict[str, Task]
|
|
250
292
|
project_root: Path
|
|
@@ -259,33 +301,41 @@ class Recipe:
|
|
|
259
301
|
_original_yaml_data: dict[str, Any] = field(default_factory=dict) # Store original YAML data for lazy evaluation context
|
|
260
302
|
|
|
261
303
|
def get_task(self, name: str) -> Task | None:
|
|
262
|
-
"""
|
|
304
|
+
"""
|
|
305
|
+
Get task by name.
|
|
263
306
|
|
|
264
307
|
Args:
|
|
265
|
-
|
|
308
|
+
name: Task name (may be namespaced like 'build.compile')
|
|
266
309
|
|
|
267
310
|
Returns:
|
|
268
|
-
|
|
311
|
+
Task if found, None otherwise
|
|
312
|
+
@athena: 3f8137d71757
|
|
269
313
|
"""
|
|
270
314
|
return self.tasks.get(name)
|
|
271
315
|
|
|
272
316
|
def task_names(self) -> list[str]:
|
|
273
|
-
"""
|
|
317
|
+
"""
|
|
318
|
+
Get all task names.
|
|
319
|
+
@athena: 1df54563a7b6
|
|
320
|
+
"""
|
|
274
321
|
return list(self.tasks.keys())
|
|
275
322
|
|
|
276
323
|
def get_environment(self, name: str) -> Environment | None:
|
|
277
|
-
"""
|
|
324
|
+
"""
|
|
325
|
+
Get environment by name.
|
|
278
326
|
|
|
279
327
|
Args:
|
|
280
|
-
|
|
328
|
+
name: Environment name
|
|
281
329
|
|
|
282
330
|
Returns:
|
|
283
|
-
|
|
331
|
+
Environment if found, None otherwise
|
|
332
|
+
@athena: 098227ca38a2
|
|
284
333
|
"""
|
|
285
334
|
return self.environments.get(name)
|
|
286
335
|
|
|
287
336
|
def evaluate_variables(self, root_task: str | None = None) -> None:
|
|
288
|
-
"""
|
|
337
|
+
"""
|
|
338
|
+
Evaluate variables lazily based on task reachability.
|
|
289
339
|
|
|
290
340
|
This method implements lazy variable evaluation, which only evaluates
|
|
291
341
|
variables that are actually reachable from the target task. This provides:
|
|
@@ -299,15 +349,16 @@ class Recipe:
|
|
|
299
349
|
This method is idempotent - calling it multiple times is safe (uses caching).
|
|
300
350
|
|
|
301
351
|
Args:
|
|
302
|
-
|
|
352
|
+
root_task: Optional task name to determine reachability (None = evaluate all)
|
|
303
353
|
|
|
304
354
|
Raises:
|
|
305
|
-
|
|
355
|
+
ValueError: If variable evaluation or substitution fails
|
|
306
356
|
|
|
307
357
|
Example:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
358
|
+
>>> recipe = parse_recipe(path) # Variables not yet evaluated
|
|
359
|
+
>>> recipe.evaluate_variables("build") # Evaluate only reachable variables
|
|
360
|
+
>>> # Now recipe.evaluated_variables contains only vars used by "build" task
|
|
361
|
+
@athena: d8de7b5f42b6
|
|
311
362
|
"""
|
|
312
363
|
if self._variables_evaluated:
|
|
313
364
|
return # Already evaluated, skip (idempotent)
|
|
@@ -454,7 +505,8 @@ class Recipe:
|
|
|
454
505
|
|
|
455
506
|
|
|
456
507
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
457
|
-
"""
|
|
508
|
+
"""
|
|
509
|
+
Find recipe file in current or parent directories.
|
|
458
510
|
|
|
459
511
|
Looks for recipe files matching these patterns (in order of preference):
|
|
460
512
|
- tasktree.yaml
|
|
@@ -466,13 +518,14 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
466
518
|
with instructions to use --tasks option.
|
|
467
519
|
|
|
468
520
|
Args:
|
|
469
|
-
|
|
521
|
+
start_dir: Directory to start searching from (defaults to cwd)
|
|
470
522
|
|
|
471
523
|
Returns:
|
|
472
|
-
|
|
524
|
+
Path to recipe file if found, None otherwise
|
|
473
525
|
|
|
474
526
|
Raises:
|
|
475
|
-
|
|
527
|
+
ValueError: If multiple recipe files found in the same directory
|
|
528
|
+
@athena: 38ccb0c1bb86
|
|
476
529
|
"""
|
|
477
530
|
if start_dir is None:
|
|
478
531
|
start_dir = Path.cwd()
|
|
@@ -532,13 +585,15 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
532
585
|
|
|
533
586
|
|
|
534
587
|
def _validate_variable_name(name: str) -> None:
|
|
535
|
-
"""
|
|
588
|
+
"""
|
|
589
|
+
Validate that a variable name is a valid identifier.
|
|
536
590
|
|
|
537
591
|
Args:
|
|
538
|
-
|
|
592
|
+
name: Variable name to validate
|
|
539
593
|
|
|
540
594
|
Raises:
|
|
541
|
-
|
|
595
|
+
ValueError: If name is not a valid identifier
|
|
596
|
+
@athena: 61f92f7ad278
|
|
542
597
|
"""
|
|
543
598
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
|
|
544
599
|
raise ValueError(
|
|
@@ -548,16 +603,18 @@ def _validate_variable_name(name: str) -> None:
|
|
|
548
603
|
|
|
549
604
|
|
|
550
605
|
def _infer_variable_type(value: Any) -> str:
|
|
551
|
-
"""
|
|
606
|
+
"""
|
|
607
|
+
Infer type name from Python value.
|
|
552
608
|
|
|
553
609
|
Args:
|
|
554
|
-
|
|
610
|
+
value: Python value from YAML
|
|
555
611
|
|
|
556
612
|
Returns:
|
|
557
|
-
|
|
613
|
+
Type name string (str, int, float, bool)
|
|
558
614
|
|
|
559
615
|
Raises:
|
|
560
|
-
|
|
616
|
+
ValueError: If value type is not supported
|
|
617
|
+
@athena: 335ae24e1504
|
|
561
618
|
"""
|
|
562
619
|
type_map = {
|
|
563
620
|
str: "str",
|
|
@@ -575,29 +632,33 @@ def _infer_variable_type(value: Any) -> str:
|
|
|
575
632
|
|
|
576
633
|
|
|
577
634
|
def _is_env_variable_reference(value: Any) -> bool:
|
|
578
|
-
"""
|
|
635
|
+
"""
|
|
636
|
+
Check if value is an environment variable reference.
|
|
579
637
|
|
|
580
638
|
Args:
|
|
581
|
-
|
|
639
|
+
value: Raw value from YAML
|
|
582
640
|
|
|
583
641
|
Returns:
|
|
584
|
-
|
|
642
|
+
True if value is { env: VAR_NAME } dict
|
|
643
|
+
@athena: c01927ec19ef
|
|
585
644
|
"""
|
|
586
645
|
return isinstance(value, dict) and "env" in value
|
|
587
646
|
|
|
588
647
|
|
|
589
648
|
def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, str | None]:
|
|
590
|
-
"""
|
|
649
|
+
"""
|
|
650
|
+
Validate and extract environment variable name and optional default from reference.
|
|
591
651
|
|
|
592
652
|
Args:
|
|
593
|
-
|
|
594
|
-
|
|
653
|
+
var_name: Name of the variable being defined
|
|
654
|
+
value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
|
|
595
655
|
|
|
596
656
|
Returns:
|
|
597
|
-
|
|
657
|
+
Tuple of (environment variable name, default value or None)
|
|
598
658
|
|
|
599
659
|
Raises:
|
|
600
|
-
|
|
660
|
+
ValueError: If reference is invalid
|
|
661
|
+
@athena: 9fc8b2333b54
|
|
601
662
|
"""
|
|
602
663
|
# Validate dict structure - allow 'env' and optionally 'default'
|
|
603
664
|
valid_keys = {"env", "default"}
|
|
@@ -651,18 +712,20 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
651
712
|
|
|
652
713
|
|
|
653
714
|
def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None = None) -> str:
|
|
654
|
-
"""
|
|
715
|
+
"""
|
|
716
|
+
Resolve environment variable value.
|
|
655
717
|
|
|
656
718
|
Args:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
719
|
+
var_name: Name of the variable being defined
|
|
720
|
+
env_var_name: Name of environment variable to read
|
|
721
|
+
default: Optional default value to use if environment variable is not set
|
|
660
722
|
|
|
661
723
|
Returns:
|
|
662
|
-
|
|
724
|
+
Environment variable value as string, or default if not set and default provided
|
|
663
725
|
|
|
664
726
|
Raises:
|
|
665
|
-
|
|
727
|
+
ValueError: If environment variable is not set and no default provided
|
|
728
|
+
@athena: c00d3a241a99
|
|
666
729
|
"""
|
|
667
730
|
value = os.environ.get(env_var_name, default)
|
|
668
731
|
|
|
@@ -680,29 +743,33 @@ def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None
|
|
|
680
743
|
|
|
681
744
|
|
|
682
745
|
def _is_file_read_reference(value: Any) -> bool:
|
|
683
|
-
"""
|
|
746
|
+
"""
|
|
747
|
+
Check if value is a file read reference.
|
|
684
748
|
|
|
685
749
|
Args:
|
|
686
|
-
|
|
750
|
+
value: Raw value from YAML
|
|
687
751
|
|
|
688
752
|
Returns:
|
|
689
|
-
|
|
753
|
+
True if value is { read: filepath } dict
|
|
754
|
+
@athena: da129db1b17b
|
|
690
755
|
"""
|
|
691
756
|
return isinstance(value, dict) and "read" in value
|
|
692
757
|
|
|
693
758
|
|
|
694
759
|
def _validate_file_read_reference(var_name: str, value: dict) -> str:
|
|
695
|
-
"""
|
|
760
|
+
"""
|
|
761
|
+
Validate and extract filepath from file read reference.
|
|
696
762
|
|
|
697
763
|
Args:
|
|
698
|
-
|
|
699
|
-
|
|
764
|
+
var_name: Name of the variable being defined
|
|
765
|
+
value: Dict that should be { read: filepath }
|
|
700
766
|
|
|
701
767
|
Returns:
|
|
702
|
-
|
|
768
|
+
Filepath string
|
|
703
769
|
|
|
704
770
|
Raises:
|
|
705
|
-
|
|
771
|
+
ValueError: If reference is invalid
|
|
772
|
+
@athena: 2615951372fc
|
|
706
773
|
"""
|
|
707
774
|
# Validate dict structure (only "read" key allowed)
|
|
708
775
|
if len(value) != 1:
|
|
@@ -728,7 +795,8 @@ def _validate_file_read_reference(var_name: str, value: dict) -> str:
|
|
|
728
795
|
|
|
729
796
|
|
|
730
797
|
def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
731
|
-
"""
|
|
798
|
+
"""
|
|
799
|
+
Resolve file path relative to recipe file location.
|
|
732
800
|
|
|
733
801
|
Handles three path types:
|
|
734
802
|
1. Tilde paths (~): Expand to user home directory
|
|
@@ -736,11 +804,12 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
|
736
804
|
3. Relative paths: Resolve relative to recipe file's directory
|
|
737
805
|
|
|
738
806
|
Args:
|
|
739
|
-
|
|
740
|
-
|
|
807
|
+
filepath: Path string from YAML (may be relative, absolute, or tilde)
|
|
808
|
+
recipe_file_path: Path to the recipe file containing the variable
|
|
741
809
|
|
|
742
810
|
Returns:
|
|
743
|
-
|
|
811
|
+
Resolved absolute Path object
|
|
812
|
+
@athena: e80470e9c7d6
|
|
744
813
|
"""
|
|
745
814
|
# Expand tilde to home directory
|
|
746
815
|
if filepath.startswith("~"):
|
|
@@ -758,18 +827,20 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
|
758
827
|
|
|
759
828
|
|
|
760
829
|
def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) -> str:
|
|
761
|
-
"""
|
|
830
|
+
"""
|
|
831
|
+
Read file contents for variable value.
|
|
762
832
|
|
|
763
833
|
Args:
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
834
|
+
var_name: Name of the variable being defined
|
|
835
|
+
filepath: Original filepath string (for error messages)
|
|
836
|
+
resolved_path: Resolved absolute path to the file
|
|
767
837
|
|
|
768
838
|
Returns:
|
|
769
|
-
|
|
839
|
+
File contents as string (with trailing newline stripped)
|
|
770
840
|
|
|
771
841
|
Raises:
|
|
772
|
-
|
|
842
|
+
ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
|
|
843
|
+
@athena: cab84337f145
|
|
773
844
|
"""
|
|
774
845
|
# Check file exists
|
|
775
846
|
if not resolved_path.exists():
|
|
@@ -811,29 +882,33 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
811
882
|
|
|
812
883
|
|
|
813
884
|
def _is_eval_reference(value: Any) -> bool:
|
|
814
|
-
"""
|
|
885
|
+
"""
|
|
886
|
+
Check if value is an eval command reference.
|
|
815
887
|
|
|
816
888
|
Args:
|
|
817
|
-
|
|
889
|
+
value: Raw value from YAML
|
|
818
890
|
|
|
819
891
|
Returns:
|
|
820
|
-
|
|
892
|
+
True if value is { eval: command } dict
|
|
893
|
+
@athena: 121784f6d4ab
|
|
821
894
|
"""
|
|
822
895
|
return isinstance(value, dict) and "eval" in value
|
|
823
896
|
|
|
824
897
|
|
|
825
898
|
def _validate_eval_reference(var_name: str, value: dict) -> str:
|
|
826
|
-
"""
|
|
899
|
+
"""
|
|
900
|
+
Validate and extract command from eval reference.
|
|
827
901
|
|
|
828
902
|
Args:
|
|
829
|
-
|
|
830
|
-
|
|
903
|
+
var_name: Name of the variable being defined
|
|
904
|
+
value: Dict that should be { eval: command }
|
|
831
905
|
|
|
832
906
|
Returns:
|
|
833
|
-
|
|
907
|
+
Command string
|
|
834
908
|
|
|
835
909
|
Raises:
|
|
836
|
-
|
|
910
|
+
ValueError: If reference is invalid
|
|
911
|
+
@athena: f3cde1011d2d
|
|
837
912
|
"""
|
|
838
913
|
# Validate dict structure (only "eval" key allowed)
|
|
839
914
|
if len(value) != 1:
|
|
@@ -859,10 +934,12 @@ def _validate_eval_reference(var_name: str, value: dict) -> str:
|
|
|
859
934
|
|
|
860
935
|
|
|
861
936
|
def _get_default_shell_and_args() -> tuple[str, list[str]]:
|
|
862
|
-
"""
|
|
937
|
+
"""
|
|
938
|
+
Get default shell and args for current platform.
|
|
863
939
|
|
|
864
940
|
Returns:
|
|
865
|
-
|
|
941
|
+
Tuple of (shell, args) for platform default
|
|
942
|
+
@athena: 68a19449a035
|
|
866
943
|
"""
|
|
867
944
|
is_windows = platform.system() == "Windows"
|
|
868
945
|
if is_windows:
|
|
@@ -877,19 +954,21 @@ def _resolve_eval_variable(
|
|
|
877
954
|
recipe_file_path: Path,
|
|
878
955
|
recipe_data: dict
|
|
879
956
|
) -> str:
|
|
880
|
-
"""
|
|
957
|
+
"""
|
|
958
|
+
Execute command and capture output for variable value.
|
|
881
959
|
|
|
882
960
|
Args:
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
961
|
+
var_name: Name of the variable being defined
|
|
962
|
+
command: Command to execute
|
|
963
|
+
recipe_file_path: Path to recipe file (for working directory)
|
|
964
|
+
recipe_data: Parsed YAML data (for accessing default_env)
|
|
887
965
|
|
|
888
966
|
Returns:
|
|
889
|
-
|
|
967
|
+
Command stdout as string (with trailing newline stripped)
|
|
890
968
|
|
|
891
969
|
Raises:
|
|
892
|
-
|
|
970
|
+
ValueError: If command fails or cannot be executed
|
|
971
|
+
@athena: 647d3a310c77
|
|
893
972
|
"""
|
|
894
973
|
# Determine shell to use
|
|
895
974
|
shell = None
|
|
@@ -968,21 +1047,23 @@ def _resolve_variable_value(
|
|
|
968
1047
|
file_path: Path,
|
|
969
1048
|
recipe_data: dict | None = None
|
|
970
1049
|
) -> str:
|
|
971
|
-
"""
|
|
1050
|
+
"""
|
|
1051
|
+
Resolve a single variable value with circular reference detection.
|
|
972
1052
|
|
|
973
1053
|
Args:
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1054
|
+
name: Variable name being resolved
|
|
1055
|
+
raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
|
|
1056
|
+
resolved: Dictionary of already-resolved variables
|
|
1057
|
+
resolution_stack: Stack of variables currently being resolved (for circular detection)
|
|
1058
|
+
file_path: Path to recipe file (for resolving relative file paths in { read: ... })
|
|
1059
|
+
recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
|
|
980
1060
|
|
|
981
1061
|
Returns:
|
|
982
|
-
|
|
1062
|
+
Resolved string value
|
|
983
1063
|
|
|
984
1064
|
Raises:
|
|
985
|
-
|
|
1065
|
+
ValueError: If circular reference detected or validation fails
|
|
1066
|
+
@athena: da94de106756
|
|
986
1067
|
"""
|
|
987
1068
|
# Check for circular reference
|
|
988
1069
|
if name in resolution_stack:
|
|
@@ -1114,20 +1195,22 @@ def _resolve_variable_value(
|
|
|
1114
1195
|
|
|
1115
1196
|
|
|
1116
1197
|
def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
|
|
1117
|
-
"""
|
|
1198
|
+
"""
|
|
1199
|
+
Parse and resolve the variables section from YAML data.
|
|
1118
1200
|
|
|
1119
1201
|
Variables are resolved in order, allowing variables to reference
|
|
1120
1202
|
previously-defined variables using {{ var.name }} syntax.
|
|
1121
1203
|
|
|
1122
1204
|
Args:
|
|
1123
|
-
|
|
1124
|
-
|
|
1205
|
+
data: Parsed YAML data (root level)
|
|
1206
|
+
file_path: Path to the recipe file (for resolving relative file paths)
|
|
1125
1207
|
|
|
1126
1208
|
Returns:
|
|
1127
|
-
|
|
1209
|
+
Dictionary mapping variable names to resolved string values
|
|
1128
1210
|
|
|
1129
1211
|
Raises:
|
|
1130
|
-
|
|
1212
|
+
ValueError: For validation errors, undefined refs, or circular refs
|
|
1213
|
+
@athena: aa45e860a958
|
|
1131
1214
|
"""
|
|
1132
1215
|
if "variables" not in data:
|
|
1133
1216
|
return {}
|
|
@@ -1152,26 +1235,28 @@ def _expand_variable_dependencies(
|
|
|
1152
1235
|
variable_names: set[str],
|
|
1153
1236
|
raw_variables: dict[str, Any]
|
|
1154
1237
|
) -> set[str]:
|
|
1155
|
-
"""
|
|
1238
|
+
"""
|
|
1239
|
+
Expand variable set to include all transitively referenced variables.
|
|
1156
1240
|
|
|
1157
1241
|
If variable A references variable B, and B references C, then requesting A
|
|
1158
1242
|
should also evaluate B and C.
|
|
1159
1243
|
|
|
1160
1244
|
Args:
|
|
1161
|
-
|
|
1162
|
-
|
|
1245
|
+
variable_names: Initial set of variable names
|
|
1246
|
+
raw_variables: Raw variable definitions from YAML
|
|
1163
1247
|
|
|
1164
1248
|
Returns:
|
|
1165
|
-
|
|
1249
|
+
Expanded set including all transitively referenced variables
|
|
1166
1250
|
|
|
1167
1251
|
Example:
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1252
|
+
>>> raw_vars = {
|
|
1253
|
+
... "a": "{{ var.b }}",
|
|
1254
|
+
... "b": "{{ var.c }}",
|
|
1255
|
+
... "c": "value"
|
|
1256
|
+
... }
|
|
1257
|
+
>>> _expand_variable_dependencies({"a"}, raw_vars)
|
|
1258
|
+
{"a", "b", "c"}
|
|
1259
|
+
@athena: e7ec4a4a6db3
|
|
1175
1260
|
"""
|
|
1176
1261
|
expanded = set(variable_names)
|
|
1177
1262
|
to_process = list(variable_names)
|
|
@@ -1235,7 +1320,8 @@ def _evaluate_variable_subset(
|
|
|
1235
1320
|
file_path: Path,
|
|
1236
1321
|
data: dict
|
|
1237
1322
|
) -> dict[str, str]:
|
|
1238
|
-
"""
|
|
1323
|
+
"""
|
|
1324
|
+
Evaluate only specified variables from raw specs (for lazy evaluation).
|
|
1239
1325
|
|
|
1240
1326
|
This function is similar to _parse_variables_section but only evaluates
|
|
1241
1327
|
a subset of variables. This enables lazy evaluation where only reachable
|
|
@@ -1245,21 +1331,22 @@ def _evaluate_variable_subset(
|
|
|
1245
1331
|
variable B, both will be evaluated even if only A was explicitly requested.
|
|
1246
1332
|
|
|
1247
1333
|
Args:
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1334
|
+
raw_variables: Raw variable definitions from YAML (not yet evaluated)
|
|
1335
|
+
variable_names: Set of variable names to evaluate
|
|
1336
|
+
file_path: Recipe file path (for relative file resolution)
|
|
1337
|
+
data: Full YAML data (for context in _resolve_variable_value)
|
|
1252
1338
|
|
|
1253
1339
|
Returns:
|
|
1254
|
-
|
|
1340
|
+
Dictionary of evaluated variable values (for specified variables and their dependencies)
|
|
1255
1341
|
|
|
1256
1342
|
Raises:
|
|
1257
|
-
|
|
1343
|
+
ValueError: For validation errors, undefined refs, or circular refs
|
|
1258
1344
|
|
|
1259
1345
|
Example:
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1346
|
+
>>> raw_vars = {"a": "{{ var.b }}", "b": "value", "c": "unused"}
|
|
1347
|
+
>>> _evaluate_variable_subset(raw_vars, {"a"}, path, data)
|
|
1348
|
+
{"a": "value", "b": "value"} # "a" and its dependency "b", but not "c"
|
|
1349
|
+
@athena: 1e9d491d7404
|
|
1263
1350
|
"""
|
|
1264
1351
|
if not isinstance(raw_variables, dict):
|
|
1265
1352
|
raise ValueError("'variables' must be a dictionary")
|
|
@@ -1287,17 +1374,19 @@ def _parse_file_with_env(
|
|
|
1287
1374
|
project_root: Path,
|
|
1288
1375
|
import_stack: list[Path] | None = None,
|
|
1289
1376
|
) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]]:
|
|
1290
|
-
"""
|
|
1377
|
+
"""
|
|
1378
|
+
Parse file and extract tasks, environments, and variables.
|
|
1291
1379
|
|
|
1292
1380
|
Args:
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1381
|
+
file_path: Path to YAML file
|
|
1382
|
+
namespace: Optional namespace prefix for tasks
|
|
1383
|
+
project_root: Root directory of the project
|
|
1384
|
+
import_stack: Stack of files being imported (for circular detection)
|
|
1297
1385
|
|
|
1298
1386
|
Returns:
|
|
1299
|
-
|
|
1300
|
-
|
|
1387
|
+
Tuple of (tasks, environments, default_env_name, raw_variables, yaml_data)
|
|
1388
|
+
Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
|
|
1389
|
+
@athena: b2dced506787
|
|
1301
1390
|
"""
|
|
1302
1391
|
# Parse tasks normally
|
|
1303
1392
|
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
@@ -1420,25 +1509,27 @@ def _parse_file_with_env(
|
|
|
1420
1509
|
|
|
1421
1510
|
|
|
1422
1511
|
def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
|
|
1423
|
-
"""
|
|
1512
|
+
"""
|
|
1513
|
+
Collect all tasks reachable from the root task via dependencies.
|
|
1424
1514
|
|
|
1425
1515
|
Uses BFS to traverse the dependency graph and collect all task names
|
|
1426
1516
|
that could potentially be executed when running the root task.
|
|
1427
1517
|
|
|
1428
1518
|
Args:
|
|
1429
|
-
|
|
1430
|
-
|
|
1519
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1520
|
+
root_task: Name of the root task to start traversal from
|
|
1431
1521
|
|
|
1432
1522
|
Returns:
|
|
1433
|
-
|
|
1523
|
+
Set of task names reachable from root_task (includes root_task itself)
|
|
1434
1524
|
|
|
1435
1525
|
Raises:
|
|
1436
|
-
|
|
1526
|
+
ValueError: If root_task doesn't exist
|
|
1437
1527
|
|
|
1438
1528
|
Example:
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1529
|
+
>>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
|
|
1530
|
+
>>> collect_reachable_tasks(tasks, "a")
|
|
1531
|
+
{"a", "b"}
|
|
1532
|
+
@athena: fe29d8558be3
|
|
1442
1533
|
"""
|
|
1443
1534
|
if root_task not in tasks:
|
|
1444
1535
|
raise ValueError(f"Root task '{root_task}' not found in recipe")
|
|
@@ -1482,23 +1573,25 @@ def collect_reachable_variables(
|
|
|
1482
1573
|
environments: dict[str, Environment],
|
|
1483
1574
|
reachable_task_names: set[str]
|
|
1484
1575
|
) -> set[str]:
|
|
1485
|
-
"""
|
|
1576
|
+
"""
|
|
1577
|
+
Extract variable names used by reachable tasks.
|
|
1486
1578
|
|
|
1487
1579
|
Searches for {{ var.* }} placeholders in task and environment definitions to determine
|
|
1488
1580
|
which variables are actually needed for execution.
|
|
1489
1581
|
|
|
1490
1582
|
Args:
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1583
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1584
|
+
environments: Dictionary mapping environment names to Environment objects
|
|
1585
|
+
reachable_task_names: Set of task names that will be executed
|
|
1494
1586
|
|
|
1495
1587
|
Returns:
|
|
1496
|
-
|
|
1588
|
+
Set of variable names referenced by reachable tasks
|
|
1497
1589
|
|
|
1498
1590
|
Example:
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1591
|
+
>>> task = Task("build", cmd="echo {{ var.version }}")
|
|
1592
|
+
>>> collect_reachable_variables({"build": task}, {"build"})
|
|
1593
|
+
{"version"}
|
|
1594
|
+
@athena: fc2a03927486
|
|
1502
1595
|
"""
|
|
1503
1596
|
import re
|
|
1504
1597
|
|
|
@@ -1626,28 +1719,30 @@ def parse_recipe(
|
|
|
1626
1719
|
project_root: Path | None = None,
|
|
1627
1720
|
root_task: str | None = None
|
|
1628
1721
|
) -> Recipe:
|
|
1629
|
-
"""
|
|
1722
|
+
"""
|
|
1723
|
+
Parse a recipe file and handle imports recursively.
|
|
1630
1724
|
|
|
1631
1725
|
This function now implements lazy variable evaluation: if root_task is provided,
|
|
1632
1726
|
only variables reachable from that task will be evaluated. This provides significant
|
|
1633
1727
|
performance and security benefits for recipes with many variables.
|
|
1634
1728
|
|
|
1635
1729
|
Args:
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1730
|
+
recipe_path: Path to the main recipe file
|
|
1731
|
+
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
1732
|
+
When using --tasks option, this should be the current working directory.
|
|
1733
|
+
root_task: Optional root task for lazy variable evaluation. If provided, only variables
|
|
1734
|
+
used by tasks reachable from root_task will be evaluated (optimization).
|
|
1735
|
+
If None, all variables will be evaluated (for --list command compatibility).
|
|
1642
1736
|
|
|
1643
1737
|
Returns:
|
|
1644
|
-
|
|
1738
|
+
Recipe object with all tasks (including recursively imported tasks) and evaluated variables
|
|
1645
1739
|
|
|
1646
1740
|
Raises:
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1741
|
+
FileNotFoundError: If recipe file doesn't exist
|
|
1742
|
+
CircularImportError: If circular imports are detected
|
|
1743
|
+
yaml.YAMLError: If YAML is invalid
|
|
1744
|
+
ValueError: If recipe structure is invalid
|
|
1745
|
+
@athena: 27326e37d5f3
|
|
1651
1746
|
"""
|
|
1652
1747
|
if not recipe_path.exists():
|
|
1653
1748
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
@@ -1690,21 +1785,23 @@ def _parse_file(
|
|
|
1690
1785
|
project_root: Path,
|
|
1691
1786
|
import_stack: list[Path] | None = None,
|
|
1692
1787
|
) -> dict[str, Task]:
|
|
1693
|
-
"""
|
|
1788
|
+
"""
|
|
1789
|
+
Parse a single YAML file and return tasks, recursively processing imports.
|
|
1694
1790
|
|
|
1695
1791
|
Args:
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1792
|
+
file_path: Path to YAML file
|
|
1793
|
+
namespace: Optional namespace prefix for tasks
|
|
1794
|
+
project_root: Root directory of the project
|
|
1795
|
+
import_stack: Stack of files being imported (for circular detection)
|
|
1700
1796
|
|
|
1701
1797
|
Returns:
|
|
1702
|
-
|
|
1798
|
+
Dictionary of task name to Task objects
|
|
1703
1799
|
|
|
1704
1800
|
Raises:
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1801
|
+
CircularImportError: If a circular import is detected
|
|
1802
|
+
FileNotFoundError: If an imported file doesn't exist
|
|
1803
|
+
ValueError: If task structure is invalid
|
|
1804
|
+
@athena: f2a1f2c70240
|
|
1708
1805
|
"""
|
|
1709
1806
|
# Initialize import stack if not provided
|
|
1710
1807
|
if import_stack is None:
|
|
@@ -1893,15 +1990,17 @@ def _parse_file(
|
|
|
1893
1990
|
|
|
1894
1991
|
|
|
1895
1992
|
def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
|
|
1896
|
-
"""
|
|
1993
|
+
"""
|
|
1994
|
+
Check for exported arguments that differ only in case.
|
|
1897
1995
|
|
|
1898
1996
|
On Unix systems, environment variables are case-sensitive, but having
|
|
1899
1997
|
args that differ only in case (e.g., $Server and $server) can be confusing.
|
|
1900
1998
|
This function emits a warning if such collisions are detected.
|
|
1901
1999
|
|
|
1902
2000
|
Args:
|
|
1903
|
-
|
|
1904
|
-
|
|
2001
|
+
args: List of argument specifications
|
|
2002
|
+
task_name: Name of the task (for warning message)
|
|
2003
|
+
@athena: a3f0f3b184a8
|
|
1905
2004
|
"""
|
|
1906
2005
|
import sys
|
|
1907
2006
|
|
|
@@ -1931,41 +2030,43 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
|
|
|
1931
2030
|
|
|
1932
2031
|
|
|
1933
2032
|
def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
1934
|
-
"""
|
|
2033
|
+
"""
|
|
2034
|
+
Parse argument specification from YAML.
|
|
1935
2035
|
|
|
1936
2036
|
Supports both string format and dictionary format:
|
|
1937
2037
|
|
|
1938
2038
|
String format (simple names only):
|
|
1939
|
-
|
|
1940
|
-
|
|
2039
|
+
- Simple name: "argname"
|
|
2040
|
+
- Exported (becomes env var): "$argname"
|
|
1941
2041
|
|
|
1942
2042
|
Dictionary format:
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2043
|
+
- argname: { default: "value" }
|
|
2044
|
+
- argname: { type: int, default: 42 }
|
|
2045
|
+
- argname: { type: int, min: 1, max: 100 }
|
|
2046
|
+
- argname: { type: str, choices: ["dev", "staging", "prod"] }
|
|
2047
|
+
- $argname: { default: "value" } # Exported (type not allowed)
|
|
1948
2048
|
|
|
1949
2049
|
Args:
|
|
1950
|
-
|
|
2050
|
+
arg_spec: Argument specification (string or dict with single key)
|
|
1951
2051
|
|
|
1952
2052
|
Returns:
|
|
1953
|
-
|
|
2053
|
+
ArgSpec object containing parsed argument information
|
|
1954
2054
|
|
|
1955
2055
|
Examples:
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2056
|
+
>>> parse_arg_spec("environment")
|
|
2057
|
+
ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2058
|
+
>>> parse_arg_spec({"key2": {"default": "foo"}})
|
|
2059
|
+
ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2060
|
+
>>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
|
|
2061
|
+
ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2062
|
+
>>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
|
|
2063
|
+
ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
|
|
2064
|
+
>>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
|
|
2065
|
+
ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
|
|
1966
2066
|
|
|
1967
2067
|
Raises:
|
|
1968
|
-
|
|
2068
|
+
ValueError: If argument specification is invalid
|
|
2069
|
+
@athena: 2a4c7e804622
|
|
1969
2070
|
"""
|
|
1970
2071
|
# Handle dictionary format: { argname: { type: ..., default: ... } }
|
|
1971
2072
|
if isinstance(arg_spec, dict):
|
|
@@ -2028,18 +2129,20 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
|
2028
2129
|
|
|
2029
2130
|
|
|
2030
2131
|
def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
2031
|
-
"""
|
|
2132
|
+
"""
|
|
2133
|
+
Parse argument specification from dictionary format.
|
|
2032
2134
|
|
|
2033
2135
|
Args:
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2136
|
+
arg_name: Name of the argument
|
|
2137
|
+
config: Dictionary with optional keys: type, default, min, max, choices
|
|
2138
|
+
is_exported: Whether argument should be exported to environment
|
|
2037
2139
|
|
|
2038
2140
|
Returns:
|
|
2039
|
-
|
|
2141
|
+
ArgSpec object containing the parsed argument specification
|
|
2040
2142
|
|
|
2041
2143
|
Raises:
|
|
2042
|
-
|
|
2144
|
+
ValueError: If dictionary format is invalid
|
|
2145
|
+
@athena: 5b6b93a3612a
|
|
2043
2146
|
"""
|
|
2044
2147
|
# Validate dictionary keys
|
|
2045
2148
|
valid_keys = {"type", "default", "min", "max", "choices"}
|
|
@@ -2275,7 +2378,8 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2275
2378
|
|
|
2276
2379
|
|
|
2277
2380
|
def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> DependencyInvocation:
|
|
2278
|
-
"""
|
|
2381
|
+
"""
|
|
2382
|
+
Parse a dependency specification into a DependencyInvocation.
|
|
2279
2383
|
|
|
2280
2384
|
Supports three forms:
|
|
2281
2385
|
1. Simple string: "task_name" -> DependencyInvocation(task_name, None)
|
|
@@ -2283,14 +2387,15 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
|
|
|
2283
2387
|
3. Named args: {"task_name": {arg1: val1}} -> DependencyInvocation(task_name, {arg1: val1})
|
|
2284
2388
|
|
|
2285
2389
|
Args:
|
|
2286
|
-
|
|
2287
|
-
|
|
2390
|
+
dep_spec: Dependency specification (string or dict)
|
|
2391
|
+
recipe: Recipe containing task definitions (for arg normalization)
|
|
2288
2392
|
|
|
2289
2393
|
Returns:
|
|
2290
|
-
|
|
2394
|
+
DependencyInvocation object with normalized args
|
|
2291
2395
|
|
|
2292
2396
|
Raises:
|
|
2293
|
-
|
|
2397
|
+
ValueError: If dependency specification is invalid
|
|
2398
|
+
@athena: d30ff06259c2
|
|
2294
2399
|
"""
|
|
2295
2400
|
# Simple string case
|
|
2296
2401
|
if isinstance(dep_spec, str):
|
|
@@ -2339,17 +2444,19 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
|
|
|
2339
2444
|
|
|
2340
2445
|
|
|
2341
2446
|
def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
|
|
2342
|
-
"""
|
|
2447
|
+
"""
|
|
2448
|
+
Get and validate that a task exists in the recipe.
|
|
2343
2449
|
|
|
2344
2450
|
Args:
|
|
2345
|
-
|
|
2346
|
-
|
|
2451
|
+
task_name: Name of the task to retrieve
|
|
2452
|
+
recipe: Recipe containing task definitions
|
|
2347
2453
|
|
|
2348
2454
|
Returns:
|
|
2349
|
-
|
|
2455
|
+
The validated Task object
|
|
2350
2456
|
|
|
2351
2457
|
Raises:
|
|
2352
|
-
|
|
2458
|
+
ValueError: If task is not found
|
|
2459
|
+
@athena: 674f077e3977
|
|
2353
2460
|
"""
|
|
2354
2461
|
task = recipe.get_task(task_name)
|
|
2355
2462
|
if task is None:
|
|
@@ -2360,18 +2467,20 @@ def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
|
|
|
2360
2467
|
def _parse_positional_dependency_args(
|
|
2361
2468
|
task_name: str, args_list: list[Any], recipe: Recipe
|
|
2362
2469
|
) -> DependencyInvocation:
|
|
2363
|
-
"""
|
|
2470
|
+
"""
|
|
2471
|
+
Parse positional dependency arguments.
|
|
2364
2472
|
|
|
2365
2473
|
Args:
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2474
|
+
task_name: Name of the dependency task
|
|
2475
|
+
args_list: List of positional argument values
|
|
2476
|
+
recipe: Recipe containing task definitions
|
|
2369
2477
|
|
|
2370
2478
|
Returns:
|
|
2371
|
-
|
|
2479
|
+
DependencyInvocation with normalized named args
|
|
2372
2480
|
|
|
2373
2481
|
Raises:
|
|
2374
|
-
|
|
2482
|
+
ValueError: If validation fails
|
|
2483
|
+
@athena: 4d1c7957e2dd
|
|
2375
2484
|
"""
|
|
2376
2485
|
# Get the task to validate against
|
|
2377
2486
|
task = _get_validated_task(task_name, recipe)
|
|
@@ -2420,18 +2529,20 @@ def _parse_positional_dependency_args(
|
|
|
2420
2529
|
def _parse_named_dependency_args(
|
|
2421
2530
|
task_name: str, args_dict: dict[str, Any], recipe: Recipe
|
|
2422
2531
|
) -> DependencyInvocation:
|
|
2423
|
-
"""
|
|
2532
|
+
"""
|
|
2533
|
+
Parse named dependency arguments.
|
|
2424
2534
|
|
|
2425
2535
|
Args:
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2536
|
+
task_name: Name of the dependency task
|
|
2537
|
+
args_dict: Dictionary of argument names to values
|
|
2538
|
+
recipe: Recipe containing task definitions
|
|
2429
2539
|
|
|
2430
2540
|
Returns:
|
|
2431
|
-
|
|
2541
|
+
DependencyInvocation with normalized args (defaults filled)
|
|
2432
2542
|
|
|
2433
2543
|
Raises:
|
|
2434
|
-
|
|
2544
|
+
ValueError: If validation fails
|
|
2545
|
+
@athena: c522211de525
|
|
2435
2546
|
"""
|
|
2436
2547
|
# Get the task to validate against
|
|
2437
2548
|
task = _get_validated_task(task_name, recipe)
|