tasktree 0.0.21__py3-none-any.whl → 0.0.23__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 +1 -1
- tasktree/cli.py +212 -119
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +36 -23
- tasktree/executor.py +412 -240
- tasktree/graph.py +18 -13
- tasktree/hasher.py +18 -11
- tasktree/logging.py +112 -0
- tasktree/parser.py +237 -135
- tasktree/process_runner.py +411 -0
- tasktree/state.py +7 -8
- tasktree/substitution.py +29 -17
- tasktree/types.py +32 -15
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/METADATA +213 -18
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.21.dist-info/RECORD +0 -14
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/entry_points.txt +0 -0
tasktree/parser.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Any, List
|
|
|
16
16
|
import yaml
|
|
17
17
|
|
|
18
18
|
from tasktree.types import get_click_type
|
|
19
|
+
from tasktree.process_runner import TaskOutputTypes
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class CircularImportError(Exception):
|
|
@@ -23,6 +24,7 @@ class CircularImportError(Exception):
|
|
|
23
24
|
Raised when a circular import is detected.
|
|
24
25
|
@athena: 935d53bc7d05
|
|
25
26
|
"""
|
|
27
|
+
|
|
26
28
|
pass
|
|
27
29
|
|
|
28
30
|
|
|
@@ -39,7 +41,9 @@ class Environment:
|
|
|
39
41
|
|
|
40
42
|
name: str
|
|
41
43
|
shell: str = "" # Path to shell (required for shell envs, optional for Docker)
|
|
42
|
-
args: list[str] | dict[str, str] = field(
|
|
44
|
+
args: list[str] | dict[str, str] = field(
|
|
45
|
+
default_factory=list
|
|
46
|
+
) # Shell args (list) or Docker build args (dict)
|
|
43
47
|
preamble: str = ""
|
|
44
48
|
# Docker-specific fields (presence of dockerfile indicates Docker environment)
|
|
45
49
|
dockerfile: str = "" # Path to Dockerfile
|
|
@@ -48,7 +52,9 @@ class Environment:
|
|
|
48
52
|
ports: list[str] = field(default_factory=list) # Port mappings
|
|
49
53
|
env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
|
|
50
54
|
working_dir: str = "" # Working directory (container or host)
|
|
51
|
-
extra_args: List[str] = field(
|
|
55
|
+
extra_args: List[str] = field(
|
|
56
|
+
default_factory=list
|
|
57
|
+
) # Any extra arguments to pass to docker
|
|
52
58
|
run_as_root: bool = False # If True, skip user mapping (run as root in container)
|
|
53
59
|
|
|
54
60
|
def __post_init__(self):
|
|
@@ -64,37 +70,58 @@ class Environment:
|
|
|
64
70
|
class Task:
|
|
65
71
|
"""
|
|
66
72
|
Represents a task definition.
|
|
67
|
-
@athena:
|
|
73
|
+
@athena: e2ea62ad15ba
|
|
68
74
|
"""
|
|
69
75
|
|
|
70
76
|
name: str
|
|
71
77
|
cmd: str
|
|
72
78
|
desc: str = ""
|
|
73
|
-
deps: list[str | dict[str, Any]] = field(
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
deps: list[str | dict[str, Any]] = field(
|
|
80
|
+
default_factory=list
|
|
81
|
+
) # Can be strings or dicts with args
|
|
82
|
+
inputs: list[str | dict[str, str]] = field(
|
|
83
|
+
default_factory=list
|
|
84
|
+
) # Can be strings or dicts with named inputs
|
|
85
|
+
outputs: list[str | dict[str, str]] = field(
|
|
86
|
+
default_factory=list
|
|
87
|
+
) # Can be strings or dicts with named outputs
|
|
76
88
|
working_dir: str = ""
|
|
77
|
-
args: list[str | dict[str, Any]] = field(
|
|
89
|
+
args: list[str | dict[str, Any]] = field(
|
|
90
|
+
default_factory=list
|
|
91
|
+
) # Can be strings or dicts (each dict has single key: arg name)
|
|
78
92
|
source_file: str = "" # Track which file defined this task
|
|
79
93
|
env: str = "" # Environment name to use for execution
|
|
80
94
|
private: bool = False # If True, task is hidden from --list output
|
|
95
|
+
task_output: TaskOutputTypes | None = None
|
|
81
96
|
|
|
82
97
|
# Internal fields for efficient output lookup (built in __post_init__)
|
|
83
|
-
_output_map: dict[str, str] = field(
|
|
84
|
-
|
|
98
|
+
_output_map: dict[str, str] = field(
|
|
99
|
+
init=False, default_factory=dict, repr=False
|
|
100
|
+
) # name → path mapping
|
|
101
|
+
_anonymous_outputs: list[str] = field(
|
|
102
|
+
init=False, default_factory=list, repr=False
|
|
103
|
+
) # unnamed outputs
|
|
85
104
|
|
|
86
105
|
# Internal fields for efficient input lookup (built in __post_init__)
|
|
87
|
-
_input_map: dict[str, str] = field(
|
|
88
|
-
|
|
106
|
+
_input_map: dict[str, str] = field(
|
|
107
|
+
init=False, default_factory=dict, repr=False
|
|
108
|
+
) # name → path mapping
|
|
109
|
+
_anonymous_inputs: list[str] = field(
|
|
110
|
+
init=False, default_factory=list, repr=False
|
|
111
|
+
) # unnamed inputs
|
|
89
112
|
|
|
90
113
|
# Internal fields for positional input/output access (built in __post_init__)
|
|
91
|
-
_indexed_inputs: list[str] = field(
|
|
92
|
-
|
|
114
|
+
_indexed_inputs: list[str] = field(
|
|
115
|
+
init=False, default_factory=list, repr=False
|
|
116
|
+
) # all inputs in YAML order
|
|
117
|
+
_indexed_outputs: list[str] = field(
|
|
118
|
+
init=False, default_factory=list, repr=False
|
|
119
|
+
) # all outputs in YAML order
|
|
93
120
|
|
|
94
121
|
def __post_init__(self):
|
|
95
122
|
"""
|
|
96
123
|
Ensure lists are always lists and build input/output maps and indexed lists.
|
|
97
|
-
@athena:
|
|
124
|
+
@athena: 5c750d8b1ef7
|
|
98
125
|
"""
|
|
99
126
|
if isinstance(self.deps, str):
|
|
100
127
|
self.deps = [self.deps]
|
|
@@ -138,7 +165,7 @@ class Task:
|
|
|
138
165
|
f"Task '{self.name}': Named output '{name}' must have a string path, got {type(path).__name__}: {path}"
|
|
139
166
|
)
|
|
140
167
|
|
|
141
|
-
if not re.match(r
|
|
168
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
142
169
|
raise ValueError(
|
|
143
170
|
f"Task '{self.name}': Named output '{name}' must be a valid identifier "
|
|
144
171
|
f"(letters, numbers, underscores, cannot start with number)"
|
|
@@ -180,7 +207,7 @@ class Task:
|
|
|
180
207
|
f"Task '{self.name}': Named input '{name}' must have a string path, got {type(path).__name__}: {path}"
|
|
181
208
|
)
|
|
182
209
|
|
|
183
|
-
if not re.match(r
|
|
210
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
184
211
|
raise ValueError(
|
|
185
212
|
f"Task '{self.name}': Named input '{name}' must be a valid identifier "
|
|
186
213
|
f"(letters, numbers, underscores, cannot start with number)"
|
|
@@ -219,6 +246,7 @@ class DependencySpec:
|
|
|
219
246
|
for numeric types, to preserve template placeholders.
|
|
220
247
|
@athena: 7b2f8a15d312
|
|
221
248
|
"""
|
|
249
|
+
|
|
222
250
|
task_name: str
|
|
223
251
|
arg_templates: dict[str, str] | None = None
|
|
224
252
|
|
|
@@ -243,6 +271,7 @@ class DependencyInvocation:
|
|
|
243
271
|
args: Dictionary of argument names to values (None if no args specified)
|
|
244
272
|
@athena: 0c023366160b
|
|
245
273
|
"""
|
|
274
|
+
|
|
246
275
|
task_name: str
|
|
247
276
|
args: dict[str, Any] | None = None
|
|
248
277
|
|
|
@@ -272,6 +301,7 @@ class ArgSpec:
|
|
|
272
301
|
choices: List of valid choices for the argument (None if not specified)
|
|
273
302
|
@athena: fcaf20fb1ca2
|
|
274
303
|
"""
|
|
304
|
+
|
|
275
305
|
name: str
|
|
276
306
|
arg_type: str
|
|
277
307
|
default: str | None = None
|
|
@@ -285,7 +315,7 @@ class ArgSpec:
|
|
|
285
315
|
class Recipe:
|
|
286
316
|
"""
|
|
287
317
|
Represents a parsed recipe file with all tasks.
|
|
288
|
-
@athena:
|
|
318
|
+
@athena: 5d1881f292cc
|
|
289
319
|
"""
|
|
290
320
|
|
|
291
321
|
tasks: dict[str, Task]
|
|
@@ -294,11 +324,19 @@ class Recipe:
|
|
|
294
324
|
environments: dict[str, Environment] = field(default_factory=dict)
|
|
295
325
|
default_env: str = "" # Name of default environment
|
|
296
326
|
global_env_override: str = "" # Global environment override (set via CLI --env)
|
|
297
|
-
variables: dict[str, str] = field(
|
|
298
|
-
|
|
299
|
-
|
|
327
|
+
variables: dict[str, str] = field(
|
|
328
|
+
default_factory=dict
|
|
329
|
+
) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
|
|
330
|
+
raw_variables: dict[str, Any] = field(
|
|
331
|
+
default_factory=dict
|
|
332
|
+
) # Raw variable specs from YAML (not yet evaluated)
|
|
333
|
+
evaluated_variables: dict[str, str] = field(
|
|
334
|
+
default_factory=dict
|
|
335
|
+
) # Evaluated variable values (cached after evaluation)
|
|
300
336
|
_variables_evaluated: bool = False # Track if variables have been evaluated
|
|
301
|
-
_original_yaml_data: dict[str, Any] = field(
|
|
337
|
+
_original_yaml_data: dict[str, Any] = field(
|
|
338
|
+
default_factory=dict
|
|
339
|
+
) # Store original YAML data for lazy evaluation context
|
|
302
340
|
|
|
303
341
|
def get_task(self, name: str) -> Task | None:
|
|
304
342
|
"""
|
|
@@ -358,7 +396,7 @@ class Recipe:
|
|
|
358
396
|
>>> recipe = parse_recipe(path) # Variables not yet evaluated
|
|
359
397
|
>>> recipe.evaluate_variables("build") # Evaluate only reachable variables
|
|
360
398
|
>>> # Now recipe.evaluated_variables contains only vars used by "build" task
|
|
361
|
-
@athena:
|
|
399
|
+
@athena: 108eb8ae4de1
|
|
362
400
|
"""
|
|
363
401
|
if self._variables_evaluated:
|
|
364
402
|
return # Already evaluated, skip (idempotent)
|
|
@@ -370,7 +408,9 @@ class Recipe:
|
|
|
370
408
|
# (CLI will provide its own "Task not found" error)
|
|
371
409
|
try:
|
|
372
410
|
reachable_tasks = collect_reachable_tasks(self.tasks, root_task)
|
|
373
|
-
variables_to_eval = collect_reachable_variables(
|
|
411
|
+
variables_to_eval = collect_reachable_variables(
|
|
412
|
+
self.tasks, self.environments, reachable_tasks
|
|
413
|
+
)
|
|
374
414
|
except ValueError:
|
|
375
415
|
# Root task not found - fall back to eager evaluation
|
|
376
416
|
# This allows the recipe to be parsed even with invalid task names
|
|
@@ -387,7 +427,7 @@ class Recipe:
|
|
|
387
427
|
self.raw_variables,
|
|
388
428
|
variables_to_eval,
|
|
389
429
|
self.recipe_path,
|
|
390
|
-
self._original_yaml_data
|
|
430
|
+
self._original_yaml_data,
|
|
391
431
|
)
|
|
392
432
|
|
|
393
433
|
# Also update the deprecated 'variables' field for backward compatibility
|
|
@@ -402,18 +442,24 @@ class Recipe:
|
|
|
402
442
|
|
|
403
443
|
task.cmd = substitute_variables(task.cmd, self.evaluated_variables)
|
|
404
444
|
task.desc = substitute_variables(task.desc, self.evaluated_variables)
|
|
405
|
-
task.working_dir = substitute_variables(
|
|
445
|
+
task.working_dir = substitute_variables(
|
|
446
|
+
task.working_dir, self.evaluated_variables
|
|
447
|
+
)
|
|
406
448
|
|
|
407
449
|
# Substitute variables in inputs (handle both string and dict inputs)
|
|
408
450
|
resolved_inputs = []
|
|
409
451
|
for inp in task.inputs:
|
|
410
452
|
if isinstance(inp, str):
|
|
411
|
-
resolved_inputs.append(
|
|
453
|
+
resolved_inputs.append(
|
|
454
|
+
substitute_variables(inp, self.evaluated_variables)
|
|
455
|
+
)
|
|
412
456
|
elif isinstance(inp, dict):
|
|
413
457
|
# Named input: substitute the path value
|
|
414
458
|
resolved_dict = {}
|
|
415
459
|
for name, path in inp.items():
|
|
416
|
-
resolved_dict[name] = substitute_variables(
|
|
460
|
+
resolved_dict[name] = substitute_variables(
|
|
461
|
+
path, self.evaluated_variables
|
|
462
|
+
)
|
|
417
463
|
resolved_inputs.append(resolved_dict)
|
|
418
464
|
else:
|
|
419
465
|
resolved_inputs.append(inp)
|
|
@@ -423,12 +469,16 @@ class Recipe:
|
|
|
423
469
|
resolved_outputs = []
|
|
424
470
|
for out in task.outputs:
|
|
425
471
|
if isinstance(out, str):
|
|
426
|
-
resolved_outputs.append(
|
|
472
|
+
resolved_outputs.append(
|
|
473
|
+
substitute_variables(out, self.evaluated_variables)
|
|
474
|
+
)
|
|
427
475
|
elif isinstance(out, dict):
|
|
428
476
|
# Named output: substitute the path value
|
|
429
477
|
resolved_dict = {}
|
|
430
478
|
for name, path in out.items():
|
|
431
|
-
resolved_dict[name] = substitute_variables(
|
|
479
|
+
resolved_dict[name] = substitute_variables(
|
|
480
|
+
path, self.evaluated_variables
|
|
481
|
+
)
|
|
432
482
|
resolved_outputs.append(resolved_dict)
|
|
433
483
|
else:
|
|
434
484
|
resolved_outputs.append(out)
|
|
@@ -441,7 +491,9 @@ class Recipe:
|
|
|
441
491
|
resolved_args = []
|
|
442
492
|
for arg in task.args:
|
|
443
493
|
if isinstance(arg, str):
|
|
444
|
-
resolved_args.append(
|
|
494
|
+
resolved_args.append(
|
|
495
|
+
substitute_variables(arg, self.evaluated_variables)
|
|
496
|
+
)
|
|
445
497
|
elif isinstance(arg, dict):
|
|
446
498
|
# Dict arg: substitute in nested values (like default values)
|
|
447
499
|
resolved_dict = {}
|
|
@@ -451,11 +503,19 @@ class Recipe:
|
|
|
451
503
|
resolved_spec = {}
|
|
452
504
|
for key, value in arg_spec.items():
|
|
453
505
|
if isinstance(value, str):
|
|
454
|
-
resolved_spec[key] = substitute_variables(
|
|
506
|
+
resolved_spec[key] = substitute_variables(
|
|
507
|
+
value, self.evaluated_variables
|
|
508
|
+
)
|
|
455
509
|
elif isinstance(value, list):
|
|
456
510
|
# Handle lists like 'choices'
|
|
457
511
|
resolved_spec[key] = [
|
|
458
|
-
|
|
512
|
+
(
|
|
513
|
+
substitute_variables(
|
|
514
|
+
v, self.evaluated_variables
|
|
515
|
+
)
|
|
516
|
+
if isinstance(v, str)
|
|
517
|
+
else v
|
|
518
|
+
)
|
|
459
519
|
for v in value
|
|
460
520
|
]
|
|
461
521
|
else:
|
|
@@ -463,7 +523,11 @@ class Recipe:
|
|
|
463
523
|
resolved_dict[arg_name] = resolved_spec
|
|
464
524
|
else:
|
|
465
525
|
# Simple value
|
|
466
|
-
resolved_dict[arg_name] =
|
|
526
|
+
resolved_dict[arg_name] = (
|
|
527
|
+
substitute_variables(arg_spec, self.evaluated_variables)
|
|
528
|
+
if isinstance(arg_spec, str)
|
|
529
|
+
else arg_spec
|
|
530
|
+
)
|
|
467
531
|
resolved_args.append(resolved_dict)
|
|
468
532
|
else:
|
|
469
533
|
resolved_args.append(arg)
|
|
@@ -472,15 +536,23 @@ class Recipe:
|
|
|
472
536
|
# Substitute evaluated variables into all environments
|
|
473
537
|
for env in self.environments.values():
|
|
474
538
|
if env.preamble:
|
|
475
|
-
env.preamble = substitute_variables(
|
|
539
|
+
env.preamble = substitute_variables(
|
|
540
|
+
env.preamble, self.evaluated_variables
|
|
541
|
+
)
|
|
476
542
|
|
|
477
543
|
# Substitute in volumes
|
|
478
544
|
if env.volumes:
|
|
479
|
-
env.volumes = [
|
|
545
|
+
env.volumes = [
|
|
546
|
+
substitute_variables(vol, self.evaluated_variables)
|
|
547
|
+
for vol in env.volumes
|
|
548
|
+
]
|
|
480
549
|
|
|
481
550
|
# Substitute in ports
|
|
482
551
|
if env.ports:
|
|
483
|
-
env.ports = [
|
|
552
|
+
env.ports = [
|
|
553
|
+
substitute_variables(port, self.evaluated_variables)
|
|
554
|
+
for port in env.ports
|
|
555
|
+
]
|
|
484
556
|
|
|
485
557
|
# Substitute in env_vars values
|
|
486
558
|
if env.env_vars:
|
|
@@ -491,7 +563,9 @@ class Recipe:
|
|
|
491
563
|
|
|
492
564
|
# Substitute in working_dir
|
|
493
565
|
if env.working_dir:
|
|
494
|
-
env.working_dir = substitute_variables(
|
|
566
|
+
env.working_dir = substitute_variables(
|
|
567
|
+
env.working_dir, self.evaluated_variables
|
|
568
|
+
)
|
|
495
569
|
|
|
496
570
|
# Substitute in build args (dict for Docker environments)
|
|
497
571
|
if env.args and isinstance(env.args, dict):
|
|
@@ -593,9 +667,9 @@ def _validate_variable_name(name: str) -> None:
|
|
|
593
667
|
|
|
594
668
|
Raises:
|
|
595
669
|
ValueError: If name is not a valid identifier
|
|
596
|
-
@athena:
|
|
670
|
+
@athena: b768b37686da
|
|
597
671
|
"""
|
|
598
|
-
if not re.match(r
|
|
672
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
599
673
|
raise ValueError(
|
|
600
674
|
f"Variable name '{name}' is invalid. Names must start with "
|
|
601
675
|
f"letter/underscore and contain only alphanumerics and underscores."
|
|
@@ -616,12 +690,7 @@ def _infer_variable_type(value: Any) -> str:
|
|
|
616
690
|
ValueError: If value type is not supported
|
|
617
691
|
@athena: 335ae24e1504
|
|
618
692
|
"""
|
|
619
|
-
type_map = {
|
|
620
|
-
str: "str",
|
|
621
|
-
int: "int",
|
|
622
|
-
float: "float",
|
|
623
|
-
bool: "bool"
|
|
624
|
-
}
|
|
693
|
+
type_map = {str: "str", int: "int", float: "float", bool: "bool"}
|
|
625
694
|
python_type = type(value)
|
|
626
695
|
if python_type not in type_map:
|
|
627
696
|
raise ValueError(
|
|
@@ -645,7 +714,9 @@ def _is_env_variable_reference(value: Any) -> bool:
|
|
|
645
714
|
return isinstance(value, dict) and "env" in value
|
|
646
715
|
|
|
647
716
|
|
|
648
|
-
def _validate_env_variable_reference(
|
|
717
|
+
def _validate_env_variable_reference(
|
|
718
|
+
var_name: str, value: dict
|
|
719
|
+
) -> tuple[str, str | None]:
|
|
649
720
|
"""
|
|
650
721
|
Validate and extract environment variable name and optional default from reference.
|
|
651
722
|
|
|
@@ -658,7 +729,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
658
729
|
|
|
659
730
|
Raises:
|
|
660
731
|
ValueError: If reference is invalid
|
|
661
|
-
@athena:
|
|
732
|
+
@athena: 9738cec4b0b4
|
|
662
733
|
"""
|
|
663
734
|
# Validate dict structure - allow 'env' and optionally 'default'
|
|
664
735
|
valid_keys = {"env", "default"}
|
|
@@ -666,7 +737,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
666
737
|
if invalid_keys:
|
|
667
738
|
raise ValueError(
|
|
668
739
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
669
|
-
f
|
|
740
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}\n'
|
|
670
741
|
f"Found invalid keys: {', '.join(invalid_keys)}"
|
|
671
742
|
)
|
|
672
743
|
|
|
@@ -675,7 +746,7 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
675
746
|
raise ValueError(
|
|
676
747
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
677
748
|
f"Missing required 'env' key.\n"
|
|
678
|
-
f
|
|
749
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
|
|
679
750
|
)
|
|
680
751
|
|
|
681
752
|
env_var_name = value["env"]
|
|
@@ -684,12 +755,12 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
684
755
|
if not env_var_name or not isinstance(env_var_name, str):
|
|
685
756
|
raise ValueError(
|
|
686
757
|
f"Invalid environment variable reference in variable '{var_name}'.\n"
|
|
687
|
-
f
|
|
758
|
+
f'Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: "value" }}'
|
|
688
759
|
f"Found: {{ env: {env_var_name!r} }}"
|
|
689
760
|
)
|
|
690
761
|
|
|
691
762
|
# Validate env var name format (allow both uppercase and mixed case for flexibility)
|
|
692
|
-
if not re.match(r
|
|
763
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", env_var_name):
|
|
693
764
|
raise ValueError(
|
|
694
765
|
f"Invalid environment variable name '{env_var_name}' in variable '{var_name}'.\n"
|
|
695
766
|
f"Environment variable names must start with a letter or underscore,\n"
|
|
@@ -705,13 +776,15 @@ def _validate_env_variable_reference(var_name: str, value: dict) -> tuple[str, s
|
|
|
705
776
|
f"Invalid default value in variable '{var_name}'.\n"
|
|
706
777
|
f"Environment variable defaults must be strings.\n"
|
|
707
778
|
f"Got: {default!r} (type: {type(default).__name__})\n"
|
|
708
|
-
f
|
|
779
|
+
f'Use a quoted string: {{ env: {env_var_name}, default: "{default}" }}'
|
|
709
780
|
)
|
|
710
781
|
|
|
711
782
|
return env_var_name, default
|
|
712
783
|
|
|
713
784
|
|
|
714
|
-
def _resolve_env_variable(
|
|
785
|
+
def _resolve_env_variable(
|
|
786
|
+
var_name: str, env_var_name: str, default: str | None = None
|
|
787
|
+
) -> str:
|
|
715
788
|
"""
|
|
716
789
|
Resolve environment variable value.
|
|
717
790
|
|
|
@@ -840,7 +913,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
840
913
|
|
|
841
914
|
Raises:
|
|
842
915
|
ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
|
|
843
|
-
@athena:
|
|
916
|
+
@athena: 211ae0e2493d
|
|
844
917
|
"""
|
|
845
918
|
# Check file exists
|
|
846
919
|
if not resolved_path.exists():
|
|
@@ -859,7 +932,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
859
932
|
|
|
860
933
|
# Read file with UTF-8 error handling
|
|
861
934
|
try:
|
|
862
|
-
content = resolved_path.read_text(encoding=
|
|
935
|
+
content = resolved_path.read_text(encoding="utf-8")
|
|
863
936
|
except PermissionError:
|
|
864
937
|
raise ValueError(
|
|
865
938
|
f"Failed to read file for variable '{var_name}': {filepath}\n"
|
|
@@ -875,7 +948,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
|
|
|
875
948
|
)
|
|
876
949
|
|
|
877
950
|
# Strip single trailing newline if present
|
|
878
|
-
if content.endswith(
|
|
951
|
+
if content.endswith("\n"):
|
|
879
952
|
content = content[:-1]
|
|
880
953
|
|
|
881
954
|
return content
|
|
@@ -939,20 +1012,17 @@ def _get_default_shell_and_args() -> tuple[str, list[str]]:
|
|
|
939
1012
|
|
|
940
1013
|
Returns:
|
|
941
1014
|
Tuple of (shell, args) for platform default
|
|
942
|
-
@athena:
|
|
1015
|
+
@athena: 475863b02b48
|
|
943
1016
|
"""
|
|
944
1017
|
is_windows = platform.system() == "Windows"
|
|
945
1018
|
if is_windows:
|
|
946
|
-
return
|
|
1019
|
+
return "cmd", ["/c"]
|
|
947
1020
|
else:
|
|
948
|
-
return
|
|
1021
|
+
return "bash", ["-c"]
|
|
949
1022
|
|
|
950
1023
|
|
|
951
1024
|
def _resolve_eval_variable(
|
|
952
|
-
var_name: str,
|
|
953
|
-
command: str,
|
|
954
|
-
recipe_file_path: Path,
|
|
955
|
-
recipe_data: dict
|
|
1025
|
+
var_name: str, command: str, recipe_file_path: Path, recipe_data: dict
|
|
956
1026
|
) -> str:
|
|
957
1027
|
"""
|
|
958
1028
|
Execute command and capture output for variable value.
|
|
@@ -968,7 +1038,7 @@ def _resolve_eval_variable(
|
|
|
968
1038
|
|
|
969
1039
|
Raises:
|
|
970
1040
|
ValueError: If command fails or cannot be executed
|
|
971
|
-
@athena:
|
|
1041
|
+
@athena: 0f912a7346fd
|
|
972
1042
|
"""
|
|
973
1043
|
# Determine shell to use
|
|
974
1044
|
shell = None
|
|
@@ -1033,7 +1103,7 @@ def _resolve_eval_variable(
|
|
|
1033
1103
|
output = result.stdout
|
|
1034
1104
|
|
|
1035
1105
|
# Strip single trailing newline if present
|
|
1036
|
-
if output.endswith(
|
|
1106
|
+
if output.endswith("\n"):
|
|
1037
1107
|
output = output[:-1]
|
|
1038
1108
|
|
|
1039
1109
|
return output
|
|
@@ -1045,7 +1115,7 @@ def _resolve_variable_value(
|
|
|
1045
1115
|
resolved: dict[str, str],
|
|
1046
1116
|
resolution_stack: list[str],
|
|
1047
1117
|
file_path: Path,
|
|
1048
|
-
recipe_data: dict | None = None
|
|
1118
|
+
recipe_data: dict | None = None,
|
|
1049
1119
|
) -> str:
|
|
1050
1120
|
"""
|
|
1051
1121
|
Resolve a single variable value with circular reference detection.
|
|
@@ -1063,7 +1133,7 @@ def _resolve_variable_value(
|
|
|
1063
1133
|
|
|
1064
1134
|
Raises:
|
|
1065
1135
|
ValueError: If circular reference detected or validation fails
|
|
1066
|
-
@athena:
|
|
1136
|
+
@athena: 2d87857c4e95
|
|
1067
1137
|
"""
|
|
1068
1138
|
# Check for circular reference
|
|
1069
1139
|
if name in resolution_stack:
|
|
@@ -1083,6 +1153,7 @@ def _resolve_variable_value(
|
|
|
1083
1153
|
|
|
1084
1154
|
# Still perform variable-in-variable substitution
|
|
1085
1155
|
from tasktree.substitution import substitute_variables
|
|
1156
|
+
|
|
1086
1157
|
try:
|
|
1087
1158
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1088
1159
|
except ValueError as e:
|
|
@@ -1094,7 +1165,9 @@ def _resolve_variable_value(
|
|
|
1094
1165
|
undefined_var = match.group(1)
|
|
1095
1166
|
if undefined_var in resolution_stack:
|
|
1096
1167
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1097
|
-
raise ValueError(
|
|
1168
|
+
raise ValueError(
|
|
1169
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1170
|
+
)
|
|
1098
1171
|
# Re-raise the original error if not circular
|
|
1099
1172
|
raise
|
|
1100
1173
|
|
|
@@ -1113,6 +1186,7 @@ def _resolve_variable_value(
|
|
|
1113
1186
|
|
|
1114
1187
|
# Still perform variable-in-variable substitution
|
|
1115
1188
|
from tasktree.substitution import substitute_variables
|
|
1189
|
+
|
|
1116
1190
|
try:
|
|
1117
1191
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1118
1192
|
except ValueError as e:
|
|
@@ -1124,7 +1198,9 @@ def _resolve_variable_value(
|
|
|
1124
1198
|
undefined_var = match.group(1)
|
|
1125
1199
|
if undefined_var in resolution_stack:
|
|
1126
1200
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1127
|
-
raise ValueError(
|
|
1201
|
+
raise ValueError(
|
|
1202
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1203
|
+
)
|
|
1128
1204
|
# Re-raise the original error if not circular
|
|
1129
1205
|
raise
|
|
1130
1206
|
|
|
@@ -1140,6 +1216,7 @@ def _resolve_variable_value(
|
|
|
1140
1216
|
|
|
1141
1217
|
# Still perform variable-in-variable substitution
|
|
1142
1218
|
from tasktree.substitution import substitute_variables
|
|
1219
|
+
|
|
1143
1220
|
try:
|
|
1144
1221
|
resolved_value = substitute_variables(string_value, resolved)
|
|
1145
1222
|
except ValueError as e:
|
|
@@ -1151,7 +1228,9 @@ def _resolve_variable_value(
|
|
|
1151
1228
|
undefined_var = match.group(1)
|
|
1152
1229
|
if undefined_var in resolution_stack:
|
|
1153
1230
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1154
|
-
raise ValueError(
|
|
1231
|
+
raise ValueError(
|
|
1232
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1233
|
+
)
|
|
1155
1234
|
# Re-raise the original error if not circular
|
|
1156
1235
|
raise
|
|
1157
1236
|
|
|
@@ -1160,6 +1239,7 @@ def _resolve_variable_value(
|
|
|
1160
1239
|
# Validate and infer type
|
|
1161
1240
|
type_name = _infer_variable_type(raw_value)
|
|
1162
1241
|
from tasktree.types import get_click_type
|
|
1242
|
+
|
|
1163
1243
|
validator = get_click_type(type_name)
|
|
1164
1244
|
|
|
1165
1245
|
# Validate and stringify the value
|
|
@@ -1173,6 +1253,7 @@ def _resolve_variable_value(
|
|
|
1173
1253
|
|
|
1174
1254
|
# Substitute any {{ var.name }} references in the string value
|
|
1175
1255
|
from tasktree.substitution import substitute_variables
|
|
1256
|
+
|
|
1176
1257
|
try:
|
|
1177
1258
|
resolved_value = substitute_variables(string_value_str, resolved)
|
|
1178
1259
|
except ValueError as e:
|
|
@@ -1185,7 +1266,9 @@ def _resolve_variable_value(
|
|
|
1185
1266
|
undefined_var = match.group(1)
|
|
1186
1267
|
if undefined_var in resolution_stack:
|
|
1187
1268
|
cycle = " -> ".join(resolution_stack + [undefined_var])
|
|
1188
|
-
raise ValueError(
|
|
1269
|
+
raise ValueError(
|
|
1270
|
+
f"Circular reference detected in variables: {cycle}"
|
|
1271
|
+
)
|
|
1189
1272
|
# Re-raise the original error if not circular
|
|
1190
1273
|
raise
|
|
1191
1274
|
|
|
@@ -1232,8 +1315,7 @@ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
|
|
|
1232
1315
|
|
|
1233
1316
|
|
|
1234
1317
|
def _expand_variable_dependencies(
|
|
1235
|
-
variable_names: set[str],
|
|
1236
|
-
raw_variables: dict[str, Any]
|
|
1318
|
+
variable_names: set[str], raw_variables: dict[str, Any]
|
|
1237
1319
|
) -> set[str]:
|
|
1238
1320
|
"""
|
|
1239
1321
|
Expand variable set to include all transitively referenced variables.
|
|
@@ -1256,11 +1338,11 @@ def _expand_variable_dependencies(
|
|
|
1256
1338
|
... }
|
|
1257
1339
|
>>> _expand_variable_dependencies({"a"}, raw_vars)
|
|
1258
1340
|
{"a", "b", "c"}
|
|
1259
|
-
@athena:
|
|
1341
|
+
@athena: c7d55d26a3c2
|
|
1260
1342
|
"""
|
|
1261
1343
|
expanded = set(variable_names)
|
|
1262
1344
|
to_process = list(variable_names)
|
|
1263
|
-
pattern = re.compile(r
|
|
1345
|
+
pattern = re.compile(r"\{\{\s*var\.(\w+)\s*}}")
|
|
1264
1346
|
|
|
1265
1347
|
while to_process:
|
|
1266
1348
|
var_name = to_process.pop(0)
|
|
@@ -1279,8 +1361,8 @@ def _expand_variable_dependencies(
|
|
|
1279
1361
|
expanded.add(referenced_var)
|
|
1280
1362
|
to_process.append(referenced_var)
|
|
1281
1363
|
# Handle { read: filepath } variables - check file contents for variable references
|
|
1282
|
-
elif isinstance(raw_value, dict) and
|
|
1283
|
-
filepath = raw_value[
|
|
1364
|
+
elif isinstance(raw_value, dict) and "read" in raw_value:
|
|
1365
|
+
filepath = raw_value["read"]
|
|
1284
1366
|
# For dependency expansion, we speculatively read files to find variable references
|
|
1285
1367
|
# This is acceptable because file reads are relatively cheap compared to eval commands
|
|
1286
1368
|
try:
|
|
@@ -1288,6 +1370,7 @@ def _expand_variable_dependencies(
|
|
|
1288
1370
|
# Skip if filepath is None or empty (validation error will be caught during evaluation)
|
|
1289
1371
|
if filepath and isinstance(filepath, str):
|
|
1290
1372
|
from pathlib import Path
|
|
1373
|
+
|
|
1291
1374
|
if Path(filepath).exists():
|
|
1292
1375
|
file_content = Path(filepath).read_text()
|
|
1293
1376
|
# Extract variable references from file content
|
|
@@ -1301,8 +1384,12 @@ def _expand_variable_dependencies(
|
|
|
1301
1384
|
# The error will be caught during actual evaluation
|
|
1302
1385
|
pass
|
|
1303
1386
|
# Handle { env: VAR, default: ... } variables - check default value for variable references
|
|
1304
|
-
elif
|
|
1305
|
-
|
|
1387
|
+
elif (
|
|
1388
|
+
isinstance(raw_value, dict)
|
|
1389
|
+
and "env" in raw_value
|
|
1390
|
+
and "default" in raw_value
|
|
1391
|
+
):
|
|
1392
|
+
default_value = raw_value["default"]
|
|
1306
1393
|
# Check if default value contains variable references
|
|
1307
1394
|
if isinstance(default_value, str):
|
|
1308
1395
|
for match in pattern.finditer(default_value):
|
|
@@ -1315,10 +1402,7 @@ def _expand_variable_dependencies(
|
|
|
1315
1402
|
|
|
1316
1403
|
|
|
1317
1404
|
def _evaluate_variable_subset(
|
|
1318
|
-
raw_variables: dict[str, Any],
|
|
1319
|
-
variable_names: set[str],
|
|
1320
|
-
file_path: Path,
|
|
1321
|
-
data: dict
|
|
1405
|
+
raw_variables: dict[str, Any], variable_names: set[str], file_path: Path, data: dict
|
|
1322
1406
|
) -> dict[str, str]:
|
|
1323
1407
|
"""
|
|
1324
1408
|
Evaluate only specified variables from raw specs (for lazy evaluation).
|
|
@@ -1373,7 +1457,9 @@ def _parse_file_with_env(
|
|
|
1373
1457
|
namespace: str | None,
|
|
1374
1458
|
project_root: Path,
|
|
1375
1459
|
import_stack: list[Path] | None = None,
|
|
1376
|
-
) -> tuple[
|
|
1460
|
+
) -> tuple[
|
|
1461
|
+
dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]
|
|
1462
|
+
]:
|
|
1377
1463
|
"""
|
|
1378
1464
|
Parse file and extract tasks, environments, and variables.
|
|
1379
1465
|
|
|
@@ -1384,9 +1470,9 @@ def _parse_file_with_env(
|
|
|
1384
1470
|
import_stack: Stack of files being imported (for circular detection)
|
|
1385
1471
|
|
|
1386
1472
|
Returns:
|
|
1387
|
-
Tuple of (tasks, environments, default_env_name, raw_variables,
|
|
1473
|
+
Tuple of (tasks, environments, default_env_name, raw_variables, YAML_data)
|
|
1388
1474
|
Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
|
|
1389
|
-
@athena:
|
|
1475
|
+
@athena: 8b00183e612d
|
|
1390
1476
|
"""
|
|
1391
1477
|
# Parse tasks normally
|
|
1392
1478
|
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
@@ -1502,7 +1588,7 @@ def _parse_file_with_env(
|
|
|
1502
1588
|
env_vars=env_vars,
|
|
1503
1589
|
working_dir=working_dir,
|
|
1504
1590
|
extra_args=extra_args,
|
|
1505
|
-
run_as_root=run_as_root
|
|
1591
|
+
run_as_root=run_as_root,
|
|
1506
1592
|
)
|
|
1507
1593
|
|
|
1508
1594
|
return tasks, environments, default_env, raw_variables, yaml_data
|
|
@@ -1571,7 +1657,7 @@ def collect_reachable_tasks(tasks: dict[str, Task], root_task: str) -> set[str]:
|
|
|
1571
1657
|
def collect_reachable_variables(
|
|
1572
1658
|
tasks: dict[str, Task],
|
|
1573
1659
|
environments: dict[str, Environment],
|
|
1574
|
-
reachable_task_names: set[str]
|
|
1660
|
+
reachable_task_names: set[str],
|
|
1575
1661
|
) -> set[str]:
|
|
1576
1662
|
"""
|
|
1577
1663
|
Extract variable names used by reachable tasks.
|
|
@@ -1591,12 +1677,12 @@ def collect_reachable_variables(
|
|
|
1591
1677
|
>>> task = Task("build", cmd="echo {{ var.version }}")
|
|
1592
1678
|
>>> collect_reachable_variables({"build": task}, {"build"})
|
|
1593
1679
|
{"version"}
|
|
1594
|
-
@athena:
|
|
1680
|
+
@athena: 84edaecf913a
|
|
1595
1681
|
"""
|
|
1596
1682
|
import re
|
|
1597
1683
|
|
|
1598
1684
|
# Pattern to match {{ var.name }}
|
|
1599
|
-
var_pattern = re.compile(r
|
|
1685
|
+
var_pattern = re.compile(r"\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}")
|
|
1600
1686
|
|
|
1601
1687
|
variables = set()
|
|
1602
1688
|
|
|
@@ -1715,9 +1801,7 @@ def collect_reachable_variables(
|
|
|
1715
1801
|
|
|
1716
1802
|
|
|
1717
1803
|
def parse_recipe(
|
|
1718
|
-
recipe_path: Path,
|
|
1719
|
-
project_root: Path | None = None,
|
|
1720
|
-
root_task: str | None = None
|
|
1804
|
+
recipe_path: Path, project_root: Path | None = None, root_task: str | None = None
|
|
1721
1805
|
) -> Recipe:
|
|
1722
1806
|
"""
|
|
1723
1807
|
Parse a recipe file and handle imports recursively.
|
|
@@ -1742,7 +1826,7 @@ def parse_recipe(
|
|
|
1742
1826
|
CircularImportError: If circular imports are detected
|
|
1743
1827
|
yaml.YAMLError: If YAML is invalid
|
|
1744
1828
|
ValueError: If recipe structure is invalid
|
|
1745
|
-
@athena:
|
|
1829
|
+
@athena: c79c0f326180
|
|
1746
1830
|
"""
|
|
1747
1831
|
if not recipe_path.exists():
|
|
1748
1832
|
raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
|
|
@@ -1768,7 +1852,7 @@ def parse_recipe(
|
|
|
1768
1852
|
raw_variables=raw_variables,
|
|
1769
1853
|
evaluated_variables={}, # Empty initially
|
|
1770
1854
|
_variables_evaluated=False,
|
|
1771
|
-
_original_yaml_data=yaml_data
|
|
1855
|
+
_original_yaml_data=yaml_data,
|
|
1772
1856
|
)
|
|
1773
1857
|
|
|
1774
1858
|
# Trigger lazy variable evaluation
|
|
@@ -1801,7 +1885,7 @@ def _parse_file(
|
|
|
1801
1885
|
CircularImportError: If a circular import is detected
|
|
1802
1886
|
FileNotFoundError: If an imported file doesn't exist
|
|
1803
1887
|
ValueError: If task structure is invalid
|
|
1804
|
-
@athena:
|
|
1888
|
+
@athena: 225864160e55
|
|
1805
1889
|
"""
|
|
1806
1890
|
# Initialize import stack if not provided
|
|
1807
1891
|
if import_stack is None:
|
|
@@ -1823,7 +1907,8 @@ def _parse_file(
|
|
|
1823
1907
|
data = {}
|
|
1824
1908
|
|
|
1825
1909
|
tasks: dict[str, Task] = {}
|
|
1826
|
-
|
|
1910
|
+
# TODO: Understand why this is not used.
|
|
1911
|
+
# file_dir = file_path.parent
|
|
1827
1912
|
|
|
1828
1913
|
# Default working directory is the project root (where tt is invoked)
|
|
1829
1914
|
# NOT the directory where the tasks file is located
|
|
@@ -1843,7 +1928,9 @@ def _parse_file(
|
|
|
1843
1928
|
local_import_namespaces.add(child_namespace)
|
|
1844
1929
|
|
|
1845
1930
|
# Build full namespace chain
|
|
1846
|
-
full_namespace =
|
|
1931
|
+
full_namespace = (
|
|
1932
|
+
f"{namespace}.{child_namespace}" if namespace else child_namespace
|
|
1933
|
+
)
|
|
1847
1934
|
|
|
1848
1935
|
# Resolve import path relative to current file's directory
|
|
1849
1936
|
child_path = file_path.parent / child_file
|
|
@@ -1861,15 +1948,16 @@ def _parse_file(
|
|
|
1861
1948
|
tasks.update(nested_tasks)
|
|
1862
1949
|
|
|
1863
1950
|
# Validate top-level keys (only imports, environments, tasks, and variables are allowed)
|
|
1864
|
-
|
|
1951
|
+
valid_top_level_keys = {"imports", "environments", "tasks", "variables"}
|
|
1865
1952
|
|
|
1866
1953
|
# Check if tasks key is missing when there appear to be task definitions at root
|
|
1867
1954
|
# Do this BEFORE checking for unknown keys, to provide better error message
|
|
1868
1955
|
if "tasks" not in data and data:
|
|
1869
1956
|
# Check if there are potential task definitions at root level
|
|
1870
1957
|
potential_tasks = [
|
|
1871
|
-
k
|
|
1872
|
-
|
|
1958
|
+
k
|
|
1959
|
+
for k, v in data.items()
|
|
1960
|
+
if isinstance(v, dict) and k not in valid_top_level_keys
|
|
1873
1961
|
]
|
|
1874
1962
|
|
|
1875
1963
|
if potential_tasks:
|
|
@@ -1879,13 +1967,13 @@ def _parse_file(
|
|
|
1879
1967
|
f"Found these keys at root level: {', '.join(potential_tasks)}\n\n"
|
|
1880
1968
|
f"Did you mean:\n\n"
|
|
1881
1969
|
f"tasks:\n"
|
|
1882
|
-
+
|
|
1883
|
-
"\n cmd: ...\n\n"
|
|
1884
|
-
f"Valid top-level keys are: {', '.join(sorted(
|
|
1970
|
+
+ "\n".join(f" {k}:" for k in potential_tasks)
|
|
1971
|
+
+ "\n cmd: ...\n\n"
|
|
1972
|
+
f"Valid top-level keys are: {', '.join(sorted(valid_top_level_keys))}"
|
|
1885
1973
|
)
|
|
1886
1974
|
|
|
1887
1975
|
# Now check for other invalid top-level keys (non-dict values)
|
|
1888
|
-
invalid_keys = set(data.keys()) -
|
|
1976
|
+
invalid_keys = set(data.keys()) - valid_top_level_keys
|
|
1889
1977
|
if invalid_keys:
|
|
1890
1978
|
raise ValueError(
|
|
1891
1979
|
f"Invalid recipe format in {file_path}\n\n"
|
|
@@ -1903,10 +1991,14 @@ def _parse_file(
|
|
|
1903
1991
|
|
|
1904
1992
|
# Process local tasks
|
|
1905
1993
|
for task_name, task_data in tasks_data.items():
|
|
1906
|
-
|
|
1907
1994
|
if not isinstance(task_data, dict):
|
|
1908
1995
|
raise ValueError(f"Task '{task_name}' must be a dictionary")
|
|
1909
1996
|
|
|
1997
|
+
if "." in task_name:
|
|
1998
|
+
raise ValueError(
|
|
1999
|
+
f"Task name '{task_name}' cannot contain dots (reserved for namespacing)"
|
|
2000
|
+
)
|
|
2001
|
+
|
|
1910
2002
|
if "cmd" not in task_data:
|
|
1911
2003
|
raise ValueError(f"Task '{task_name}' missing required 'cmd' field")
|
|
1912
2004
|
|
|
@@ -1944,19 +2036,19 @@ def _parse_file(
|
|
|
1944
2036
|
elif isinstance(dep, dict):
|
|
1945
2037
|
# Dict dependency with args - rewrite the task name key
|
|
1946
2038
|
rewritten_dep = {}
|
|
1947
|
-
for
|
|
1948
|
-
if "." not in
|
|
2039
|
+
for t_name, args in dep.items():
|
|
2040
|
+
if "." not in t_name:
|
|
1949
2041
|
# Simple name - prefix it
|
|
1950
|
-
rewritten_dep[f"{namespace}.{
|
|
2042
|
+
rewritten_dep[f"{namespace}.{t_name}"] = args
|
|
1951
2043
|
else:
|
|
1952
2044
|
# Check if it starts with a local import namespace
|
|
1953
|
-
dep_root =
|
|
2045
|
+
dep_root = t_name.split(".", 1)[0]
|
|
1954
2046
|
if dep_root in local_import_namespaces:
|
|
1955
2047
|
# Local import reference - prefix it
|
|
1956
|
-
rewritten_dep[f"{namespace}.{
|
|
2048
|
+
rewritten_dep[f"{namespace}.{t_name}"] = args
|
|
1957
2049
|
else:
|
|
1958
2050
|
# External reference - keep as-is
|
|
1959
|
-
rewritten_dep[
|
|
2051
|
+
rewritten_dep[t_name] = args
|
|
1960
2052
|
rewritten_deps.append(rewritten_dep)
|
|
1961
2053
|
else:
|
|
1962
2054
|
# Unknown type - keep as-is
|
|
@@ -1975,6 +2067,7 @@ def _parse_file(
|
|
|
1975
2067
|
source_file=str(file_path),
|
|
1976
2068
|
env=task_data.get("env", ""),
|
|
1977
2069
|
private=task_data.get("private", False),
|
|
2070
|
+
task_output=task_data.get("task_output", None)
|
|
1978
2071
|
)
|
|
1979
2072
|
|
|
1980
2073
|
# Check for case-sensitive argument collisions
|
|
@@ -2000,7 +2093,7 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
|
|
|
2000
2093
|
Args:
|
|
2001
2094
|
args: List of argument specifications
|
|
2002
2095
|
task_name: Name of the task (for warning message)
|
|
2003
|
-
@athena:
|
|
2096
|
+
@athena: 11ec810aa07b
|
|
2004
2097
|
"""
|
|
2005
2098
|
import sys
|
|
2006
2099
|
|
|
@@ -2023,7 +2116,7 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
|
|
|
2023
2116
|
f"Warning: Task '{task_name}' has exported arguments that differ only in case: "
|
|
2024
2117
|
f"${other_name} and ${name}. "
|
|
2025
2118
|
f"This may be confusing on case-sensitive systems.",
|
|
2026
|
-
file=sys.stderr
|
|
2119
|
+
file=sys.stderr,
|
|
2027
2120
|
)
|
|
2028
2121
|
else:
|
|
2029
2122
|
seen_lower[lower_name] = name
|
|
@@ -2066,7 +2159,7 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
|
2066
2159
|
|
|
2067
2160
|
Raises:
|
|
2068
2161
|
ValueError: If argument specification is invalid
|
|
2069
|
-
@athena:
|
|
2162
|
+
@athena: ef9805c194d7
|
|
2070
2163
|
"""
|
|
2071
2164
|
# Handle dictionary format: { argname: { type: ..., default: ... } }
|
|
2072
2165
|
if isinstance(arg_spec, dict):
|
|
@@ -2124,7 +2217,7 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
|
|
|
2124
2217
|
is_exported=is_exported,
|
|
2125
2218
|
min_val=None,
|
|
2126
2219
|
max_val=None,
|
|
2127
|
-
choices=None
|
|
2220
|
+
choices=None,
|
|
2128
2221
|
)
|
|
2129
2222
|
|
|
2130
2223
|
|
|
@@ -2142,7 +2235,7 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2142
2235
|
|
|
2143
2236
|
Raises:
|
|
2144
2237
|
ValueError: If dictionary format is invalid
|
|
2145
|
-
@athena:
|
|
2238
|
+
@athena: a6020b5b771c
|
|
2146
2239
|
"""
|
|
2147
2240
|
# Validate dictionary keys
|
|
2148
2241
|
valid_keys = {"type", "default", "min", "max", "choices"}
|
|
@@ -2176,22 +2269,18 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2176
2269
|
f"Exported argument '${arg_name}' must have a string default value.\n"
|
|
2177
2270
|
f"Got: {default!r} (type: {type(default).__name__})\n"
|
|
2178
2271
|
f"Exported arguments become environment variables, which are always strings.\n"
|
|
2179
|
-
f
|
|
2272
|
+
f'Use a quoted string: ${arg_name}: {{ default: "{default}" }}'
|
|
2180
2273
|
)
|
|
2181
2274
|
|
|
2182
2275
|
# Validate choices
|
|
2183
2276
|
if choices is not None:
|
|
2184
2277
|
# Validate choices is a list
|
|
2185
2278
|
if not isinstance(choices, list):
|
|
2186
|
-
raise ValueError(
|
|
2187
|
-
f"Argument '{arg_name}': choices must be a list"
|
|
2188
|
-
)
|
|
2279
|
+
raise ValueError(f"Argument '{arg_name}': choices must be a list")
|
|
2189
2280
|
|
|
2190
2281
|
# Validate choices is not empty
|
|
2191
2282
|
if len(choices) == 0:
|
|
2192
|
-
raise ValueError(
|
|
2193
|
-
f"Argument '{arg_name}': choices list cannot be empty"
|
|
2194
|
-
)
|
|
2283
|
+
raise ValueError(f"Argument '{arg_name}': choices list cannot be empty")
|
|
2195
2284
|
|
|
2196
2285
|
# Check for mutual exclusivity with min/max
|
|
2197
2286
|
if min_val is not None or max_val is not None:
|
|
@@ -2220,7 +2309,9 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2220
2309
|
for value_name, value_type in inferred_types[1:]:
|
|
2221
2310
|
if value_type != first_type:
|
|
2222
2311
|
# Build error message showing the conflicting types
|
|
2223
|
-
type_info = ", ".join(
|
|
2312
|
+
type_info = ", ".join(
|
|
2313
|
+
[f"{name}={vtype}" for name, vtype in inferred_types]
|
|
2314
|
+
)
|
|
2224
2315
|
raise ValueError(
|
|
2225
2316
|
f"Argument '{arg_name}': inconsistent types inferred from min, max, and default.\n"
|
|
2226
2317
|
f"All values must have the same type.\n"
|
|
@@ -2244,7 +2335,10 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2244
2335
|
)
|
|
2245
2336
|
|
|
2246
2337
|
# Validate min/max are only used with numeric types
|
|
2247
|
-
if (min_val is not None or max_val is not None) and arg_type not in (
|
|
2338
|
+
if (min_val is not None or max_val is not None) and arg_type not in (
|
|
2339
|
+
"int",
|
|
2340
|
+
"float",
|
|
2341
|
+
):
|
|
2248
2342
|
raise ValueError(
|
|
2249
2343
|
f"Argument '{arg_name}': min/max constraints are only supported for 'int' and 'float' types, "
|
|
2250
2344
|
f"not '{arg_type}'"
|
|
@@ -2373,11 +2467,13 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
|
|
|
2373
2467
|
is_exported=is_exported,
|
|
2374
2468
|
min_val=min_val,
|
|
2375
2469
|
max_val=max_val,
|
|
2376
|
-
choices=choices
|
|
2470
|
+
choices=choices,
|
|
2377
2471
|
)
|
|
2378
2472
|
|
|
2379
2473
|
|
|
2380
|
-
def parse_dependency_spec(
|
|
2474
|
+
def parse_dependency_spec(
|
|
2475
|
+
dep_spec: str | dict[str, Any], recipe: Recipe
|
|
2476
|
+
) -> DependencyInvocation:
|
|
2381
2477
|
"""
|
|
2382
2478
|
Parse a dependency specification into a DependencyInvocation.
|
|
2383
2479
|
|
|
@@ -2505,7 +2601,9 @@ def _parse_positional_dependency_args(
|
|
|
2505
2601
|
spec = parsed_specs[i]
|
|
2506
2602
|
if isinstance(value, str):
|
|
2507
2603
|
# Convert string values using type validator
|
|
2508
|
-
click_type = get_click_type(
|
|
2604
|
+
click_type = get_click_type(
|
|
2605
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2606
|
+
)
|
|
2509
2607
|
args_dict[spec.name] = click_type.convert(value, None, None)
|
|
2510
2608
|
else:
|
|
2511
2609
|
# Value is already typed (e.g., bool, int from YAML)
|
|
@@ -2516,7 +2614,9 @@ def _parse_positional_dependency_args(
|
|
|
2516
2614
|
spec = parsed_specs[i]
|
|
2517
2615
|
if spec.default is not None:
|
|
2518
2616
|
# Defaults in task specs are always strings, convert them
|
|
2519
|
-
click_type = get_click_type(
|
|
2617
|
+
click_type = get_click_type(
|
|
2618
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2619
|
+
)
|
|
2520
2620
|
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
2521
2621
|
else:
|
|
2522
2622
|
raise ValueError(
|
|
@@ -2561,9 +2661,7 @@ def _parse_named_dependency_args(
|
|
|
2561
2661
|
# Validate all provided arg names exist
|
|
2562
2662
|
for arg_name in args_dict:
|
|
2563
2663
|
if arg_name not in spec_map:
|
|
2564
|
-
raise ValueError(
|
|
2565
|
-
f"Task '{task_name}' has no argument named '{arg_name}'"
|
|
2566
|
-
)
|
|
2664
|
+
raise ValueError(f"Task '{task_name}' has no argument named '{arg_name}'")
|
|
2567
2665
|
|
|
2568
2666
|
# Build normalized args dict with defaults
|
|
2569
2667
|
normalized_args = {}
|
|
@@ -2572,14 +2670,18 @@ def _parse_named_dependency_args(
|
|
|
2572
2670
|
# Use provided value with type conversion (only convert strings)
|
|
2573
2671
|
value = args_dict[spec.name]
|
|
2574
2672
|
if isinstance(value, str):
|
|
2575
|
-
click_type = get_click_type(
|
|
2673
|
+
click_type = get_click_type(
|
|
2674
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2675
|
+
)
|
|
2576
2676
|
normalized_args[spec.name] = click_type.convert(value, None, None)
|
|
2577
2677
|
else:
|
|
2578
2678
|
# Value is already typed (e.g., bool, int from YAML)
|
|
2579
2679
|
normalized_args[spec.name] = value
|
|
2580
2680
|
elif spec.default is not None:
|
|
2581
2681
|
# Use default value (defaults are always strings in task specs)
|
|
2582
|
-
click_type = get_click_type(
|
|
2682
|
+
click_type = get_click_type(
|
|
2683
|
+
spec.arg_type, min_val=spec.min_val, max_val=spec.max_val
|
|
2684
|
+
)
|
|
2583
2685
|
normalized_args[spec.name] = click_type.convert(spec.default, None, None)
|
|
2584
2686
|
else:
|
|
2585
2687
|
# Required arg not provided
|