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/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(default_factory=list) # Shell args (list) or Docker build args (dict)
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(default_factory=list) # Any extra arguments to pass to docker
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: f516b5ae61c5
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(default_factory=list) # Can be strings or dicts with args
74
- inputs: list[str | dict[str, str]] = field(default_factory=list) # Can be strings or dicts with named inputs
75
- outputs: list[str | dict[str, str]] = field(default_factory=list) # Can be strings or dicts with named outputs
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(default_factory=list) # Can be strings or dicts (each dict has single key: arg name)
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(init=False, default_factory=dict, repr=False) # name → path mapping
84
- _anonymous_outputs: list[str] = field(init=False, default_factory=list, repr=False) # unnamed outputs
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(init=False, default_factory=dict, repr=False) # name → path mapping
88
- _anonymous_inputs: list[str] = field(init=False, default_factory=list, repr=False) # unnamed inputs
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(init=False, default_factory=list, repr=False) # all inputs in YAML order
92
- _indexed_outputs: list[str] = field(init=False, default_factory=list, repr=False) # all outputs in YAML order
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: a48b1eba81cd
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'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
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'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
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: 47f568c77013
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(default_factory=dict) # Global variables (resolved at parse time) - DEPRECATED, use evaluated_variables
298
- raw_variables: dict[str, Any] = field(default_factory=dict) # Raw variable specs from YAML (not yet evaluated)
299
- evaluated_variables: dict[str, str] = field(default_factory=dict) # Evaluated variable values (cached after evaluation)
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(default_factory=dict) # Store original YAML data for lazy evaluation context
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: d8de7b5f42b6
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(self.tasks, self.environments, reachable_tasks)
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(task.working_dir, self.evaluated_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(substitute_variables(inp, self.evaluated_variables))
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(path, self.evaluated_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(substitute_variables(out, self.evaluated_variables))
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(path, self.evaluated_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(substitute_variables(arg, self.evaluated_variables))
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(value, self.evaluated_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
- substitute_variables(v, self.evaluated_variables) if isinstance(v, str) else v
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] = substitute_variables(arg_spec, self.evaluated_variables) if isinstance(arg_spec, str) else arg_spec
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(env.preamble, self.evaluated_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 = [substitute_variables(vol, self.evaluated_variables) for vol in 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 = [substitute_variables(port, self.evaluated_variables) for port in 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(env.working_dir, self.evaluated_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: 61f92f7ad278
670
+ @athena: b768b37686da
597
671
  """
598
- if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
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(var_name: str, value: dict) -> tuple[str, str | None]:
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: 9fc8b2333b54
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"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}\n"
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"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
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"Expected: {{ env: VARIABLE_NAME }} or {{ env: VARIABLE_NAME, default: \"value\" }}"
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'^[a-zA-Z_][a-zA-Z0-9_]*$', env_var_name):
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"Use a quoted string: {{ env: {env_var_name}, default: \"{default}\" }}"
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(var_name: str, env_var_name: str, default: str | None = None) -> str:
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: cab84337f145
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='utf-8')
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('\n'):
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: 68a19449a035
1015
+ @athena: 475863b02b48
943
1016
  """
944
1017
  is_windows = platform.system() == "Windows"
945
1018
  if is_windows:
946
- return ("cmd", ["/c"])
1019
+ return "cmd", ["/c"]
947
1020
  else:
948
- return ("bash", ["-c"])
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: 647d3a310c77
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('\n'):
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: da94de106756
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(f"Circular reference detected in variables: {cycle}")
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(f"Circular reference detected in variables: {cycle}")
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(f"Circular reference detected in variables: {cycle}")
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(f"Circular reference detected in variables: {cycle}")
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: e7ec4a4a6db3
1341
+ @athena: c7d55d26a3c2
1260
1342
  """
1261
1343
  expanded = set(variable_names)
1262
1344
  to_process = list(variable_names)
1263
- pattern = re.compile(r'\{\{\s*var\.(\w+)\s*\}\}')
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 'read' in raw_value:
1283
- filepath = raw_value['read']
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 isinstance(raw_value, dict) and 'env' in raw_value and 'default' in raw_value:
1305
- default_value = raw_value['default']
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[dict[str, Task], dict[str, Environment], str, dict[str, Any], dict[str, Any]]:
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, yaml_data)
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: b2dced506787
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: fc2a03927486
1680
+ @athena: 84edaecf913a
1595
1681
  """
1596
1682
  import re
1597
1683
 
1598
1684
  # Pattern to match {{ var.name }}
1599
- var_pattern = re.compile(r'\{\{\s*var\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}')
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: 27326e37d5f3
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: f2a1f2c70240
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
- file_dir = file_path.parent
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 = f"{namespace}.{child_namespace}" if namespace else child_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
- VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks", "variables"}
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 for k, v in data.items()
1872
- if isinstance(v, dict) and k not in VALID_TOP_LEVEL_KEYS
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
- + '\n'.join(f" {k}:" for k in potential_tasks) +
1883
- "\n cmd: ...\n\n"
1884
- f"Valid top-level keys are: {', '.join(sorted(VALID_TOP_LEVEL_KEYS))}"
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()) - VALID_TOP_LEVEL_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 task_name, args in dep.items():
1948
- if "." not in task_name:
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}.{task_name}"] = args
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 = task_name.split(".", 1)[0]
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}.{task_name}"] = args
2048
+ rewritten_dep[f"{namespace}.{t_name}"] = args
1957
2049
  else:
1958
2050
  # External reference - keep as-is
1959
- rewritten_dep[task_name] = args
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: a3f0f3b184a8
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: 2a4c7e804622
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: 5b6b93a3612a
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"Use a quoted string: ${arg_name}: {{ default: \"{default}\" }}"
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([f"{name}={vtype}" for name, vtype in inferred_types])
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 ("int", "float"):
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(dep_spec: str | dict[str, Any], recipe: Recipe) -> DependencyInvocation:
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(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
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(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
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(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
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(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
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