tasktree 0.0.20__py3-none-any.whl → 0.0.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tasktree/__init__.py +4 -1
- tasktree/cli.py +198 -60
- tasktree/docker.py +105 -64
- tasktree/executor.py +427 -310
- tasktree/graph.py +138 -82
- tasktree/hasher.py +81 -25
- tasktree/parser.py +554 -344
- tasktree/state.py +50 -22
- tasktree/substitution.py +188 -117
- tasktree/types.py +80 -25
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/METADATA +147 -21
- tasktree-0.0.22.dist-info/RECORD +14 -0
- tasktree-0.0.20.dist-info/RECORD +0 -14
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/WHEEL +0 -0
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/entry_points.txt +0 -0
tasktree/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,22 +19,30 @@ 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
|
+
"""
|
|
26
|
+
|
|
20
27
|
pass
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
@dataclass
|
|
24
31
|
class Environment:
|
|
25
|
-
"""
|
|
32
|
+
"""
|
|
33
|
+
Represents an execution environment configuration.
|
|
26
34
|
|
|
27
35
|
Can be either a shell environment or a Docker environment:
|
|
28
36
|
- Shell environment: has 'shell' field, executes directly on host
|
|
29
37
|
- Docker environment: has 'dockerfile' field, executes in container
|
|
38
|
+
@athena: cfe3f8754968
|
|
30
39
|
"""
|
|
31
40
|
|
|
32
41
|
name: str
|
|
33
42
|
shell: str = "" # Path to shell (required for shell envs, optional for Docker)
|
|
34
|
-
args: list[str] | dict[str, str] = field(
|
|
43
|
+
args: list[str] | dict[str, str] = field(
|
|
44
|
+
default_factory=list
|
|
45
|
+
) # Shell args (list) or Docker build args (dict)
|
|
35
46
|
preamble: str = ""
|
|
36
47
|
# Docker-specific fields (presence of dockerfile indicates Docker environment)
|
|
37
48
|
dockerfile: str = "" # Path to Dockerfile
|
|
@@ -40,41 +51,76 @@ class Environment:
|
|
|
40
51
|
ports: list[str] = field(default_factory=list) # Port mappings
|
|
41
52
|
env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
|
|
42
53
|
working_dir: str = "" # Working directory (container or host)
|
|
43
|
-
extra_args: List[str] = field(
|
|
54
|
+
extra_args: List[str] = field(
|
|
55
|
+
default_factory=list
|
|
56
|
+
) # Any extra arguments to pass to docker
|
|
44
57
|
run_as_root: bool = False # If True, skip user mapping (run as root in container)
|
|
45
58
|
|
|
46
59
|
def __post_init__(self):
|
|
47
|
-
"""
|
|
60
|
+
"""
|
|
61
|
+
Ensure args is in the correct format.
|
|
62
|
+
@athena: a4292f3f4150
|
|
63
|
+
"""
|
|
48
64
|
if isinstance(self.args, str):
|
|
49
65
|
self.args = [self.args]
|
|
50
66
|
|
|
51
67
|
|
|
52
68
|
@dataclass
|
|
53
69
|
class Task:
|
|
54
|
-
"""
|
|
70
|
+
"""
|
|
71
|
+
Represents a task definition.
|
|
72
|
+
@athena: f516b5ae61c5
|
|
73
|
+
"""
|
|
55
74
|
|
|
56
75
|
name: str
|
|
57
76
|
cmd: str
|
|
58
77
|
desc: str = ""
|
|
59
|
-
deps: list[str | dict[str, Any]] = field(
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
deps: list[str | dict[str, Any]] = field(
|
|
79
|
+
default_factory=list
|
|
80
|
+
) # Can be strings or dicts with args
|
|
81
|
+
inputs: list[str | dict[str, str]] = field(
|
|
82
|
+
default_factory=list
|
|
83
|
+
) # Can be strings or dicts with named inputs
|
|
84
|
+
outputs: list[str | dict[str, str]] = field(
|
|
85
|
+
default_factory=list
|
|
86
|
+
) # Can be strings or dicts with named outputs
|
|
62
87
|
working_dir: str = ""
|
|
63
|
-
args: list[str | dict[str, Any]] = field(
|
|
88
|
+
args: list[str | dict[str, Any]] = field(
|
|
89
|
+
default_factory=list
|
|
90
|
+
) # Can be strings or dicts (each dict has single key: arg name)
|
|
64
91
|
source_file: str = "" # Track which file defined this task
|
|
65
92
|
env: str = "" # Environment name to use for execution
|
|
66
93
|
private: bool = False # If True, task is hidden from --list output
|
|
67
94
|
|
|
68
95
|
# Internal fields for efficient output lookup (built in __post_init__)
|
|
69
|
-
_output_map: dict[str, str] = field(
|
|
70
|
-
|
|
96
|
+
_output_map: dict[str, str] = field(
|
|
97
|
+
init=False, default_factory=dict, repr=False
|
|
98
|
+
) # name → path mapping
|
|
99
|
+
_anonymous_outputs: list[str] = field(
|
|
100
|
+
init=False, default_factory=list, repr=False
|
|
101
|
+
) # unnamed outputs
|
|
71
102
|
|
|
72
103
|
# Internal fields for efficient input lookup (built in __post_init__)
|
|
73
|
-
_input_map: dict[str, str] = field(
|
|
74
|
-
|
|
104
|
+
_input_map: dict[str, str] = field(
|
|
105
|
+
init=False, default_factory=dict, repr=False
|
|
106
|
+
) # name → path mapping
|
|
107
|
+
_anonymous_inputs: list[str] = field(
|
|
108
|
+
init=False, default_factory=list, repr=False
|
|
109
|
+
) # unnamed inputs
|
|
110
|
+
|
|
111
|
+
# Internal fields for positional input/output access (built in __post_init__)
|
|
112
|
+
_indexed_inputs: list[str] = field(
|
|
113
|
+
init=False, default_factory=list, repr=False
|
|
114
|
+
) # all inputs in YAML order
|
|
115
|
+
_indexed_outputs: list[str] = field(
|
|
116
|
+
init=False, default_factory=list, repr=False
|
|
117
|
+
) # all outputs in YAML order
|
|
75
118
|
|
|
76
119
|
def __post_init__(self):
|
|
77
|
-
"""
|
|
120
|
+
"""
|
|
121
|
+
Ensure lists are always lists and build input/output maps and indexed lists.
|
|
122
|
+
@athena: a48b1eba81cd
|
|
123
|
+
"""
|
|
78
124
|
if isinstance(self.deps, str):
|
|
79
125
|
self.deps = [self.deps]
|
|
80
126
|
if isinstance(self.inputs, str):
|
|
@@ -100,6 +146,7 @@ class Task:
|
|
|
100
146
|
# Build output maps for efficient lookup
|
|
101
147
|
self._output_map = {}
|
|
102
148
|
self._anonymous_outputs = []
|
|
149
|
+
self._indexed_outputs = []
|
|
103
150
|
|
|
104
151
|
for idx, output in enumerate(self.outputs):
|
|
105
152
|
if isinstance(output, dict):
|
|
@@ -116,7 +163,7 @@ class Task:
|
|
|
116
163
|
f"Task '{self.name}': Named output '{name}' must have a string path, got {type(path).__name__}: {path}"
|
|
117
164
|
)
|
|
118
165
|
|
|
119
|
-
if not re.match(r
|
|
166
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
120
167
|
raise ValueError(
|
|
121
168
|
f"Task '{self.name}': Named output '{name}' must be a valid identifier "
|
|
122
169
|
f"(letters, numbers, underscores, cannot start with number)"
|
|
@@ -128,9 +175,11 @@ class Task:
|
|
|
128
175
|
)
|
|
129
176
|
|
|
130
177
|
self._output_map[name] = path
|
|
178
|
+
self._indexed_outputs.append(path)
|
|
131
179
|
elif isinstance(output, str):
|
|
132
180
|
# Anonymous output: just store
|
|
133
181
|
self._anonymous_outputs.append(output)
|
|
182
|
+
self._indexed_outputs.append(output)
|
|
134
183
|
else:
|
|
135
184
|
raise ValueError(
|
|
136
185
|
f"Task '{self.name}': Output at index {idx} must be a string or dict, got {type(output).__name__}: {output}"
|
|
@@ -139,6 +188,7 @@ class Task:
|
|
|
139
188
|
# Build input maps for efficient lookup
|
|
140
189
|
self._input_map = {}
|
|
141
190
|
self._anonymous_inputs = []
|
|
191
|
+
self._indexed_inputs = []
|
|
142
192
|
|
|
143
193
|
for idx, input_item in enumerate(self.inputs):
|
|
144
194
|
if isinstance(input_item, dict):
|
|
@@ -155,7 +205,7 @@ class Task:
|
|
|
155
205
|
f"Task '{self.name}': Named input '{name}' must have a string path, got {type(path).__name__}: {path}"
|
|
156
206
|
)
|
|
157
207
|
|
|
158
|
-
if not re.match(r
|
|
208
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
159
209
|
raise ValueError(
|
|
160
210
|
f"Task '{self.name}': Named input '{name}' must be a valid identifier "
|
|
161
211
|
f"(letters, numbers, underscores, cannot start with number)"
|
|
@@ -167,9 +217,11 @@ class Task:
|
|
|
167
217
|
)
|
|
168
218
|
|
|
169
219
|
self._input_map[name] = path
|
|
220
|
+
self._indexed_inputs.append(path)
|
|
170
221
|
elif isinstance(input_item, str):
|
|
171
222
|
# Anonymous input: just store
|
|
172
223
|
self._anonymous_inputs.append(input_item)
|
|
224
|
+
self._indexed_inputs.append(input_item)
|
|
173
225
|
else:
|
|
174
226
|
raise ValueError(
|
|
175
227
|
f"Task '{self.name}': Input at index {idx} must be a string or dict, got {type(input_item).__name__}: {input_item}"
|
|
@@ -178,23 +230,29 @@ class Task:
|
|
|
178
230
|
|
|
179
231
|
@dataclass
|
|
180
232
|
class DependencySpec:
|
|
181
|
-
"""
|
|
233
|
+
"""
|
|
234
|
+
Parsed dependency specification with potential template placeholders.
|
|
182
235
|
|
|
183
236
|
This represents a dependency as defined in the recipe file, before template
|
|
184
237
|
substitution. Argument values may contain {{ arg.* }} templates that will be
|
|
185
238
|
substituted with parent task's argument values during graph construction.
|
|
186
239
|
|
|
187
240
|
Attributes:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
241
|
+
task_name: Name of the dependency task
|
|
242
|
+
arg_templates: Dictionary mapping argument names to string templates
|
|
243
|
+
(None if no args specified). All values are strings, even
|
|
244
|
+
for numeric types, to preserve template placeholders.
|
|
245
|
+
@athena: 7b2f8a15d312
|
|
192
246
|
"""
|
|
247
|
+
|
|
193
248
|
task_name: str
|
|
194
249
|
arg_templates: dict[str, str] | None = None
|
|
195
250
|
|
|
196
251
|
def __str__(self) -> str:
|
|
197
|
-
"""
|
|
252
|
+
"""
|
|
253
|
+
String representation for display.
|
|
254
|
+
@athena: e5669be6329b
|
|
255
|
+
"""
|
|
198
256
|
if not self.arg_templates:
|
|
199
257
|
return self.task_name
|
|
200
258
|
args_str = ", ".join(f"{k}={v}" for k, v in self.arg_templates.items())
|
|
@@ -203,17 +261,23 @@ class DependencySpec:
|
|
|
203
261
|
|
|
204
262
|
@dataclass
|
|
205
263
|
class DependencyInvocation:
|
|
206
|
-
"""
|
|
264
|
+
"""
|
|
265
|
+
Represents a task dependency invocation with optional arguments.
|
|
207
266
|
|
|
208
267
|
Attributes:
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
task_name: Name of the dependency task
|
|
269
|
+
args: Dictionary of argument names to values (None if no args specified)
|
|
270
|
+
@athena: 0c023366160b
|
|
211
271
|
"""
|
|
272
|
+
|
|
212
273
|
task_name: str
|
|
213
274
|
args: dict[str, Any] | None = None
|
|
214
275
|
|
|
215
276
|
def __str__(self) -> str:
|
|
216
|
-
"""
|
|
277
|
+
"""
|
|
278
|
+
String representation for display.
|
|
279
|
+
@athena: 22fc0502192b
|
|
280
|
+
"""
|
|
217
281
|
if not self.args:
|
|
218
282
|
return self.task_name
|
|
219
283
|
args_str = ", ".join(f"{k}={v}" for k, v in self.args.items())
|
|
@@ -222,17 +286,20 @@ class DependencyInvocation:
|
|
|
222
286
|
|
|
223
287
|
@dataclass
|
|
224
288
|
class ArgSpec:
|
|
225
|
-
"""
|
|
289
|
+
"""
|
|
290
|
+
Represents a parsed argument specification.
|
|
226
291
|
|
|
227
292
|
Attributes:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
293
|
+
name: Argument name
|
|
294
|
+
arg_type: Type of the argument (str, int, float, bool, path)
|
|
295
|
+
default: Default value as a string (None if no default)
|
|
296
|
+
is_exported: Whether the argument is exported as an environment variable
|
|
297
|
+
min_val: Minimum value for numeric arguments (None if not specified)
|
|
298
|
+
max_val: Maximum value for numeric arguments (None if not specified)
|
|
299
|
+
choices: List of valid choices for the argument (None if not specified)
|
|
300
|
+
@athena: fcaf20fb1ca2
|
|
235
301
|
"""
|
|
302
|
+
|
|
236
303
|
name: str
|
|
237
304
|
arg_type: str
|
|
238
305
|
default: str | None = None
|
|
@@ -244,7 +311,10 @@ class ArgSpec:
|
|
|
244
311
|
|
|
245
312
|
@dataclass
|
|
246
313
|
class Recipe:
|
|
247
|
-
"""
|
|
314
|
+
"""
|
|
315
|
+
Represents a parsed recipe file with all tasks.
|
|
316
|
+
@athena: 47f568c77013
|
|
317
|
+
"""
|
|
248
318
|
|
|
249
319
|
tasks: dict[str, Task]
|
|
250
320
|
project_root: Path
|
|
@@ -252,40 +322,56 @@ class Recipe:
|
|
|
252
322
|
environments: dict[str, Environment] = field(default_factory=dict)
|
|
253
323
|
default_env: str = "" # Name of default environment
|
|
254
324
|
global_env_override: str = "" # Global environment override (set via CLI --env)
|
|
255
|
-
variables: dict[str, str] = field(
|
|
256
|
-
|
|
257
|
-
|
|
325
|
+
variables: dict[str, str] = field(
|
|
326
|
+
default_factory=dict
|
|
327
|
+
) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
|
|
328
|
+
raw_variables: dict[str, Any] = field(
|
|
329
|
+
default_factory=dict
|
|
330
|
+
) # Raw variable specs from YAML (not yet evaluated)
|
|
331
|
+
evaluated_variables: dict[str, str] = field(
|
|
332
|
+
default_factory=dict
|
|
333
|
+
) # Evaluated variable values (cached after evaluation)
|
|
258
334
|
_variables_evaluated: bool = False # Track if variables have been evaluated
|
|
259
|
-
_original_yaml_data: dict[str, Any] = field(
|
|
335
|
+
_original_yaml_data: dict[str, Any] = field(
|
|
336
|
+
default_factory=dict
|
|
337
|
+
) # Store original YAML data for lazy evaluation context
|
|
260
338
|
|
|
261
339
|
def get_task(self, name: str) -> Task | None:
|
|
262
|
-
"""
|
|
340
|
+
"""
|
|
341
|
+
Get task by name.
|
|
263
342
|
|
|
264
343
|
Args:
|
|
265
|
-
|
|
344
|
+
name: Task name (may be namespaced like 'build.compile')
|
|
266
345
|
|
|
267
346
|
Returns:
|
|
268
|
-
|
|
347
|
+
Task if found, None otherwise
|
|
348
|
+
@athena: 3f8137d71757
|
|
269
349
|
"""
|
|
270
350
|
return self.tasks.get(name)
|
|
271
351
|
|
|
272
352
|
def task_names(self) -> list[str]:
|
|
273
|
-
"""
|
|
353
|
+
"""
|
|
354
|
+
Get all task names.
|
|
355
|
+
@athena: 1df54563a7b6
|
|
356
|
+
"""
|
|
274
357
|
return list(self.tasks.keys())
|
|
275
358
|
|
|
276
359
|
def get_environment(self, name: str) -> Environment | None:
|
|
277
|
-
"""
|
|
360
|
+
"""
|
|
361
|
+
Get environment by name.
|
|
278
362
|
|
|
279
363
|
Args:
|
|
280
|
-
|
|
364
|
+
name: Environment name
|
|
281
365
|
|
|
282
366
|
Returns:
|
|
283
|
-
|
|
367
|
+
Environment if found, None otherwise
|
|
368
|
+
@athena: 098227ca38a2
|
|
284
369
|
"""
|
|
285
370
|
return self.environments.get(name)
|
|
286
371
|
|
|
287
372
|
def evaluate_variables(self, root_task: str | None = None) -> None:
|
|
288
|
-
"""
|
|
373
|
+
"""
|
|
374
|
+
Evaluate variables lazily based on task reachability.
|
|
289
375
|
|
|
290
376
|
This method implements lazy variable evaluation, which only evaluates
|
|
291
377
|
variables that are actually reachable from the target task. This provides:
|
|
@@ -299,15 +385,16 @@ class Recipe:
|
|
|
299
385
|
This method is idempotent - calling it multiple times is safe (uses caching).
|
|
300
386
|
|
|
301
387
|
Args:
|
|
302
|
-
|
|
388
|
+
root_task: Optional task name to determine reachability (None = evaluate all)
|
|
303
389
|
|
|
304
390
|
Raises:
|
|
305
|
-
|
|
391
|
+
ValueError: If variable evaluation or substitution fails
|
|
306
392
|
|
|
307
393
|
Example:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
394
|
+
>>> recipe = parse_recipe(path) # Variables not yet evaluated
|
|
395
|
+
>>> recipe.evaluate_variables("build") # Evaluate only reachable variables
|
|
396
|
+
>>> # Now recipe.evaluated_variables contains only vars used by "build" task
|
|
397
|
+
@athena: d8de7b5f42b6
|
|
311
398
|
"""
|
|
312
399
|
if self._variables_evaluated:
|
|
313
400
|
return # Already evaluated, skip (idempotent)
|
|
@@ -319,7 +406,9 @@ class Recipe:
|
|
|
319
406
|
# (CLI will provide its own "Task not found" error)
|
|
320
407
|
try:
|
|
321
408
|
reachable_tasks = collect_reachable_tasks(self.tasks, root_task)
|
|
322
|
-
variables_to_eval = collect_reachable_variables(
|
|
409
|
+
variables_to_eval = collect_reachable_variables(
|
|
410
|
+
self.tasks, self.environments, reachable_tasks
|
|
411
|
+
)
|
|
323
412
|
except ValueError:
|
|
324
413
|
# Root task not found - fall back to eager evaluation
|
|
325
414
|
# This allows the recipe to be parsed even with invalid task names
|
|
@@ -336,7 +425,7 @@ class Recipe:
|
|
|
336
425
|
self.raw_variables,
|
|
337
426
|
variables_to_eval,
|
|
338
427
|
self.recipe_path,
|
|
339
|
-
self._original_yaml_data
|
|
428
|
+
self._original_yaml_data,
|
|
340
429
|
)
|
|
341
430
|
|
|
342
431
|
# Also update the deprecated 'variables' field for backward compatibility
|
|
@@ -351,18 +440,24 @@ class Recipe:
|
|
|
351
440
|
|
|
352
441
|
task.cmd = substitute_variables(task.cmd, self.evaluated_variables)
|
|
353
442
|
task.desc = substitute_variables(task.desc, self.evaluated_variables)
|
|
354
|
-
task.working_dir = substitute_variables(
|
|
443
|
+
task.working_dir = substitute_variables(
|
|
444
|
+
task.working_dir, self.evaluated_variables
|
|
445
|
+
)
|
|
355
446
|
|
|
356
447
|
# Substitute variables in inputs (handle both string and dict inputs)
|
|
357
448
|
resolved_inputs = []
|
|
358
449
|
for inp in task.inputs:
|
|
359
450
|
if isinstance(inp, str):
|
|
360
|
-
resolved_inputs.append(
|
|
451
|
+
resolved_inputs.append(
|
|
452
|
+
substitute_variables(inp, self.evaluated_variables)
|
|
453
|
+
)
|
|
361
454
|
elif isinstance(inp, dict):
|
|
362
455
|
# Named input: substitute the path value
|
|
363
456
|
resolved_dict = {}
|
|
364
457
|
for name, path in inp.items():
|
|
365
|
-
resolved_dict[name] = substitute_variables(
|
|
458
|
+
resolved_dict[name] = substitute_variables(
|
|
459
|
+
path, self.evaluated_variables
|
|
460
|
+
)
|
|
366
461
|
resolved_inputs.append(resolved_dict)
|
|
367
462
|
else:
|
|
368
463
|
resolved_inputs.append(inp)
|
|
@@ -372,12 +467,16 @@ class Recipe:
|
|
|
372
467
|
resolved_outputs = []
|
|
373
468
|
for out in task.outputs:
|
|
374
469
|
if isinstance(out, str):
|
|
375
|
-
resolved_outputs.append(
|
|
470
|
+
resolved_outputs.append(
|
|
471
|
+
substitute_variables(out, self.evaluated_variables)
|
|
472
|
+
)
|
|
376
473
|
elif isinstance(out, dict):
|
|
377
474
|
# Named output: substitute the path value
|
|
378
475
|
resolved_dict = {}
|
|
379
476
|
for name, path in out.items():
|
|
380
|
-
resolved_dict[name] = substitute_variables(
|
|
477
|
+
resolved_dict[name] = substitute_variables(
|
|
478
|
+
path, self.evaluated_variables
|
|
479
|
+
)
|
|
381
480
|
resolved_outputs.append(resolved_dict)
|
|
382
481
|
else:
|
|
383
482
|
resolved_outputs.append(out)
|
|
@@ -390,7 +489,9 @@ class Recipe:
|
|
|
390
489
|
resolved_args = []
|
|
391
490
|
for arg in task.args:
|
|
392
491
|
if isinstance(arg, str):
|
|
393
|
-
resolved_args.append(
|
|
492
|
+
resolved_args.append(
|
|
493
|
+
substitute_variables(arg, self.evaluated_variables)
|
|
494
|
+
)
|
|
394
495
|
elif isinstance(arg, dict):
|
|
395
496
|
# Dict arg: substitute in nested values (like default values)
|
|
396
497
|
resolved_dict = {}
|
|
@@ -400,11 +501,19 @@ class Recipe:
|
|
|
400
501
|
resolved_spec = {}
|
|
401
502
|
for key, value in arg_spec.items():
|
|
402
503
|
if isinstance(value, str):
|
|
403
|
-
resolved_spec[key] = substitute_variables(
|
|
504
|
+
resolved_spec[key] = substitute_variables(
|
|
505
|
+
value, self.evaluated_variables
|
|
506
|
+
)
|
|
404
507
|
elif isinstance(value, list):
|
|
405
508
|
# Handle lists like 'choices'
|
|
406
509
|
resolved_spec[key] = [
|
|
407
|
-
|
|
510
|
+
(
|
|
511
|
+
substitute_variables(
|
|
512
|
+
v, self.evaluated_variables
|
|
513
|
+
)
|
|
514
|
+
if isinstance(v, str)
|
|
515
|
+
else v
|
|
516
|
+
)
|
|
408
517
|
for v in value
|
|
409
518
|
]
|
|
410
519
|
else:
|
|
@@ -412,7 +521,11 @@ class Recipe:
|
|
|
412
521
|
resolved_dict[arg_name] = resolved_spec
|
|
413
522
|
else:
|
|
414
523
|
# Simple value
|
|
415
|
-
resolved_dict[arg_name] =
|
|
524
|
+
resolved_dict[arg_name] = (
|
|
525
|
+
substitute_variables(arg_spec, self.evaluated_variables)
|
|
526
|
+
if isinstance(arg_spec, str)
|
|
527
|
+
else arg_spec
|
|
528
|
+
)
|
|
416
529
|
resolved_args.append(resolved_dict)
|
|
417
530
|
else:
|
|
418
531
|
resolved_args.append(arg)
|
|
@@ -421,15 +534,23 @@ class Recipe:
|
|
|
421
534
|
# Substitute evaluated variables into all environments
|
|
422
535
|
for env in self.environments.values():
|
|
423
536
|
if env.preamble:
|
|
424
|
-
env.preamble = substitute_variables(
|
|
537
|
+
env.preamble = substitute_variables(
|
|
538
|
+
env.preamble, self.evaluated_variables
|
|
539
|
+
)
|
|
425
540
|
|
|
426
541
|
# Substitute in volumes
|
|
427
542
|
if env.volumes:
|
|
428
|
-
env.volumes = [
|
|
543
|
+
env.volumes = [
|
|
544
|
+
substitute_variables(vol, self.evaluated_variables)
|
|
545
|
+
for vol in env.volumes
|
|
546
|
+
]
|
|
429
547
|
|
|
430
548
|
# Substitute in ports
|
|
431
549
|
if env.ports:
|
|
432
|
-
env.ports = [
|
|
550
|
+
env.ports = [
|
|
551
|
+
substitute_variables(port, self.evaluated_variables)
|
|
552
|
+
for port in env.ports
|
|
553
|
+
]
|
|
433
554
|
|
|
434
555
|
# Substitute in env_vars values
|
|
435
556
|
if env.env_vars:
|
|
@@ -440,7 +561,9 @@ class Recipe:
|
|
|
440
561
|
|
|
441
562
|
# Substitute in working_dir
|
|
442
563
|
if env.working_dir:
|
|
443
|
-
env.working_dir = substitute_variables(
|
|
564
|
+
env.working_dir = substitute_variables(
|
|
565
|
+
env.working_dir, self.evaluated_variables
|
|
566
|
+
)
|
|
444
567
|
|
|
445
568
|
# Substitute in build args (dict for Docker environments)
|
|
446
569
|
if env.args and isinstance(env.args, dict):
|
|
@@ -454,7 +577,8 @@ class Recipe:
|
|
|
454
577
|
|
|
455
578
|
|
|
456
579
|
def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
457
|
-
"""
|
|
580
|
+
"""
|
|
581
|
+
Find recipe file in current or parent directories.
|
|
458
582
|
|
|
459
583
|
Looks for recipe files matching these patterns (in order of preference):
|
|
460
584
|
- tasktree.yaml
|
|
@@ -466,13 +590,14 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
466
590
|
with instructions to use --tasks option.
|
|
467
591
|
|
|
468
592
|
Args:
|
|
469
|
-
|
|
593
|
+
start_dir: Directory to start searching from (defaults to cwd)
|
|
470
594
|
|
|
471
595
|
Returns:
|
|
472
|
-
|
|
596
|
+
Path to recipe file if found, None otherwise
|
|
473
597
|
|
|
474
598
|
Raises:
|
|
475
|
-
|
|
599
|
+
ValueError: If multiple recipe files found in the same directory
|
|
600
|
+
@athena: 38ccb0c1bb86
|
|
476
601
|
"""
|
|
477
602
|
if start_dir is None:
|
|
478
603
|
start_dir = Path.cwd()
|
|
@@ -532,15 +657,17 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
532
657
|
|
|
533
658
|
|
|
534
659
|
def _validate_variable_name(name: str) -> None:
|
|
535
|
-
"""
|
|
660
|
+
"""
|
|
661
|
+
Validate that a variable name is a valid identifier.
|
|
536
662
|
|
|
537
663
|
Args:
|
|
538
|
-
|
|
664
|
+
name: Variable name to validate
|
|
539
665
|
|
|
540
666
|
Raises:
|
|
541
|
-
|
|
667
|
+
ValueError: If name is not a valid identifier
|
|
668
|
+
@athena: 61f92f7ad278
|
|
542
669
|
"""
|
|
543
|
-
if not re.match(r
|
|
670
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
544
671
|
raise ValueError(
|
|
545
672
|
f"Variable name '{name}' is invalid. Names must start with "
|
|
546
673
|
f"letter/underscore and contain only alphanumerics and underscores."
|
|
@@ -548,23 +675,20 @@ def _validate_variable_name(name: str) -> None:
|
|
|
548
675
|
|
|
549
676
|
|
|
550
677
|
def _infer_variable_type(value: Any) -> str:
|
|
551
|
-
"""
|
|
678
|
+
"""
|
|
679
|
+
Infer type name from Python value.
|
|
552
680
|
|
|
553
681
|
Args:
|
|
554
|
-
|
|
682
|
+
value: Python value from YAML
|
|
555
683
|
|
|
556
684
|
Returns:
|
|
557
|
-
|
|
685
|
+
Type name string (str, int, float, bool)
|
|
558
686
|
|
|
559
687
|
Raises:
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
int: "int",
|
|
565
|
-
float: "float",
|
|
566
|
-
bool: "bool"
|
|
567
|
-
}
|
|
688
|
+
ValueError: If value type is not supported
|
|
689
|
+
@athena: 335ae24e1504
|
|
690
|
+
"""
|
|
691
|
+
type_map = {str: "str", int: "int", float: "float", bool: "bool"}
|
|
568
692
|
python_type = type(value)
|
|
569
693
|
if python_type not in type_map:
|
|
570
694
|
raise ValueError(
|
|
@@ -575,29 +699,35 @@ def _infer_variable_type(value: Any) -> str:
|
|
|
575
699
|
|
|
576
700
|
|
|
577
701
|
def _is_env_variable_reference(value: Any) -> bool:
|
|
578
|
-
"""
|
|
702
|
+
"""
|
|
703
|
+
Check if value is an environment variable reference.
|
|
579
704
|
|
|
580
705
|
Args:
|
|
581
|
-
|
|
706
|
+
value: Raw value from YAML
|
|
582
707
|
|
|
583
708
|
Returns:
|
|
584
|
-
|
|
709
|
+
True if value is { env: VAR_NAME } dict
|
|
710
|
+
@athena: c01927ec19ef
|
|
585
711
|
"""
|
|
586
712
|
return isinstance(value, dict) and "env" in value
|
|
587
713
|
|
|
588
714
|
|
|
589
|
-
def _validate_env_variable_reference(
|
|
590
|
-
|
|
715
|
+
def _validate_env_variable_reference(
|
|
716
|
+
var_name: str, value: dict
|
|
717
|
+
) -> tuple[str, str | None]:
|
|
718
|
+
"""
|
|
719
|
+
Validate and extract environment variable name and optional default from reference.
|
|
591
720
|
|
|
592
721
|
Args:
|
|
593
|
-
|
|
594
|
-
|
|
722
|
+
var_name: Name of the variable being defined
|
|
723
|
+
value: Dict that should be { env: ENV_VAR_NAME } or { env: ENV_VAR_NAME, default: "value" }
|
|
595
724
|
|
|
596
725
|
Returns:
|
|
597
|
-
|
|
726
|
+
Tuple of (environment variable name, default value or None)
|
|
598
727
|
|
|
599
728
|
Raises:
|
|
600
|
-
|
|
729
|
+
ValueError: If reference is invalid
|
|
730
|
+
@athena: 9fc8b2333b54
|
|
601
731
|
"""
|
|
602
732
|
# Validate dict structure - allow 'env' and optionally 'default'
|
|
603
733
|
valid_keys = {"env", "default"}
|
|
@@ -605,7 +735,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
605
735
|
if invalid_keys:
|
|
606
736
|
raise ValueError(
|
|
607
737
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
608
|
-
f
|
|
738
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}\n'
|
|
609
739
|
f"Found invalid keys: {', '.join(invalid_keys)}"
|
|
610
740
|
)
|
|
611
741
|
|
|
@@ -614,7 +744,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
614
744
|
raise ValueError(
|
|
615
745
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
616
746
|
f"Missing required 'env' key.\n"
|
|
617
|
-
f
|
|
747
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
|
|
618
748
|
)
|
|
619
749
|
|
|
620
750
|
env_var_name = value["env"]
|
|
@@ -623,12 +753,12 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
623
753
|
if not env_var_name or not isinstance(env_var_name, str):
|
|
624
754
|
raise ValueError(
|
|
625
755
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
626
|
-
f
|
|
756
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
|
|
627
757
|
f"Found: {{ env: {env_var_name!r} }}"
|
|
628
758
|
)
|
|
629
759
|
|
|
630
760
|
# Validate env var name format (allow both uppercase and mixed case for flexibility)
|
|
631
|
-
if not re.match(r
|
|
761
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", env_var_name):
|
|
632
762
|
raise ValueError(
|
|
633
763
|
f"Invalid environment variable name '{env_var_name}' in variable '{var_name}'.\n"
|
|
634
764
|
f"Environment variable names must start with a letter or underscore,\n"
|
|
@@ -644,25 +774,29 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
644
774
|
f"Invalid default value in variable '{var_name}'.\n"
|
|
645
775
|
f"Environment variable defaults must be strings.\n"
|
|
646
776
|
f"Got: {default!r} (type: {type(default).__name__})\n"
|
|
647
|
-
f
|
|
777
|
+
f'Use a quoted string: {{ env: {env_var_name}, default: "{default}" }}'
|
|
648
778
|
)
|
|
649
779
|
|
|
650
780
|
return env_var_name, default
|
|
651
781
|
|
|
652
782
|
|
|
653
|
-
def _resolve_env_variable(
|
|
654
|
-
|
|
783
|
+
def _resolve_env_variable(
|
|
784
|
+
var_name: str, env_var_name: str, default: str | None = None
|
|
785
|
+
) -> str:
|
|
786
|
+
"""
|
|
787
|
+
Resolve environment variable value.
|
|
655
788
|
|
|
656
789
|
Args:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
790
|
+
var_name: Name of the variable being defined
|
|
791
|
+
env_var_name: Name of environment variable to read
|
|
792
|
+
default: Optional default value to use if environment variable is not set
|
|
660
793
|
|
|
661
794
|
Returns:
|
|
662
|
-
|
|
795
|
+
Environment variable value as string, or default if not set and default provided
|
|
663
796
|
|
|
664
797
|
Raises:
|
|
665
|
-
|
|
798
|
+
ValueError: If environment variable is not set and no default provided
|
|
799
|
+
@athena: c00d3a241a99
|
|
666
800
|
"""
|
|
667
801
|
value = os.environ.get(env_var_name, default)
|
|
668
802
|
|
|
@@ -680,29 +814,33 @@ def _resolve_env_variable(var_name: str, env_var_name: str, default: str | None
|
|
|
680
814
|
|
|
681
815
|
|
|
682
816
|
def _is_file_read_reference(value: Any) -> bool:
|
|
683
|
-
"""
|
|
817
|
+
"""
|
|
818
|
+
Check if value is a file read reference.
|
|
684
819
|
|
|
685
820
|
Args:
|
|
686
|
-
|
|
821
|
+
value: Raw value from YAML
|
|
687
822
|
|
|
688
823
|
Returns:
|
|
689
|
-
|
|
824
|
+
True if value is { read: filepath } dict
|
|
825
|
+
@athena: da129db1b17b
|
|
690
826
|
"""
|
|
691
827
|
return isinstance(value, dict) and "read" in value
|
|
692
828
|
|
|
693
829
|
|
|
694
830
|
def _validate_file_read_reference(var_name: str, value: dict) -> str:
|
|
695
|
-
"""
|
|
831
|
+
"""
|
|
832
|
+
Validate and extract filepath from file read reference.
|
|
696
833
|
|
|
697
834
|
Args:
|
|
698
|
-
|
|
699
|
-
|
|
835
|
+
var_name: Name of the variable being defined
|
|
836
|
+
value: Dict that should be { read: filepath }
|
|
700
837
|
|
|
701
838
|
Returns:
|
|
702
|
-
|
|
839
|
+
Filepath string
|
|
703
840
|
|
|
704
841
|
Raises:
|
|
705
|
-
|
|
842
|
+
ValueError: If reference is invalid
|
|
843
|
+
@athena: 2615951372fc
|
|
706
844
|
"""
|
|
707
845
|
# Validate dict structure (only "read" key allowed)
|
|
708
846
|
if len(value) != 1:
|
|
@@ -728,7 +866,8 @@ def _validate_file_read_reference(var_name: str, value: dict) -> str:
|
|
|
728
866
|
|
|
729
867
|
|
|
730
868
|
def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
731
|
-
"""
|
|
869
|
+
"""
|
|
870
|
+
Resolve file path relative to recipe file location.
|
|
732
871
|
|
|
733
872
|
Handles three path types:
|
|
734
873
|
1. Tilde paths (~): Expand to user home directory
|
|
@@ -736,11 +875,12 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
|
736
875
|
3. Relative paths: Resolve relative to recipe file's directory
|
|
737
876
|
|
|
738
877
|
Args:
|
|
739
|
-
|
|
740
|
-
|
|
878
|
+
filepath: Path string from YAML (may be relative, absolute, or tilde)
|
|
879
|
+
recipe_file_path: Path to the recipe file containing the variable
|
|
741
880
|
|
|
742
881
|
Returns:
|
|
743
|
-
|
|
882
|
+
Resolved absolute Path object
|
|
883
|
+
@athena: e80470e9c7d6
|
|
744
884
|
"""
|
|
745
885
|
# Expand tilde to home directory
|
|
746
886
|
if filepath.startswith("~"):
|
|
@@ -758,18 +898,20 @@ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
|
|
|
758
898
|
|
|
759
899
|
|
|
760
900
|
def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) -> str:
|
|
761
|
-
"""
|
|
901
|
+
"""
|
|
902
|
+
Read file contents for variable value.
|
|
762
903
|
|
|
763
904
|
Args:
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
905
|
+
var_name: Name of the variable being defined
|
|
906
|
+
filepath: Original filepath string (for error messages)
|
|
907
|
+
resolved_path: Resolved absolute path to the file
|
|
767
908
|
|
|
768
909
|
Returns:
|
|
769
|
-
|
|
910
|
+
File contents as string (with trailing newline stripped)
|
|
770
911
|
|
|
771
912
|
Raises:
|
|
772
|
-
|
|
913
|
+
ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
|
|
914
|
+
@athena: cab84337f145
|
|
773
915
|
"""
|
|
774
916
|
# Check file exists
|
|
775
917
|
if not resolved_path.exists():
|
|
@@ -788,7 +930,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
788
930
|
|
|
789
931
|
# Read file with UTF-8 error handling
|
|
790
932
|
try:
|
|
791
|
-
content = resolved_path.read_text(encoding=
|
|
933
|
+
content = resolved_path.read_text(encoding="utf-8")
|
|
792
934
|
except PermissionError:
|
|
793
935
|
raise ValueError(
|
|
794
936
|
f"Failed to read file for variable '{var_name}': {filepath}\n"
|
|
@@ -804,36 +946,40 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
804
946
|
)
|
|
805
947
|
|
|
806
948
|
# Strip single trailing newline if present
|
|
807
|
-
if content.endswith(
|
|
949
|
+
if content.endswith("\n"):
|
|
808
950
|
content = content[:-1]
|
|
809
951
|
|
|
810
952
|
return content
|
|
811
953
|
|
|
812
954
|
|
|
813
955
|
def _is_eval_reference(value: Any) -> bool:
|
|
814
|
-
"""
|
|
956
|
+
"""
|
|
957
|
+
Check if value is an eval command reference.
|
|
815
958
|
|
|
816
959
|
Args:
|
|
817
|
-
|
|
960
|
+
value: Raw value from YAML
|
|
818
961
|
|
|
819
962
|
Returns:
|
|
820
|
-
|
|
963
|
+
True if value is { eval: command } dict
|
|
964
|
+
@athena: 121784f6d4ab
|
|
821
965
|
"""
|
|
822
966
|
return isinstance(value, dict) and "eval" in value
|
|
823
967
|
|
|
824
968
|
|
|
825
969
|
def _validate_eval_reference(var_name: str, value: dict) -> str:
|
|
826
|
-
"""
|
|
970
|
+
"""
|
|
971
|
+
Validate and extract command from eval reference.
|
|
827
972
|
|
|
828
973
|
Args:
|
|
829
|
-
|
|
830
|
-
|
|
974
|
+
var_name: Name of the variable being defined
|
|
975
|
+
value: Dict that should be { eval: command }
|
|
831
976
|
|
|
832
977
|
Returns:
|
|
833
|
-
|
|
978
|
+
Command string
|
|
834
979
|
|
|
835
980
|
Raises:
|
|
836
|
-
|
|
981
|
+
ValueError: If reference is invalid
|
|
982
|
+
@athena: f3cde1011d2d
|
|
837
983
|
"""
|
|
838
984
|
# Validate dict structure (only "eval" key allowed)
|
|
839
985
|
if len(value) != 1:
|
|
@@ -859,37 +1005,38 @@ def _validate_eval_reference(var_name: str, value: dict) -> str:
|
|
|
859
1005
|
|
|
860
1006
|
|
|
861
1007
|
def _get_default_shell_and_args() -> tuple[str, list[str]]:
|
|
862
|
-
"""
|
|
1008
|
+
"""
|
|
1009
|
+
Get default shell and args for current platform.
|
|
863
1010
|
|
|
864
1011
|
Returns:
|
|
865
|
-
|
|
1012
|
+
Tuple of (shell, args) for platform default
|
|
1013
|
+
@athena: 475863b02b48
|
|
866
1014
|
"""
|
|
867
1015
|
is_windows = platform.system() == "Windows"
|
|
868
1016
|
if is_windows:
|
|
869
|
-
return
|
|
1017
|
+
return "cmd", ["/c"]
|
|
870
1018
|
else:
|
|
871
|
-
return
|
|
1019
|
+
return "bash", ["-c"]
|
|
872
1020
|
|
|
873
1021
|
|
|
874
1022
|
def _resolve_eval_variable(
|
|
875
|
-
var_name: str,
|
|
876
|
-
command: str,
|
|
877
|
-
recipe_file_path: Path,
|
|
878
|
-
recipe_data: dict
|
|
1023
|
+
var_name: str, command: str, recipe_file_path: Path, recipe_data: dict
|
|
879
1024
|
) -> str:
|
|
880
|
-
"""
|
|
1025
|
+
"""
|
|
1026
|
+
Execute command and capture output for variable value.
|
|
881
1027
|
|
|
882
1028
|
Args:
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1029
|
+
var_name: Name of the variable being defined
|
|
1030
|
+
command: Command to execute
|
|
1031
|
+
recipe_file_path: Path to recipe file (for working directory)
|
|
1032
|
+
recipe_data: Parsed YAML data (for accessing default_env)
|
|
887
1033
|
|
|
888
1034
|
Returns:
|
|
889
|
-
|
|
1035
|
+
Command stdout as string (with trailing newline stripped)
|
|
890
1036
|
|
|
891
1037
|
Raises:
|
|
892
|
-
|
|
1038
|
+
ValueError: If command fails or cannot be executed
|
|
1039
|
+
@athena: 647d3a310c77
|
|
893
1040
|
"""
|
|
894
1041
|
# Determine shell to use
|
|
895
1042
|
shell = None
|
|
@@ -954,7 +1101,7 @@ def _resolve_eval_variable(
|
|
|
954
1101
|
output = result.stdout
|
|
955
1102
|
|
|
956
1103
|
# Strip single trailing newline if present
|
|
957
|
-
if output.endswith(
|
|
1104
|
+
if output.endswith("\n"):
|
|
958
1105
|
output = output[:-1]
|
|
959
1106
|
|
|
960
1107
|
return output
|
|
@@ -966,23 +1113,25 @@ def _resolve_variable_value(
|
|
|
966
1113
|
resolved: dict[str, str],
|
|
967
1114
|
resolution_stack: list[str],
|
|
968
1115
|
file_path: Path,
|
|
969
|
-
recipe_data: dict | None = None
|
|
1116
|
+
recipe_data: dict | None = None,
|
|
970
1117
|
) -> str:
|
|
971
|
-
"""
|
|
1118
|
+
"""
|
|
1119
|
+
Resolve a single variable value with circular reference detection.
|
|
972
1120
|
|
|
973
1121
|
Args:
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1122
|
+
name: Variable name being resolved
|
|
1123
|
+
raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
|
|
1124
|
+
resolved: Dictionary of already-resolved variables
|
|
1125
|
+
resolution_stack: Stack of variables currently being resolved (for circular detection)
|
|
1126
|
+
file_path: Path to recipe file (for resolving relative file paths in { read: ... })
|
|
1127
|
+
recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
|
|
980
1128
|
|
|
981
1129
|
Returns:
|
|
982
|
-
|
|
1130
|
+
Resolved string value
|
|
983
1131
|
|
|
984
1132
|
Raises:
|
|
985
|
-
|
|
1133
|
+
ValueError: If circular reference detected or validation fails
|
|
1134
|
+
@athena: da94de106756
|
|
986
1135
|
"""
|
|
987
1136
|
# Check for circular reference
|
|
988
1137
|
if name in resolution_stack:
|
|
@@ -1002,6 +1151,7 @@ def _resolve_variable_value(
|
|
|
1002
1151
|
|
|
1003
1152
|
# Still perform variable-in-variable substitution
|
|
1004
1153
|
from tasktree.substitution import substitute_variables
|
|
1154
|
+
|
|
1005
1155
|
try:
|
|
1006
1156
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1007
1157
|
except ValueError as e:
|
|
@@ -1013,7 +1163,9 @@ def _resolve_variable_value(
|
|
|
1013
1163
|
undefined_var = match.group(1)
|
|
1014
1164
|
if undefined_var in resolution_stack:
|
|
1015
1165
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1016
|
-
raise ValueError(
|
|
1166
|
+
raise ValueError(
|
|
1167
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1168
|
+
)
|
|
1017
1169
|
# Re-raise the original error if not circular
|
|
1018
1170
|
raise
|
|
1019
1171
|
|
|
@@ -1032,6 +1184,7 @@ def _resolve_variable_value(
|
|
|
1032
1184
|
|
|
1033
1185
|
# Still perform variable-in-variable substitution
|
|
1034
1186
|
from tasktree.substitution import substitute_variables
|
|
1187
|
+
|
|
1035
1188
|
try:
|
|
1036
1189
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1037
1190
|
except ValueError as e:
|
|
@@ -1043,7 +1196,9 @@ def _resolve_variable_value(
|
|
|
1043
1196
|
undefined_var = match.group(1)
|
|
1044
1197
|
if undefined_var in resolution_stack:
|
|
1045
1198
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1046
|
-
raise ValueError(
|
|
1199
|
+
raise ValueError(
|
|
1200
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1201
|
+
)
|
|
1047
1202
|
# Re-raise the original error if not circular
|
|
1048
1203
|
raise
|
|
1049
1204
|
|
|
@@ -1059,6 +1214,7 @@ def _resolve_variable_value(
|
|
|
1059
1214
|
|
|
1060
1215
|
# Still perform variable-in-variable substitution
|
|
1061
1216
|
from tasktree.substitution import substitute_variables
|
|
1217
|
+
|
|
1062
1218
|
try:
|
|
1063
1219
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1064
1220
|
except ValueError as e:
|
|
@@ -1070,7 +1226,9 @@ def _resolve_variable_value(
|
|
|
1070
1226
|
undefined_var = match.group(1)
|
|
1071
1227
|
if undefined_var in resolution_stack:
|
|
1072
1228
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1073
|
-
raise ValueError(
|
|
1229
|
+
raise ValueError(
|
|
1230
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1231
|
+
)
|
|
1074
1232
|
# Re-raise the original error if not circular
|
|
1075
1233
|
raise
|
|
1076
1234
|
|
|
@@ -1079,6 +1237,7 @@ def _resolve_variable_value(
|
|
|
1079
1237
|
# Validate and infer type
|
|
1080
1238
|
type_name = _infer_variable_type(raw_value)
|
|
1081
1239
|
from tasktree.types import get_click_type
|
|
1240
|
+
|
|
1082
1241
|
validator = get_click_type(type_name)
|
|
1083
1242
|
|
|
1084
1243
|
# Validate and stringify the value
|
|
@@ -1092,6 +1251,7 @@ def _resolve_variable_value(
|
|
|
1092
1251
|
|
|
1093
1252
|
# Substitute any {{ var.name }} references in the string value
|
|
1094
1253
|
from tasktree.substitution import substitute_variables
|
|
1254
|
+
|
|
1095
1255
|
try:
|
|
1096
1256
|
resolved_value = substitute_variables(string_value_str, resolved)
|
|
1097
1257
|
except ValueError as e:
|
|
@@ -1104,7 +1264,9 @@ def _resolve_variable_value(
|
|
|
1104
1264
|
undefined_var = match.group(1)
|
|
1105
1265
|
if undefined_var in resolution_stack:
|
|
1106
1266
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1107
|
-
raise ValueError(
|
|
1267
|
+
raise ValueError(
|
|
1268
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1269
|
+
)
|
|
1108
1270
|
# Re-raise the original error if not circular
|
|
1109
1271
|
raise
|
|
1110
1272
|
|
|
@@ -1114,20 +1276,22 @@ def _resolve_variable_value(
|
|
|
1114
1276
|
|
|
1115
1277
|
|
|
1116
1278
|
def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
|
|
1117
|
-
"""
|
|
1279
|
+
"""
|
|
1280
|
+
Parse and resolve the variables section from YAML data.
|
|
1118
1281
|
|
|
1119
1282
|
Variables are resolved in order, allowing variables to reference
|
|
1120
1283
|
previously-defined variables using {{ var.name }} syntax.
|
|
1121
1284
|
|
|
1122
1285
|
Args:
|
|
1123
|
-
|
|
1124
|
-
|
|
1286
|
+
data: Parsed YAML data (root level)
|
|
1287
|
+
file_path: Path to the recipe file (for resolving relative file paths)
|
|
1125
1288
|
|
|
1126
1289
|
Returns:
|
|
1127
|
-
|
|
1290
|
+
Dictionary mapping variable names to resolved string values
|
|
1128
1291
|
|
|
1129
1292
|
Raises:
|
|
1130
|
-
|
|
1293
|
+
ValueError: For validation errors, undefined refs, or circular refs
|
|
1294
|
+
@athena: aa45e860a958
|
|
1131
1295
|
"""
|
|
1132
1296
|
if "variables" not in data:
|
|
1133
1297
|
return {}
|
|
@@ -1149,33 +1313,34 @@ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
|
|
|
1149
1313
|
|
|
1150
1314
|
|
|
1151
1315
|
def _expand_variable_dependencies(
|
|
1152
|
-
variable_names: set[str],
|
|
1153
|
-
raw_variables: dict[str, Any]
|
|
1316
|
+
variable_names: set[str], raw_variables: dict[str, Any]
|
|
1154
1317
|
) -> set[str]:
|
|
1155
|
-
"""
|
|
1318
|
+
"""
|
|
1319
|
+
Expand variable set to include all transitively referenced variables.
|
|
1156
1320
|
|
|
1157
1321
|
If variable A references variable B, and B references C, then requesting A
|
|
1158
1322
|
should also evaluate B and C.
|
|
1159
1323
|
|
|
1160
1324
|
Args:
|
|
1161
|
-
|
|
1162
|
-
|
|
1325
|
+
variable_names: Initial set of variable names
|
|
1326
|
+
raw_variables: Raw variable definitions from YAML
|
|
1163
1327
|
|
|
1164
1328
|
Returns:
|
|
1165
|
-
|
|
1329
|
+
Expanded set including all transitively referenced variables
|
|
1166
1330
|
|
|
1167
1331
|
Example:
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1332
|
+
>>> raw_vars = {
|
|
1333
|
+
... "a": "{{ var.b }}",
|
|
1334
|
+
... "b": "{{ var.c }}",
|
|
1335
|
+
... "c": "value"
|
|
1336
|
+
... }
|
|
1337
|
+
>>> _expand_variable_dependencies({"a"}, raw_vars)
|
|
1338
|
+
{"a", "b", "c"}
|
|
1339
|
+
@athena: 98e583b402aa
|
|
1175
1340
|
"""
|
|
1176
1341
|
expanded = set(variable_names)
|
|
1177
1342
|
to_process = list(variable_names)
|
|
1178
|
-
pattern = re.compile(r
|
|
1343
|
+
pattern = re.compile(r"\{\{\s*var\.(\w+)\s*}}")
|
|
1179
1344
|
|
|
1180
1345
|
while to_process:
|
|
1181
1346
|
var_name = to_process.pop(0)
|
|
@@ -1194,8 +1359,8 @@ def _expand_variable_dependencies(
|
|
|
1194
1359
|
expanded.add(referenced_var)
|
|
1195
1360
|
to_process.append(referenced_var)
|
|
1196
1361
|
# Handle { read: filepath } variables - check file contents for variable references
|
|
1197
|
-
elif isinstance(raw_value, dict) and
|
|
1198
|
-
filepath = raw_value[
|
|
1362
|
+
elif isinstance(raw_value, dict) and "read" in raw_value:
|
|
1363
|
+
filepath = raw_value["read"]
|
|
1199
1364
|
# For dependency expansion, we speculatively read files to find variable references
|
|
1200
1365
|
# This is acceptable because file reads are relatively cheap compared to eval commands
|
|
1201
1366
|
try:
|
|
@@ -1203,6 +1368,7 @@ def _expand_variable_dependencies(
|
|
|
1203
1368
|
# Skip if filepath is None or empty (validation error will be caught during evaluation)
|
|
1204
1369
|
if filepath and isinstance(filepath, str):
|
|
1205
1370
|
from pathlib import Path
|
|
1371
|
+
|
|
1206
1372
|
if Path(filepath).exists():
|
|
1207
1373
|
file_content = Path(filepath).read_text()
|
|
1208
1374
|
# Extract variable references from file content
|
|
@@ -1216,8 +1382,12 @@ def _expand_variable_dependencies(
|
|
|
1216
1382
|
# The error will be caught during actual evaluation
|
|
1217
1383
|
pass
|
|
1218
1384
|
# Handle { env: VAR, default: ... } variables - check default value for variable references
|
|
1219
|
-
elif
|
|
1220
|
-
|
|
1385
|
+
elif (
|
|
1386
|
+
isinstance(raw_value, dict)
|
|
1387
|
+
and "env" in raw_value
|
|
1388
|
+
and "default" in raw_value
|
|
1389
|
+
):
|
|
1390
|
+
default_value = raw_value["default"]
|
|
1221
1391
|
# Check if default value contains variable references
|
|
1222
1392
|
if isinstance(default_value, str):
|
|
1223
1393
|
for match in pattern.finditer(default_value):
|
|
@@ -1230,12 +1400,10 @@ def _expand_variable_dependencies(
|
|
|
1230
1400
|
|
|
1231
1401
|
|
|
1232
1402
|
def _evaluate_variable_subset(
|
|
1233
|
-
raw_variables: dict[str, Any],
|
|
1234
|
-
variable_names: set[str],
|
|
1235
|
-
file_path: Path,
|
|
1236
|
-
data: dict
|
|
1403
|
+
raw_variables: dict[str, Any], variable_names: set[str], file_path: Path, data: dict
|
|
1237
1404
|
) -> dict[str, str]:
|
|
1238
|
-
"""
|
|
1405
|
+
"""
|
|
1406
|
+
Evaluate only specified variables from raw specs (for lazy evaluation).
|
|
1239
1407
|
|
|
1240
1408
|
This function is similar to _parse_variables_section but only evaluates
|
|
1241
1409
|
a subset of variables. This enables lazy evaluation where only reachable
|
|
@@ -1245,21 +1413,22 @@ def _evaluate_variable_subset(
|
|
|
1245
1413
|
variable B, both will be evaluated even if only A was explicitly requested.
|
|
1246
1414
|
|
|
1247
1415
|
Args:
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1416
|
+
raw_variables: Raw variable definitions from YAML (not yet evaluated)
|
|
1417
|
+
variable_names: Set of variable names to evaluate
|
|
1418
|
+
file_path: Recipe file path (for relative file resolution)
|
|
1419
|
+
data: Full YAML data (for context in _resolve_variable_value)
|
|
1252
1420
|
|
|
1253
1421
|
Returns:
|
|
1254
|
-
|
|
1422
|
+
Dictionary of evaluated variable values (for specified variables and their dependencies)
|
|
1255
1423
|
|
|
1256
1424
|
Raises:
|
|
1257
|
-
|
|
1425
|
+
ValueError: For validation errors, undefined refs, or circular refs
|
|
1258
1426
|
|
|
1259
1427
|
Example:
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1428
|
+
>>> raw_vars = {"a": "{{ var.b }}", "b": "value", "c": "unused"}
|
|
1429
|
+
>>> _evaluate_variable_subset(raw_vars, {"a"}, path, data)
|
|
1430
|
+
{"a": "value", "b": "value"} # "a" and its dependency "b", but not "c"
|
|
1431
|
+
@athena: 1e9d491d7404
|
|
1263
1432
|
"""
|
|
1264
1433
|
if not isinstance(raw_variables, dict):
|
|
1265
1434
|
raise ValueError("'variables' must be a dictionary")
|
|
@@ -1286,18 +1455,22 @@ def _parse_file_with_env(
|
|
|
1286
1455
|
namespace: str | None,
|
|
1287
1456
|
project_root: Path,
|
|
1288
1457
|
import_stack: list[Path] | None = None,
|
|
1289
|
-
) -> tuple[
|
|
1290
|
-
|
|
1458
|
+
) -> tuple[
|
|
1459
|
+
dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]
|
|
1460
|
+
]:
|
|
1461
|
+
"""
|
|
1462
|
+
Parse file and extract tasks, environments, and variables.
|
|
1291
1463
|
|
|
1292
1464
|
Args:
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1465
|
+
file_path: Path to YAML file
|
|
1466
|
+
namespace: Optional namespace prefix for tasks
|
|
1467
|
+
project_root: Root directory of the project
|
|
1468
|
+
import_stack: Stack of files being imported (for circular detection)
|
|
1297
1469
|
|
|
1298
1470
|
Returns:
|
|
1299
|
-
|
|
1300
|
-
|
|
1471
|
+
Tuple of (tasks, environments, default_env_name, raw_variables, YAML_data)
|
|
1472
|
+
Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
|
|
1473
|
+
@athena: b2dced506787
|
|
1301
1474
|
"""
|
|
1302
1475
|
# Parse tasks normally
|
|
1303
1476
|
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
@@ -1413,32 +1586,34 @@ def _parse_file_with_env(
|
|
|
1413
1586
|
env_vars=env_vars,
|
|
1414
1587
|
working_dir=working_dir,
|
|
1415
1588
|
extra_args=extra_args,
|
|
1416
|
-
run_as_root=run_as_root
|
|
1589
|
+
run_as_root=run_as_root,
|
|
1417
1590
|
)
|
|
1418
1591
|
|
|
1419
1592
|
return tasks, environments, default_env, raw_variables, yaml_data
|
|
1420
1593
|
|
|
1421
1594
|
|
|
1422
1595
|
def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
|
|
1423
|
-
"""
|
|
1596
|
+
"""
|
|
1597
|
+
Collect all tasks reachable from the root task via dependencies.
|
|
1424
1598
|
|
|
1425
1599
|
Uses BFS to traverse the dependency graph and collect all task names
|
|
1426
1600
|
that could potentially be executed when running the root task.
|
|
1427
1601
|
|
|
1428
1602
|
Args:
|
|
1429
|
-
|
|
1430
|
-
|
|
1603
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1604
|
+
root_task: Name of the root task to start traversal from
|
|
1431
1605
|
|
|
1432
1606
|
Returns:
|
|
1433
|
-
|
|
1607
|
+
Set of task names reachable from root_task (includes root_task itself)
|
|
1434
1608
|
|
|
1435
1609
|
Raises:
|
|
1436
|
-
|
|
1610
|
+
ValueError: If root_task doesn't exist
|
|
1437
1611
|
|
|
1438
1612
|
Example:
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1613
|
+
>>> tasks = {"a": Task("a", deps=["b"]), "b": Task("b", deps=[]), "c": Task("c", deps=[])}
|
|
1614
|
+
>>> collect_reachable_tasks(tasks, "a")
|
|
1615
|
+
{"a", "b"}
|
|
1616
|
+
@athena: fe29d8558be3
|
|
1442
1617
|
"""
|
|
1443
1618
|
if root_task not in tasks:
|
|
1444
1619
|
raise ValueError(f"Root task '{root_task}' not found in recipe")
|
|
@@ -1480,30 +1655,32 @@ def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
|
|
|
1480
1655
|
def collect_reachable_variables(
|
|
1481
1656
|
tasks: dict[str, Task],
|
|
1482
1657
|
environments: dict[str, Environment],
|
|
1483
|
-
reachable_task_names: set[str]
|
|
1658
|
+
reachable_task_names: set[str],
|
|
1484
1659
|
) -> set[str]:
|
|
1485
|
-
"""
|
|
1660
|
+
"""
|
|
1661
|
+
Extract variable names used by reachable tasks.
|
|
1486
1662
|
|
|
1487
1663
|
Searches for {{ var.* }} placeholders in task and environment definitions to determine
|
|
1488
1664
|
which variables are actually needed for execution.
|
|
1489
1665
|
|
|
1490
1666
|
Args:
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1667
|
+
tasks: Dictionary mapping task names to Task objects
|
|
1668
|
+
environments: Dictionary mapping environment names to Environment objects
|
|
1669
|
+
reachable_task_names: Set of task names that will be executed
|
|
1494
1670
|
|
|
1495
1671
|
Returns:
|
|
1496
|
-
|
|
1672
|
+
Set of variable names referenced by reachable tasks
|
|
1497
1673
|
|
|
1498
1674
|
Example:
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1675
|
+
>>> task = Task("build", cmd="echo {{ var.version }}")
|
|
1676
|
+
>>> collect_reachable_variables({"build": task}, {"build"})
|
|
1677
|
+
{"version"}
|
|
1678
|
+
@athena: e22e54537f8d
|
|
1502
1679
|
"""
|
|
1503
1680
|
import re
|
|
1504
1681
|
|
|
1505
1682
|
# Pattern to match {{ var.name }}
|
|
1506
|
-
var_pattern = re.compile(r
|
|
1683
|
+
var_pattern = re.compile(r"\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}")
|
|
1507
1684
|
|
|
1508
1685
|
variables = set()
|
|
1509
1686
|
|
|
@@ -1622,32 +1799,32 @@ def collect_reachable_variables(
|
|
|
1622
1799
|
|
|
1623
1800
|
|
|
1624
1801
|
def parse_recipe(
|
|
1625
|
-
recipe_path: Path,
|
|
1626
|
-
project_root: Path | None = None,
|
|
1627
|
-
root_task: str | None = None
|
|
1802
|
+
recipe_path: Path, project_root: Path | None = None, root_task: str | None = None
|
|
1628
1803
|
) -> Recipe:
|
|
1629
|
-
"""
|
|
1804
|
+
"""
|
|
1805
|
+
Parse a recipe file and handle imports recursively.
|
|
1630
1806
|
|
|
1631
1807
|
This function now implements lazy variable evaluation: if root_task is provided,
|
|
1632
1808
|
only variables reachable from that task will be evaluated. This provides significant
|
|
1633
1809
|
performance and security benefits for recipes with many variables.
|
|
1634
1810
|
|
|
1635
1811
|
Args:
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1812
|
+
recipe_path: Path to the main recipe file
|
|
1813
|
+
project_root: Optional project root directory. If not provided, uses recipe file's parent directory.
|
|
1814
|
+
When using --tasks option, this should be the current working directory.
|
|
1815
|
+
root_task: Optional root task for lazy variable evaluation. If provided, only variables
|
|
1816
|
+
used by tasks reachable from root_task will be evaluated (optimization).
|
|
1817
|
+
If None, all variables will be evaluated (for --list command compatibility).
|
|
1642
1818
|
|
|
1643
1819
|
Returns:
|
|
1644
|
-
|
|
1820
|
+
Recipe object with all tasks (including recursively imported tasks) and evaluated variables
|
|
1645
1821
|
|
|
1646
1822
|
Raises:
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1823
|
+
FileNotFoundError: If recipe file doesn't exist
|
|
1824
|
+
CircularImportError: If circular imports are detected
|
|
1825
|
+
yaml.YAMLError: If YAML is invalid
|
|
1826
|
+
ValueError: If recipe structure is invalid
|
|
1827
|
+
@athena: 27326e37d5f3
|
|
1651
1828
|
"""
|
|
1652
1829
|
if not recipe_path.exists():
|
|
1653
1830
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
@@ -1673,7 +1850,7 @@ def parse_recipe(
|
|
|
1673
1850
|
raw_variables=raw_variables,
|
|
1674
1851
|
evaluated_variables={}, # Empty initially
|
|
1675
1852
|
_variables_evaluated=False,
|
|
1676
|
-
_original_yaml_data=yaml_data
|
|
1853
|
+
_original_yaml_data=yaml_data,
|
|
1677
1854
|
)
|
|
1678
1855
|
|
|
1679
1856
|
# Trigger lazy variable evaluation
|
|
@@ -1690,21 +1867,23 @@ def _parse_file(
|
|
|
1690
1867
|
project_root: Path,
|
|
1691
1868
|
import_stack: list[Path] | None = None,
|
|
1692
1869
|
) -> dict[str, Task]:
|
|
1693
|
-
"""
|
|
1870
|
+
"""
|
|
1871
|
+
Parse a single YAML file and return tasks, recursively processing imports.
|
|
1694
1872
|
|
|
1695
1873
|
Args:
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1874
|
+
file_path: Path to YAML file
|
|
1875
|
+
namespace: Optional namespace prefix for tasks
|
|
1876
|
+
project_root: Root directory of the project
|
|
1877
|
+
import_stack: Stack of files being imported (for circular detection)
|
|
1700
1878
|
|
|
1701
1879
|
Returns:
|
|
1702
|
-
|
|
1880
|
+
Dictionary of task name to Task objects
|
|
1703
1881
|
|
|
1704
1882
|
Raises:
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1883
|
+
CircularImportError: If a circular import is detected
|
|
1884
|
+
FileNotFoundError: If an imported file doesn't exist
|
|
1885
|
+
ValueError: If task structure is invalid
|
|
1886
|
+
@athena: 8a903d791c2f
|
|
1708
1887
|
"""
|
|
1709
1888
|
# Initialize import stack if not provided
|
|
1710
1889
|
if import_stack is None:
|
|
@@ -1726,7 +1905,8 @@ def _parse_file(
|
|
|
1726
1905
|
data = {}
|
|
1727
1906
|
|
|
1728
1907
|
tasks: dict[str, Task] = {}
|
|
1729
|
-
|
|
1908
|
+
# TODO: Understand why this is not used.
|
|
1909
|
+
# file_dir = file_path.parent
|
|
1730
1910
|
|
|
1731
1911
|
# Default working directory is the project root (where tt is invoked)
|
|
1732
1912
|
# NOT the directory where the tasks file is located
|
|
@@ -1746,7 +1926,9 @@ def _parse_file(
|
|
|
1746
1926
|
local_import_namespaces.add(child_namespace)
|
|
1747
1927
|
|
|
1748
1928
|
# Build full namespace chain
|
|
1749
|
-
full_namespace =
|
|
1929
|
+
full_namespace = (
|
|
1930
|
+
f"{namespace}.{child_namespace}" if namespace else child_namespace
|
|
1931
|
+
)
|
|
1750
1932
|
|
|
1751
1933
|
# Resolve import path relative to current file's directory
|
|
1752
1934
|
child_path = file_path.parent / child_file
|
|
@@ -1764,15 +1946,16 @@ def _parse_file(
|
|
|
1764
1946
|
tasks.update(nested_tasks)
|
|
1765
1947
|
|
|
1766
1948
|
# Validate top-level keys (only imports, environments, tasks, and variables are allowed)
|
|
1767
|
-
|
|
1949
|
+
valid_top_level_keys = {"imports", "environments", "tasks", "variables"}
|
|
1768
1950
|
|
|
1769
1951
|
# Check if tasks key is missing when there appear to be task definitions at root
|
|
1770
1952
|
# Do this BEFORE checking for unknown keys, to provide better error message
|
|
1771
1953
|
if "tasks" not in data and data:
|
|
1772
1954
|
# Check if there are potential task definitions at root level
|
|
1773
1955
|
potential_tasks = [
|
|
1774
|
-
k
|
|
1775
|
-
|
|
1956
|
+
k
|
|
1957
|
+
for k, v in data.items()
|
|
1958
|
+
if isinstance(v, dict) and k not in valid_top_level_keys
|
|
1776
1959
|
]
|
|
1777
1960
|
|
|
1778
1961
|
if potential_tasks:
|
|
@@ -1782,13 +1965,13 @@ def _parse_file(
|
|
|
1782
1965
|
f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
|
|
1783
1966
|
f"Did you mean:\n\n"
|
|
1784
1967
|
f"tasks:\n"
|
|
1785
|
-
+
|
|
1786
|
-
"\n cmd: ...\n\n"
|
|
1787
|
-
f"Valid top-level keys are: {', '.join(sorted(
|
|
1968
|
+
+ "\n".join(f" {k}:" for k in potential_tasks)
|
|
1969
|
+
+ "\n cmd: ...\n\n"
|
|
1970
|
+
f"Valid top-level keys are: {', '.join(sorted(valid_top_level_keys))}"
|
|
1788
1971
|
)
|
|
1789
1972
|
|
|
1790
1973
|
# Now check for other invalid top-level keys (non-dict values)
|
|
1791
|
-
invalid_keys = set(data.keys()) -
|
|
1974
|
+
invalid_keys = set(data.keys()) - valid_top_level_keys
|
|
1792
1975
|
if invalid_keys:
|
|
1793
1976
|
raise ValueError(
|
|
1794
1977
|
f"Invalid recipe format in {file_path}\n\n"
|
|
@@ -1806,10 +1989,14 @@ def _parse_file(
|
|
|
1806
1989
|
|
|
1807
1990
|
# Process local tasks
|
|
1808
1991
|
for task_name, task_data in tasks_data.items():
|
|
1809
|
-
|
|
1810
1992
|
if not isinstance(task_data, dict):
|
|
1811
1993
|
raise ValueError(f"Task '{task_name}' must be a dictionary")
|
|
1812
1994
|
|
|
1995
|
+
if "." in task_name:
|
|
1996
|
+
raise ValueError(
|
|
1997
|
+
f"Task name '{task_name}' cannot contain dots (reserved for namespacing)"
|
|
1998
|
+
)
|
|
1999
|
+
|
|
1813
2000
|
if "cmd" not in task_data:
|
|
1814
2001
|
raise ValueError(f"Task '{task_name}' missing required 'cmd' field")
|
|
1815
2002
|
|
|
@@ -1847,19 +2034,19 @@ def _parse_file(
|
|
|
1847
2034
|
elif isinstance(dep, dict):
|
|
1848
2035
|
# Dict dependency with args - rewrite the task name key
|
|
1849
2036
|
rewritten_dep = {}
|
|
1850
|
-
for
|
|
1851
|
-
if "." not in
|
|
2037
|
+
for t_name, args in dep.items():
|
|
2038
|
+
if "." not in t_name:
|
|
1852
2039
|
# Simple name - prefix it
|
|
1853
|
-
rewritten_dep[f"{namespace}.{
|
|
2040
|
+
rewritten_dep[f"{namespace}.{t_name}"] = args
|
|
1854
2041
|
else:
|
|
1855
2042
|
# Check if it starts with a local import namespace
|
|
1856
|
-
dep_root =
|
|
2043
|
+
dep_root = t_name.split(".", 1)[0]
|
|
1857
2044
|
if dep_root in local_import_namespaces:
|
|
1858
2045
|
# Local import reference - prefix it
|
|
1859
|
-
rewritten_dep[f"{namespace}.{
|
|
2046
|
+
rewritten_dep[f"{namespace}.{t_name}"] = args
|
|
1860
2047
|
else:
|
|
1861
2048
|
# External reference - keep as-is
|
|
1862
|
-
rewritten_dep[
|
|
2049
|
+
rewritten_dep[t_name] = args
|
|
1863
2050
|
rewritten_deps.append(rewritten_dep)
|
|
1864
2051
|
else:
|
|
1865
2052
|
# Unknown type - keep as-is
|
|
@@ -1893,15 +2080,17 @@ def _parse_file(
|
|
|
1893
2080
|
|
|
1894
2081
|
|
|
1895
2082
|
def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
|
|
1896
|
-
"""
|
|
2083
|
+
"""
|
|
2084
|
+
Check for exported arguments that differ only in case.
|
|
1897
2085
|
|
|
1898
2086
|
On Unix systems, environment variables are case-sensitive, but having
|
|
1899
2087
|
args that differ only in case (e.g., $Server and $server) can be confusing.
|
|
1900
2088
|
This function emits a warning if such collisions are detected.
|
|
1901
2089
|
|
|
1902
2090
|
Args:
|
|
1903
|
-
|
|
1904
|
-
|
|
2091
|
+
args: List of argument specifications
|
|
2092
|
+
task_name: Name of the task (for warning message)
|
|
2093
|
+
@athena: a3f0f3b184a8
|
|
1905
2094
|
"""
|
|
1906
2095
|
import sys
|
|
1907
2096
|
|
|
@@ -1924,48 +2113,50 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
|
|
|
1924
2113
|
f"Warning: Task '{task_name}' has exported arguments that differ only in case: "
|
|
1925
2114
|
f"${other_name} and ${name}. "
|
|
1926
2115
|
f"This may be confusing on case-sensitive systems.",
|
|
1927
|
-
file=sys.stderr
|
|
2116
|
+
file=sys.stderr,
|
|
1928
2117
|
)
|
|
1929
2118
|
else:
|
|
1930
2119
|
seen_lower[lower_name] = name
|
|
1931
2120
|
|
|
1932
2121
|
|
|
1933
2122
|
def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
1934
|
-
"""
|
|
2123
|
+
"""
|
|
2124
|
+
Parse argument specification from YAML.
|
|
1935
2125
|
|
|
1936
2126
|
Supports both string format and dictionary format:
|
|
1937
2127
|
|
|
1938
2128
|
String format (simple names only):
|
|
1939
|
-
|
|
1940
|
-
|
|
2129
|
+
- Simple name: "argname"
|
|
2130
|
+
- Exported (becomes env var): "$argname"
|
|
1941
2131
|
|
|
1942
2132
|
Dictionary format:
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2133
|
+
- argname: { default: "value" }
|
|
2134
|
+
- argname: { type: int, default: 42 }
|
|
2135
|
+
- argname: { type: int, min: 1, max: 100 }
|
|
2136
|
+
- argname: { type: str, choices: ["dev", "staging", "prod"] }
|
|
2137
|
+
- $argname: { default: "value" } # Exported (type not allowed)
|
|
1948
2138
|
|
|
1949
2139
|
Args:
|
|
1950
|
-
|
|
2140
|
+
arg_spec: Argument specification (string or dict with single key)
|
|
1951
2141
|
|
|
1952
2142
|
Returns:
|
|
1953
|
-
|
|
2143
|
+
ArgSpec object containing parsed argument information
|
|
1954
2144
|
|
|
1955
2145
|
Examples:
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2146
|
+
>>> parse_arg_spec("environment")
|
|
2147
|
+
ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2148
|
+
>>> parse_arg_spec({"key2": {"default": "foo"}})
|
|
2149
|
+
ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2150
|
+
>>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
|
|
2151
|
+
ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
|
|
2152
|
+
>>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
|
|
2153
|
+
ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
|
|
2154
|
+
>>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
|
|
2155
|
+
ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
|
|
1966
2156
|
|
|
1967
2157
|
Raises:
|
|
1968
|
-
|
|
2158
|
+
ValueError: If argument specification is invalid
|
|
2159
|
+
@athena: 2a4c7e804622
|
|
1969
2160
|
"""
|
|
1970
2161
|
# Handle dictionary format: { argname: { type: ..., default: ... } }
|
|
1971
2162
|
if isinstance(arg_spec, dict):
|
|
@@ -2023,23 +2214,25 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
|
2023
2214
|
is_exported=is_exported,
|
|
2024
2215
|
min_val=None,
|
|
2025
2216
|
max_val=None,
|
|
2026
|
-
choices=None
|
|
2217
|
+
choices=None,
|
|
2027
2218
|
)
|
|
2028
2219
|
|
|
2029
2220
|
|
|
2030
2221
|
def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
2031
|
-
"""
|
|
2222
|
+
"""
|
|
2223
|
+
Parse argument specification from dictionary format.
|
|
2032
2224
|
|
|
2033
2225
|
Args:
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2226
|
+
arg_name: Name of the argument
|
|
2227
|
+
config: Dictionary with optional keys: type, default, min, max, choices
|
|
2228
|
+
is_exported: Whether argument should be exported to environment
|
|
2037
2229
|
|
|
2038
2230
|
Returns:
|
|
2039
|
-
|
|
2231
|
+
ArgSpec object containing the parsed argument specification
|
|
2040
2232
|
|
|
2041
2233
|
Raises:
|
|
2042
|
-
|
|
2234
|
+
ValueError: If dictionary format is invalid
|
|
2235
|
+
@athena: 5b6b93a3612a
|
|
2043
2236
|
"""
|
|
2044
2237
|
# Validate dictionary keys
|
|
2045
2238
|
valid_keys = {"type", "default", "min", "max", "choices"}
|
|
@@ -2073,22 +2266,18 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2073
2266
|
f"Exported argument '${arg_name}' must have a string default value.\n"
|
|
2074
2267
|
f"Got: {default!r} (type: {type(default).__name__})\n"
|
|
2075
2268
|
f"Exported arguments become environment variables, which are always strings.\n"
|
|
2076
|
-
f
|
|
2269
|
+
f'Use a quoted string: ${arg_name}: {{ default: "{default}" }}'
|
|
2077
2270
|
)
|
|
2078
2271
|
|
|
2079
2272
|
# Validate choices
|
|
2080
2273
|
if choices is not None:
|
|
2081
2274
|
# Validate choices is a list
|
|
2082
2275
|
if not isinstance(choices, list):
|
|
2083
|
-
raise ValueError(
|
|
2084
|
-
f"Argument '{arg_name}': choices must be a list"
|
|
2085
|
-
)
|
|
2276
|
+
raise ValueError(f"Argument '{arg_name}': choices must be a list")
|
|
2086
2277
|
|
|
2087
2278
|
# Validate choices is not empty
|
|
2088
2279
|
if len(choices) == 0:
|
|
2089
|
-
raise ValueError(
|
|
2090
|
-
f"Argument '{arg_name}': choices list cannot be empty"
|
|
2091
|
-
)
|
|
2280
|
+
raise ValueError(f"Argument '{arg_name}': choices list cannot be empty")
|
|
2092
2281
|
|
|
2093
2282
|
# Check for mutual exclusivity with min/max
|
|
2094
2283
|
if min_val is not None or max_val is not None:
|
|
@@ -2117,7 +2306,9 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2117
2306
|
for value_name, value_type in inferred_types[1:]:
|
|
2118
2307
|
if value_type != first_type:
|
|
2119
2308
|
# Build error message showing the conflicting types
|
|
2120
|
-
type_info = ", ".join(
|
|
2309
|
+
type_info = ", ".join(
|
|
2310
|
+
[f"{name}={vtype}" for name, vtype in inferred_types]
|
|
2311
|
+
)
|
|
2121
2312
|
raise ValueError(
|
|
2122
2313
|
f"Argument '{arg_name}': inconsistent types inferred from min, max, and default.\n"
|
|
2123
2314
|
f"All values must have the same type.\n"
|
|
@@ -2141,7 +2332,10 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2141
2332
|
)
|
|
2142
2333
|
|
|
2143
2334
|
# Validate min/max are only used with numeric types
|
|
2144
|
-
if (min_val is not None or max_val is not None) and arg_type not in (
|
|
2335
|
+
if (min_val is not None or max_val is not None) and arg_type not in (
|
|
2336
|
+
"int",
|
|
2337
|
+
"float",
|
|
2338
|
+
):
|
|
2145
2339
|
raise ValueError(
|
|
2146
2340
|
f"Argument '{arg_name}': min/max constraints are only supported for 'int' and 'float' types, "
|
|
2147
2341
|
f"not '{arg_type}'"
|
|
@@ -2270,12 +2464,15 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2270
2464
|
is_exported=is_exported,
|
|
2271
2465
|
min_val=min_val,
|
|
2272
2466
|
max_val=max_val,
|
|
2273
|
-
choices=choices
|
|
2467
|
+
choices=choices,
|
|
2274
2468
|
)
|
|
2275
2469
|
|
|
2276
2470
|
|
|
2277
|
-
def parse_dependency_spec(
|
|
2278
|
-
|
|
2471
|
+
def parse_dependency_spec(
|
|
2472
|
+
dep_spec: str | dict[str, Any], recipe: Recipe
|
|
2473
|
+
) -> DependencyInvocation:
|
|
2474
|
+
"""
|
|
2475
|
+
Parse a dependency specification into a DependencyInvocation.
|
|
2279
2476
|
|
|
2280
2477
|
Supports three forms:
|
|
2281
2478
|
1. Simple string: "task_name" -> DependencyInvocation(task_name, None)
|
|
@@ -2283,14 +2480,15 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
|
|
|
2283
2480
|
3. Named args: {"task_name": {arg1: val1}} -> DependencyInvocation(task_name, {arg1: val1})
|
|
2284
2481
|
|
|
2285
2482
|
Args:
|
|
2286
|
-
|
|
2287
|
-
|
|
2483
|
+
dep_spec: Dependency specification (string or dict)
|
|
2484
|
+
recipe: Recipe containing task definitions (for arg normalization)
|
|
2288
2485
|
|
|
2289
2486
|
Returns:
|
|
2290
|
-
|
|
2487
|
+
DependencyInvocation object with normalized args
|
|
2291
2488
|
|
|
2292
2489
|
Raises:
|
|
2293
|
-
|
|
2490
|
+
ValueError: If dependency specification is invalid
|
|
2491
|
+
@athena: d30ff06259c2
|
|
2294
2492
|
"""
|
|
2295
2493
|
# Simple string case
|
|
2296
2494
|
if isinstance(dep_spec, str):
|
|
@@ -2339,17 +2537,19 @@ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> Dep
|
|
|
2339
2537
|
|
|
2340
2538
|
|
|
2341
2539
|
def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
|
|
2342
|
-
"""
|
|
2540
|
+
"""
|
|
2541
|
+
Get and validate that a task exists in the recipe.
|
|
2343
2542
|
|
|
2344
2543
|
Args:
|
|
2345
|
-
|
|
2346
|
-
|
|
2544
|
+
task_name: Name of the task to retrieve
|
|
2545
|
+
recipe: Recipe containing task definitions
|
|
2347
2546
|
|
|
2348
2547
|
Returns:
|
|
2349
|
-
|
|
2548
|
+
The validated Task object
|
|
2350
2549
|
|
|
2351
2550
|
Raises:
|
|
2352
|
-
|
|
2551
|
+
ValueError: If task is not found
|
|
2552
|
+
@athena: 674f077e3977
|
|
2353
2553
|
"""
|
|
2354
2554
|
task = recipe.get_task(task_name)
|
|
2355
2555
|
if task is None:
|
|
@@ -2360,18 +2560,20 @@ def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
|
|
|
2360
2560
|
def _parse_positional_dependency_args(
|
|
2361
2561
|
task_name: str, args_list: list[Any], recipe: Recipe
|
|
2362
2562
|
) -> DependencyInvocation:
|
|
2363
|
-
"""
|
|
2563
|
+
"""
|
|
2564
|
+
Parse positional dependency arguments.
|
|
2364
2565
|
|
|
2365
2566
|
Args:
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2567
|
+
task_name: Name of the dependency task
|
|
2568
|
+
args_list: List of positional argument values
|
|
2569
|
+
recipe: Recipe containing task definitions
|
|
2369
2570
|
|
|
2370
2571
|
Returns:
|
|
2371
|
-
|
|
2572
|
+
DependencyInvocation with normalized named args
|
|
2372
2573
|
|
|
2373
2574
|
Raises:
|
|
2374
|
-
|
|
2575
|
+
ValueError: If validation fails
|
|
2576
|
+
@athena: 4d1c7957e2dd
|
|
2375
2577
|
"""
|
|
2376
2578
|
# Get the task to validate against
|
|
2377
2579
|
task = _get_validated_task(task_name, recipe)
|
|
@@ -2396,7 +2598,9 @@ def _parse_positional_dependency_args(
|
|
|
2396
2598
|
spec = parsed_specs[i]
|
|
2397
2599
|
if isinstance(value, str):
|
|
2398
2600
|
# Convert string values using type validator
|
|
2399
|
-
click_type = get_click_type(
|
|
2601
|
+
click_type = get_click_type(
|
|
2602
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2603
|
+
)
|
|
2400
2604
|
args_dict[spec.name] = click_type.convert(value, None, None)
|
|
2401
2605
|
else:
|
|
2402
2606
|
# Value is already typed (e.g., bool, int from YAML)
|
|
@@ -2407,7 +2611,9 @@ def _parse_positional_dependency_args(
|
|
|
2407
2611
|
spec = parsed_specs[i]
|
|
2408
2612
|
if spec.default is not None:
|
|
2409
2613
|
# Defaults in task specs are always strings, convert them
|
|
2410
|
-
click_type = get_click_type(
|
|
2614
|
+
click_type = get_click_type(
|
|
2615
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2616
|
+
)
|
|
2411
2617
|
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
2412
2618
|
else:
|
|
2413
2619
|
raise ValueError(
|
|
@@ -2420,18 +2626,20 @@ def _parse_positional_dependency_args(
|
|
|
2420
2626
|
def _parse_named_dependency_args(
|
|
2421
2627
|
task_name: str, args_dict: dict[str, Any], recipe: Recipe
|
|
2422
2628
|
) -> DependencyInvocation:
|
|
2423
|
-
"""
|
|
2629
|
+
"""
|
|
2630
|
+
Parse named dependency arguments.
|
|
2424
2631
|
|
|
2425
2632
|
Args:
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2633
|
+
task_name: Name of the dependency task
|
|
2634
|
+
args_dict: Dictionary of argument names to values
|
|
2635
|
+
recipe: Recipe containing task definitions
|
|
2429
2636
|
|
|
2430
2637
|
Returns:
|
|
2431
|
-
|
|
2638
|
+
DependencyInvocation with normalized args (defaults filled)
|
|
2432
2639
|
|
|
2433
2640
|
Raises:
|
|
2434
|
-
|
|
2641
|
+
ValueError: If validation fails
|
|
2642
|
+
@athena: c522211de525
|
|
2435
2643
|
"""
|
|
2436
2644
|
# Get the task to validate against
|
|
2437
2645
|
task = _get_validated_task(task_name, recipe)
|
|
@@ -2450,9 +2658,7 @@ def _parse_named_dependency_args(
|
|
|
2450
2658
|
# Validate all provided arg names exist
|
|
2451
2659
|
for arg_name in args_dict:
|
|
2452
2660
|
if arg_name not in spec_map:
|
|
2453
|
-
raise ValueError(
|
|
2454
|
-
f"Task '{task_name}' has no argument named '{arg_name}'"
|
|
2455
|
-
)
|
|
2661
|
+
raise ValueError(f"Task '{task_name}' has no argument named '{arg_name}'")
|
|
2456
2662
|
|
|
2457
2663
|
# Build normalized args dict with defaults
|
|
2458
2664
|
normalized_args = {}
|
|
@@ -2461,14 +2667,18 @@ def _parse_named_dependency_args(
|
|
|
2461
2667
|
# Use provided value with type conversion (only convert strings)
|
|
2462
2668
|
value = args_dict[spec.name]
|
|
2463
2669
|
if isinstance(value, str):
|
|
2464
|
-
click_type = get_click_type(
|
|
2670
|
+
click_type = get_click_type(
|
|
2671
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2672
|
+
)
|
|
2465
2673
|
normalized_args[spec.name] = click_type.convert(value, None, None)
|
|
2466
2674
|
else:
|
|
2467
2675
|
# Value is already typed (e.g., bool, int from YAML)
|
|
2468
2676
|
normalized_args[spec.name] = value
|
|
2469
2677
|
elif spec.default is not None:
|
|
2470
2678
|
# Use default value (defaults are always strings in task specs)
|
|
2471
|
-
click_type = get_click_type(
|
|
2679
|
+
click_type = get_click_type(
|
|
2680
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2681
|
+
)
|
|
2472
2682
|
normalized_args[spec.name] = click_type.convert(spec.default, None, None)
|
|
2473
2683
|
else:
|
|
2474
2684
|
# Required arg not provided
|