tasktree 0.0.7__py3-none-any.whl → 0.0.9__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
@@ -2,12 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import platform
7
+ import re
8
+ import subprocess
5
9
  from dataclasses import dataclass, field
6
10
  from pathlib import Path
7
- from typing import Any
11
+ from typing import Any, List
8
12
 
9
13
  import yaml
10
14
 
15
+ from tasktree.types import get_click_type
16
+
11
17
 
12
18
  class CircularImportError(Exception):
13
19
  """Raised when a circular import is detected."""
@@ -34,6 +40,8 @@ class Environment:
34
40
  ports: list[str] = field(default_factory=list) # Port mappings
35
41
  env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
36
42
  working_dir: str = "" # Working directory (container or host)
43
+ extra_args: List[str] = field(default_factory=list) # Any extra arguments to pass to docker
44
+ run_as_root: bool = False # If True, skip user mapping (run as root in container)
37
45
 
38
46
  def __post_init__(self):
39
47
  """Ensure args is always a list."""
@@ -48,7 +56,7 @@ class Task:
48
56
  name: str
49
57
  cmd: str
50
58
  desc: str = ""
51
- deps: list[str] = field(default_factory=list)
59
+ deps: list[str | dict[str, Any]] = field(default_factory=list) # Can be strings or dicts with args
52
60
  inputs: list[str] = field(default_factory=list)
53
61
  outputs: list[str] = field(default_factory=list)
54
62
  working_dir: str = ""
@@ -68,15 +76,58 @@ class Task:
68
76
  self.args = [self.args]
69
77
 
70
78
 
79
+ @dataclass
80
+ class DependencyInvocation:
81
+ """Represents a task dependency invocation with optional arguments.
82
+
83
+ Attributes:
84
+ task_name: Name of the dependency task
85
+ args: Dictionary of argument names to values (None if no args specified)
86
+ """
87
+ task_name: str
88
+ args: dict[str, Any] | None = None
89
+
90
+ def __str__(self) -> str:
91
+ """String representation for display."""
92
+ if not self.args:
93
+ return self.task_name
94
+ args_str = ", ".join(f"{k}={v}" for k, v in self.args.items())
95
+ return f"{self.task_name}({args_str})"
96
+
97
+
98
+ @dataclass
99
+ class ArgSpec:
100
+ """Represents a parsed argument specification.
101
+
102
+ Attributes:
103
+ name: Argument name
104
+ arg_type: Type of the argument (str, int, float, bool, path)
105
+ default: Default value as a string (None if no default)
106
+ is_exported: Whether the argument is exported as an environment variable
107
+ min_val: Minimum value for numeric arguments (None if not specified)
108
+ max_val: Maximum value for numeric arguments (None if not specified)
109
+ choices: List of valid choices for the argument (None if not specified)
110
+ """
111
+ name: str
112
+ arg_type: str
113
+ default: str | None = None
114
+ is_exported: bool = False
115
+ min_val: int | float | None = None
116
+ max_val: int | float | None = None
117
+ choices: list[Any] | None = None
118
+
119
+
71
120
  @dataclass
72
121
  class Recipe:
73
122
  """Represents a parsed recipe file with all tasks."""
74
123
 
75
124
  tasks: dict[str, Task]
76
125
  project_root: Path
126
+ recipe_path: Path # Path to the recipe file
77
127
  environments: dict[str, Environment] = field(default_factory=dict)
78
128
  default_env: str = "" # Name of default environment
79
129
  global_env_override: str = "" # Global environment override (set via CLI --env)
130
+ variables: dict[str, str] = field(default_factory=dict) # Global variables (resolved at parse time)
80
131
 
81
132
  def get_task(self, name: str) -> Task | None:
82
133
  """Get task by name.
@@ -168,13 +219,609 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
168
219
  return None
169
220
 
170
221
 
222
+ def _validate_variable_name(name: str) -> None:
223
+ """Validate that a variable name is a valid identifier.
224
+
225
+ Args:
226
+ name: Variable name to validate
227
+
228
+ Raises:
229
+ ValueError: If name is not a valid identifier
230
+ """
231
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
232
+ raise ValueError(
233
+ f"Variable name '{name}' is invalid. Names must start with "
234
+ f"letter/underscore and contain only alphanumerics and underscores."
235
+ )
236
+
237
+
238
+ def _infer_variable_type(value: Any) -> str:
239
+ """Infer type name from Python value.
240
+
241
+ Args:
242
+ value: Python value from YAML
243
+
244
+ Returns:
245
+ Type name string (str, int, float, bool)
246
+
247
+ Raises:
248
+ ValueError: If value type is not supported
249
+ """
250
+ type_map = {
251
+ str: "str",
252
+ int: "int",
253
+ float: "float",
254
+ bool: "bool"
255
+ }
256
+ python_type = type(value)
257
+ if python_type not in type_map:
258
+ raise ValueError(
259
+ f"Variable has unsupported type '{python_type.__name__}'. "
260
+ f"Supported types: str, int, float, bool, path, datetime, ip, ipv4, ipv6, email, hostname"
261
+ )
262
+ return type_map[python_type]
263
+
264
+
265
+ def _is_env_variable_reference(value: Any) -> bool:
266
+ """Check if value is an environment variable reference.
267
+
268
+ Args:
269
+ value: Raw value from YAML
270
+
271
+ Returns:
272
+ True if value is { env: VAR_NAME } dict
273
+ """
274
+ return isinstance(value, dict) and "env" in value
275
+
276
+
277
+ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
278
+ """Validate and extract environment variable name from reference.
279
+
280
+ Args:
281
+ var_name: Name of the variable being defined
282
+ value: Dict that should be { env: ENV_VAR_NAME }
283
+
284
+ Returns:
285
+ Environment variable name
286
+
287
+ Raises:
288
+ ValueError: If reference is invalid
289
+ """
290
+ # Validate dict structure
291
+ if len(value) != 1:
292
+ extra_keys = [k for k in value.keys() if k != "env"]
293
+ raise ValueError(
294
+ f"Invalid environment variable reference in variable '{var_name}'.\n"
295
+ f"Expected: {{ env: VARIABLE_NAME }}\n"
296
+ f"Found extra keys: {', '.join(extra_keys)}"
297
+ )
298
+
299
+ env_var_name = value["env"]
300
+
301
+ # Validate env var name is provided
302
+ if not env_var_name or not isinstance(env_var_name, str):
303
+ raise ValueError(
304
+ f"Invalid environment variable reference in variable '{var_name}'.\n"
305
+ f"Expected: {{ env: VARIABLE_NAME }}\n"
306
+ f"Found: {{ env: {env_var_name!r} }}"
307
+ )
308
+
309
+ # Validate env var name format (allow both uppercase and mixed case for flexibility)
310
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', env_var_name):
311
+ raise ValueError(
312
+ f"Invalid environment variable name '{env_var_name}' in variable '{var_name}'.\n"
313
+ f"Environment variable names must start with a letter or underscore,\n"
314
+ f"and contain only alphanumerics and underscores."
315
+ )
316
+
317
+ return env_var_name
318
+
319
+
320
+ def _resolve_env_variable(var_name: str, env_var_name: str) -> str:
321
+ """Resolve environment variable value.
322
+
323
+ Args:
324
+ var_name: Name of the variable being defined
325
+ env_var_name: Name of environment variable to read
326
+
327
+ Returns:
328
+ Environment variable value as string
329
+
330
+ Raises:
331
+ ValueError: If environment variable is not set
332
+ """
333
+ value = os.environ.get(env_var_name)
334
+
335
+ if value is None:
336
+ raise ValueError(
337
+ f"Environment variable '{env_var_name}' (referenced by variable '{var_name}') is not set.\n\n"
338
+ f"Hint: Set it before running tt:\n"
339
+ f" {env_var_name}=value tt task\n\n"
340
+ f"Or export it in your shell:\n"
341
+ f" export {env_var_name}=value\n"
342
+ f" tt task"
343
+ )
344
+
345
+ return value
346
+
347
+
348
+ def _is_file_read_reference(value: Any) -> bool:
349
+ """Check if value is a file read reference.
350
+
351
+ Args:
352
+ value: Raw value from YAML
353
+
354
+ Returns:
355
+ True if value is { read: filepath } dict
356
+ """
357
+ return isinstance(value, dict) and "read" in value
358
+
359
+
360
+ def _validate_file_read_reference(var_name: str, value: dict) -> str:
361
+ """Validate and extract filepath from file read reference.
362
+
363
+ Args:
364
+ var_name: Name of the variable being defined
365
+ value: Dict that should be { read: filepath }
366
+
367
+ Returns:
368
+ Filepath string
369
+
370
+ Raises:
371
+ ValueError: If reference is invalid
372
+ """
373
+ # Validate dict structure (only "read" key allowed)
374
+ if len(value) != 1:
375
+ extra_keys = [k for k in value.keys() if k != "read"]
376
+ raise ValueError(
377
+ f"Invalid file read reference in variable '{var_name}'.\n"
378
+ f"Expected: {{ read: filepath }}\n"
379
+ f"Found extra keys: {', '.join(extra_keys)}"
380
+ )
381
+
382
+ filepath = value["read"]
383
+
384
+ # Validate filepath is provided and is a string
385
+ if not filepath or not isinstance(filepath, str):
386
+ raise ValueError(
387
+ f"Invalid file read reference in variable '{var_name}'.\n"
388
+ f"Expected: {{ read: filepath }}\n"
389
+ f"Found: {{ read: {filepath!r} }}\n\n"
390
+ f"Filepath must be a non-empty string."
391
+ )
392
+
393
+ return filepath
394
+
395
+
396
+ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
397
+ """Resolve file path relative to recipe file location.
398
+
399
+ Handles three path types:
400
+ 1. Tilde paths (~): Expand to user home directory
401
+ 2. Absolute paths: Use as-is
402
+ 3. Relative paths: Resolve relative to recipe file's directory
403
+
404
+ Args:
405
+ filepath: Path string from YAML (may be relative, absolute, or tilde)
406
+ recipe_file_path: Path to the recipe file containing the variable
407
+
408
+ Returns:
409
+ Resolved absolute Path object
410
+ """
411
+ # Expand tilde to home directory
412
+ if filepath.startswith("~"):
413
+ return Path(os.path.expanduser(filepath))
414
+
415
+ # Convert to Path for is_absolute check
416
+ path_obj = Path(filepath)
417
+
418
+ # Absolute paths used as-is
419
+ if path_obj.is_absolute():
420
+ return path_obj
421
+
422
+ # Relative paths resolved from recipe file's directory
423
+ return recipe_file_path.parent / filepath
424
+
425
+
426
+ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) -> str:
427
+ """Read file contents for variable value.
428
+
429
+ Args:
430
+ var_name: Name of the variable being defined
431
+ filepath: Original filepath string (for error messages)
432
+ resolved_path: Resolved absolute path to the file
433
+
434
+ Returns:
435
+ File contents as string (with trailing newline stripped)
436
+
437
+ Raises:
438
+ ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
439
+ """
440
+ # Check file exists
441
+ if not resolved_path.exists():
442
+ raise ValueError(
443
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
444
+ f"File not found: {resolved_path}\n\n"
445
+ f"Note: Relative paths are resolved from the recipe file location."
446
+ )
447
+
448
+ # Check it's a file (not directory)
449
+ if not resolved_path.is_file():
450
+ raise ValueError(
451
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
452
+ f"Path is not a file: {resolved_path}"
453
+ )
454
+
455
+ # Read file with UTF-8 error handling
456
+ try:
457
+ content = resolved_path.read_text(encoding='utf-8')
458
+ except PermissionError:
459
+ raise ValueError(
460
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
461
+ f"Permission denied: {resolved_path}\n\n"
462
+ f"Ensure the file is readable by the current user."
463
+ )
464
+ except UnicodeDecodeError as e:
465
+ raise ValueError(
466
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
467
+ f"File contains invalid UTF-8 data: {resolved_path}\n\n"
468
+ f"The {{ read: ... }} syntax only supports text files.\n"
469
+ f"Error: {e}"
470
+ )
471
+
472
+ # Strip single trailing newline if present
473
+ if content.endswith('\n'):
474
+ content = content[:-1]
475
+
476
+ return content
477
+
478
+
479
+ def _is_eval_reference(value: Any) -> bool:
480
+ """Check if value is an eval command reference.
481
+
482
+ Args:
483
+ value: Raw value from YAML
484
+
485
+ Returns:
486
+ True if value is { eval: command } dict
487
+ """
488
+ return isinstance(value, dict) and "eval" in value
489
+
490
+
491
+ def _validate_eval_reference(var_name: str, value: dict) -> str:
492
+ """Validate and extract command from eval reference.
493
+
494
+ Args:
495
+ var_name: Name of the variable being defined
496
+ value: Dict that should be { eval: command }
497
+
498
+ Returns:
499
+ Command string
500
+
501
+ Raises:
502
+ ValueError: If reference is invalid
503
+ """
504
+ # Validate dict structure (only "eval" key allowed)
505
+ if len(value) != 1:
506
+ extra_keys = [k for k in value.keys() if k != "eval"]
507
+ raise ValueError(
508
+ f"Invalid eval reference in variable '{var_name}'.\n"
509
+ f"Expected: {{ eval: command }}\n"
510
+ f"Found extra keys: {', '.join(extra_keys)}"
511
+ )
512
+
513
+ command = value["eval"]
514
+
515
+ # Validate command is provided and is a string
516
+ if not command or not isinstance(command, str):
517
+ raise ValueError(
518
+ f"Invalid eval reference in variable '{var_name}'.\n"
519
+ f"Expected: {{ eval: command }}\n"
520
+ f"Found: {{ eval: {command!r} }}\n\n"
521
+ f"Command must be a non-empty string."
522
+ )
523
+
524
+ return command
525
+
526
+
527
+ def _get_default_shell_and_args() -> tuple[str, list[str]]:
528
+ """Get default shell and args for current platform.
529
+
530
+ Returns:
531
+ Tuple of (shell, args) for platform default
532
+ """
533
+ is_windows = platform.system() == "Windows"
534
+ if is_windows:
535
+ return ("cmd", ["/c"])
536
+ else:
537
+ return ("bash", ["-c"])
538
+
539
+
540
+ def _resolve_eval_variable(
541
+ var_name: str,
542
+ command: str,
543
+ recipe_file_path: Path,
544
+ recipe_data: dict
545
+ ) -> str:
546
+ """Execute command and capture output for variable value.
547
+
548
+ Args:
549
+ var_name: Name of the variable being defined
550
+ command: Command to execute
551
+ recipe_file_path: Path to recipe file (for working directory)
552
+ recipe_data: Parsed YAML data (for accessing default_env)
553
+
554
+ Returns:
555
+ Command stdout as string (with trailing newline stripped)
556
+
557
+ Raises:
558
+ ValueError: If command fails or cannot be executed
559
+ """
560
+ # Determine shell to use
561
+ shell = None
562
+ shell_args = []
563
+
564
+ # Check if recipe has default_env specified
565
+ if recipe_data and "environments" in recipe_data:
566
+ env_data = recipe_data["environments"]
567
+ if isinstance(env_data, dict):
568
+ default_env_name = env_data.get("default", "")
569
+ if default_env_name and default_env_name in env_data:
570
+ env_config = env_data[default_env_name]
571
+ if isinstance(env_config, dict):
572
+ shell = env_config.get("shell", "")
573
+ shell_args = env_config.get("args", [])
574
+ if isinstance(shell_args, str):
575
+ shell_args = [shell_args]
576
+
577
+ # Use platform default if no environment specified or not found
578
+ if not shell:
579
+ shell, shell_args = _get_default_shell_and_args()
580
+
581
+ # Build command list
582
+ cmd_list = [shell] + shell_args + [command]
583
+
584
+ # Execute from recipe file directory
585
+ working_dir = recipe_file_path.parent
586
+
587
+ try:
588
+ result = subprocess.run(
589
+ cmd_list,
590
+ capture_output=True,
591
+ text=True,
592
+ cwd=working_dir,
593
+ check=False,
594
+ )
595
+ except FileNotFoundError:
596
+ raise ValueError(
597
+ f"Failed to execute command for variable '{var_name}'.\n"
598
+ f"Shell not found: {shell}\n\n"
599
+ f"Command: {command}\n\n"
600
+ f"Ensure the shell is installed and available in PATH."
601
+ )
602
+ except Exception as e:
603
+ raise ValueError(
604
+ f"Failed to execute command for variable '{var_name}'.\n"
605
+ f"Command: {command}\n"
606
+ f"Error: {e}"
607
+ )
608
+
609
+ # Check exit code
610
+ if result.returncode != 0:
611
+ stderr_output = result.stderr.strip() if result.stderr else "(no stderr output)"
612
+ raise ValueError(
613
+ f"Command failed for variable '{var_name}': {command}\n"
614
+ f"Exit code: {result.returncode}\n"
615
+ f"stderr: {stderr_output}\n\n"
616
+ f"Ensure the command succeeds when run from the recipe file location."
617
+ )
618
+
619
+ # Get stdout and strip trailing newline
620
+ output = result.stdout
621
+
622
+ # Strip single trailing newline if present
623
+ if output.endswith('\n'):
624
+ output = output[:-1]
625
+
626
+ return output
627
+
628
+
629
+ def _resolve_variable_value(
630
+ name: str,
631
+ raw_value: Any,
632
+ resolved: dict[str, str],
633
+ resolution_stack: list[str],
634
+ file_path: Path,
635
+ recipe_data: dict | None = None
636
+ ) -> str:
637
+ """Resolve a single variable value with circular reference detection.
638
+
639
+ Args:
640
+ name: Variable name being resolved
641
+ raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
642
+ resolved: Dictionary of already-resolved variables
643
+ resolution_stack: Stack of variables currently being resolved (for circular detection)
644
+ file_path: Path to recipe file (for resolving relative file paths in { read: ... })
645
+ recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
646
+
647
+ Returns:
648
+ Resolved string value
649
+
650
+ Raises:
651
+ ValueError: If circular reference detected or validation fails
652
+ """
653
+ # Check for circular reference
654
+ if name in resolution_stack:
655
+ cycle = " -> ".join(resolution_stack + [name])
656
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
657
+
658
+ resolution_stack.append(name)
659
+
660
+ try:
661
+ # Check if this is an eval reference
662
+ if _is_eval_reference(raw_value):
663
+ # Validate and extract command
664
+ command = _validate_eval_reference(name, raw_value)
665
+
666
+ # Execute command and capture output
667
+ string_value = _resolve_eval_variable(name, command, file_path, recipe_data)
668
+
669
+ # Still perform variable-in-variable substitution
670
+ from tasktree.substitution import substitute_variables
671
+ try:
672
+ resolved_value = substitute_variables(string_value, resolved)
673
+ except ValueError as e:
674
+ # Check if the undefined variable is in the resolution stack (circular reference)
675
+ error_msg = str(e)
676
+ if "not defined" in error_msg:
677
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
678
+ if match:
679
+ undefined_var = match.group(1)
680
+ if undefined_var in resolution_stack:
681
+ cycle = " -> ".join(resolution_stack + [undefined_var])
682
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
683
+ # Re-raise the original error if not circular
684
+ raise
685
+
686
+ return resolved_value
687
+
688
+ # Check if this is a file read reference
689
+ if _is_file_read_reference(raw_value):
690
+ # Validate and extract filepath
691
+ filepath = _validate_file_read_reference(name, raw_value)
692
+
693
+ # Resolve path (handles tilde, absolute, relative)
694
+ resolved_path = _resolve_file_path(filepath, file_path)
695
+
696
+ # Read file contents
697
+ string_value = _resolve_file_variable(name, filepath, resolved_path)
698
+
699
+ # Still perform variable-in-variable substitution
700
+ from tasktree.substitution import substitute_variables
701
+ try:
702
+ resolved_value = substitute_variables(string_value, resolved)
703
+ except ValueError as e:
704
+ # Check if the undefined variable is in the resolution stack (circular reference)
705
+ error_msg = str(e)
706
+ if "not defined" in error_msg:
707
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
708
+ if match:
709
+ undefined_var = match.group(1)
710
+ if undefined_var in resolution_stack:
711
+ cycle = " -> ".join(resolution_stack + [undefined_var])
712
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
713
+ # Re-raise the original error if not circular
714
+ raise
715
+
716
+ return resolved_value
717
+
718
+ # Check if this is an environment variable reference
719
+ if _is_env_variable_reference(raw_value):
720
+ # Validate and extract env var name
721
+ env_var_name = _validate_env_variable_reference(name, raw_value)
722
+
723
+ # Resolve from os.environ
724
+ string_value = _resolve_env_variable(name, env_var_name)
725
+
726
+ # Still perform variable-in-variable substitution
727
+ from tasktree.substitution import substitute_variables
728
+ try:
729
+ resolved_value = substitute_variables(string_value, resolved)
730
+ except ValueError as e:
731
+ # Check if the undefined variable is in the resolution stack (circular reference)
732
+ error_msg = str(e)
733
+ if "not defined" in error_msg:
734
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
735
+ if match:
736
+ undefined_var = match.group(1)
737
+ if undefined_var in resolution_stack:
738
+ cycle = " -> ".join(resolution_stack + [undefined_var])
739
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
740
+ # Re-raise the original error if not circular
741
+ raise
742
+
743
+ return resolved_value
744
+
745
+ # Validate and infer type
746
+ type_name = _infer_variable_type(raw_value)
747
+ from tasktree.types import get_click_type
748
+ validator = get_click_type(type_name)
749
+
750
+ # Validate and stringify the value
751
+ string_value = validator.convert(raw_value, None, None)
752
+
753
+ # Convert to string (lowercase for booleans to match YAML/shell conventions)
754
+ if isinstance(string_value, bool):
755
+ string_value_str = str(string_value).lower()
756
+ else:
757
+ string_value_str = str(string_value)
758
+
759
+ # Substitute any {{ var.name }} references in the string value
760
+ from tasktree.substitution import substitute_variables
761
+ try:
762
+ resolved_value = substitute_variables(string_value_str, resolved)
763
+ except ValueError as e:
764
+ # Check if the undefined variable is in the resolution stack (circular reference)
765
+ error_msg = str(e)
766
+ if "not defined" in error_msg:
767
+ # Extract the variable name from the error message
768
+ import re
769
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
770
+ if match:
771
+ undefined_var = match.group(1)
772
+ if undefined_var in resolution_stack:
773
+ cycle = " -> ".join(resolution_stack + [undefined_var])
774
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
775
+ # Re-raise the original error if not circular
776
+ raise
777
+
778
+ return resolved_value
779
+ finally:
780
+ resolution_stack.pop()
781
+
782
+
783
+ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
784
+ """Parse and resolve the variables section from YAML data.
785
+
786
+ Variables are resolved in order, allowing variables to reference
787
+ previously-defined variables using {{ var.name }} syntax.
788
+
789
+ Args:
790
+ data: Parsed YAML data (root level)
791
+ file_path: Path to the recipe file (for resolving relative file paths)
792
+
793
+ Returns:
794
+ Dictionary mapping variable names to resolved string values
795
+
796
+ Raises:
797
+ ValueError: For validation errors, undefined refs, or circular refs
798
+ """
799
+ if "variables" not in data:
800
+ return {}
801
+
802
+ vars_data = data["variables"]
803
+ if not isinstance(vars_data, dict):
804
+ raise ValueError("'variables' must be a dictionary")
805
+
806
+ resolved = {} # name -> resolved string value
807
+ resolution_stack = [] # For circular detection
808
+
809
+ for var_name, raw_value in vars_data.items():
810
+ _validate_variable_name(var_name)
811
+ resolved[var_name] = _resolve_variable_value(
812
+ var_name, raw_value, resolved, resolution_stack, file_path, data
813
+ )
814
+
815
+ return resolved
816
+
817
+
171
818
  def _parse_file_with_env(
172
819
  file_path: Path,
173
820
  namespace: str | None,
174
821
  project_root: Path,
175
822
  import_stack: list[Path] | None = None,
176
- ) -> tuple[dict[str, Task], dict[str, Environment], str]:
177
- """Parse file and extract tasks and environments.
823
+ ) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, str]]:
824
+ """Parse file and extract tasks, environments, and variables.
178
825
 
179
826
  Args:
180
827
  file_path: Path to YAML file
@@ -183,20 +830,38 @@ def _parse_file_with_env(
183
830
  import_stack: Stack of files being imported (for circular detection)
184
831
 
185
832
  Returns:
186
- Tuple of (tasks, environments, default_env_name)
833
+ Tuple of (tasks, environments, default_env_name, variables)
187
834
  """
188
835
  # Parse tasks normally
189
836
  tasks = _parse_file(file_path, namespace, project_root, import_stack)
190
837
 
191
- # Load YAML again to extract environments (only from root file)
838
+ # Load YAML again to extract environments and variables (only from root file)
192
839
  environments: dict[str, Environment] = {}
193
840
  default_env = ""
841
+ variables: dict[str, str] = {}
194
842
 
195
- # Only parse environments from the root file (namespace is None)
843
+ # Only parse environments and variables from the root file (namespace is None)
196
844
  if namespace is None:
197
845
  with open(file_path, "r") as f:
198
846
  data = yaml.safe_load(f)
199
847
 
848
+ # Parse variables first (so they can be used in environment preambles and tasks)
849
+ if data:
850
+ variables = _parse_variables_section(data, file_path)
851
+
852
+ # Apply variable substitution to all tasks
853
+ if variables:
854
+ from tasktree.substitution import substitute_variables
855
+
856
+ for task in tasks.values():
857
+ task.cmd = substitute_variables(task.cmd, variables)
858
+ task.desc = substitute_variables(task.desc, variables)
859
+ task.working_dir = substitute_variables(task.working_dir, variables)
860
+ task.inputs = [substitute_variables(inp, variables) for inp in task.inputs]
861
+ task.outputs = [substitute_variables(out, variables) for out in task.outputs]
862
+ # Substitute in argument default values (in arg spec strings)
863
+ task.args = [substitute_variables(arg, variables) for arg in task.args]
864
+
200
865
  if data and "environments" in data:
201
866
  env_data = data["environments"]
202
867
  if isinstance(env_data, dict):
@@ -219,12 +884,19 @@ def _parse_file_with_env(
219
884
  preamble = env_config.get("preamble", "")
220
885
  working_dir = env_config.get("working_dir", "")
221
886
 
887
+ # Substitute variables in preamble
888
+ if preamble and variables:
889
+ from tasktree.substitution import substitute_variables
890
+ preamble = substitute_variables(preamble, variables)
891
+
222
892
  # Parse Docker-specific fields
223
893
  dockerfile = env_config.get("dockerfile", "")
224
894
  context = env_config.get("context", "")
225
895
  volumes = env_config.get("volumes", [])
226
896
  ports = env_config.get("ports", [])
227
897
  env_vars = env_config.get("env_vars", {})
898
+ extra_args = env_config.get("extra_args", [])
899
+ run_as_root = env_config.get("run_as_root", False)
228
900
 
229
901
  # Validate environment type
230
902
  if not shell and not dockerfile:
@@ -278,9 +950,11 @@ def _parse_file_with_env(
278
950
  ports=ports,
279
951
  env_vars=env_vars,
280
952
  working_dir=working_dir,
953
+ extra_args=extra_args,
954
+ run_as_root=run_as_root
281
955
  )
282
956
 
283
- return tasks, environments, default_env
957
+ return tasks, environments, default_env, variables
284
958
 
285
959
 
286
960
  def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
@@ -308,15 +982,17 @@ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
308
982
  project_root = recipe_path.parent
309
983
 
310
984
  # Parse main file - it will recursively handle all imports
311
- tasks, environments, default_env = _parse_file_with_env(
985
+ tasks, environments, default_env, variables = _parse_file_with_env(
312
986
  recipe_path, namespace=None, project_root=project_root
313
987
  )
314
988
 
315
989
  return Recipe(
316
990
  tasks=tasks,
317
991
  project_root=project_root,
992
+ recipe_path=recipe_path,
318
993
  environments=environments,
319
994
  default_env=default_env,
995
+ variables=variables,
320
996
  )
321
997
 
322
998
 
@@ -399,8 +1075,8 @@ def _parse_file(
399
1075
 
400
1076
  tasks.update(nested_tasks)
401
1077
 
402
- # Validate top-level keys (only imports, environments, and tasks are allowed)
403
- VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks"}
1078
+ # Validate top-level keys (only imports, environments, tasks, and variables are allowed)
1079
+ VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks", "variables"}
404
1080
 
405
1081
  # Check if tasks key is missing when there appear to be task definitions at root
406
1082
  # Do this BEFORE checking for unknown keys, to provide better error message
@@ -466,18 +1142,40 @@ def _parse_file(
466
1142
  # 2. It starts with a local import namespace (like "base.setup" when "base" is imported)
467
1143
  rewritten_deps = []
468
1144
  for dep in deps:
469
- if "." not in dep:
470
- # Simple name - always prefix
471
- rewritten_deps.append(f"{namespace}.{dep}")
472
- else:
473
- # Check if it starts with a local import namespace
474
- dep_root = dep.split(".", 1)[0]
475
- if dep_root in local_import_namespaces:
476
- # Local import reference - prefix it
1145
+ if isinstance(dep, str):
1146
+ # Simple string dependency
1147
+ if "." not in dep:
1148
+ # Simple name - always prefix
477
1149
  rewritten_deps.append(f"{namespace}.{dep}")
478
1150
  else:
479
- # External reference - keep as-is
480
- rewritten_deps.append(dep)
1151
+ # Check if it starts with a local import namespace
1152
+ dep_root = dep.split(".", 1)[0]
1153
+ if dep_root in local_import_namespaces:
1154
+ # Local import reference - prefix it
1155
+ rewritten_deps.append(f"{namespace}.{dep}")
1156
+ else:
1157
+ # External reference - keep as-is
1158
+ rewritten_deps.append(dep)
1159
+ elif isinstance(dep, dict):
1160
+ # Dict dependency with args - rewrite the task name key
1161
+ rewritten_dep = {}
1162
+ for task_name, args in dep.items():
1163
+ if "." not in task_name:
1164
+ # Simple name - prefix it
1165
+ rewritten_dep[f"{namespace}.{task_name}"] = args
1166
+ else:
1167
+ # Check if it starts with a local import namespace
1168
+ dep_root = task_name.split(".", 1)[0]
1169
+ if dep_root in local_import_namespaces:
1170
+ # Local import reference - prefix it
1171
+ rewritten_dep[f"{namespace}.{task_name}"] = args
1172
+ else:
1173
+ # External reference - keep as-is
1174
+ rewritten_dep[task_name] = args
1175
+ rewritten_deps.append(rewritten_dep)
1176
+ else:
1177
+ # Unknown type - keep as-is
1178
+ rewritten_deps.append(dep)
481
1179
  deps = rewritten_deps
482
1180
 
483
1181
  task = Task(
@@ -493,6 +1191,10 @@ def _parse_file(
493
1191
  env=task_data.get("env", ""),
494
1192
  )
495
1193
 
1194
+ # Check for case-sensitive argument collisions
1195
+ if task.args:
1196
+ _check_case_sensitive_arg_collisions(task.args, full_name)
1197
+
496
1198
  tasks[full_name] = task
497
1199
 
498
1200
  # Remove current file from stack
@@ -501,28 +1203,118 @@ def _parse_file(
501
1203
  return tasks
502
1204
 
503
1205
 
504
- def parse_arg_spec(arg_spec: str) -> tuple[str, str, str | None]:
505
- """Parse argument specification.
1206
+ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
1207
+ """Check for exported arguments that differ only in case.
506
1208
 
507
- Format: name:type=default
508
- - name is required
509
- - type is optional (defaults to 'str')
510
- - default is optional
1209
+ On Unix systems, environment variables are case-sensitive, but having
1210
+ args that differ only in case (e.g., $Server and $server) can be confusing.
1211
+ This function emits a warning if such collisions are detected.
511
1212
 
512
1213
  Args:
513
- arg_spec: Argument specification string
1214
+ args: List of argument specifications
1215
+ task_name: Name of the task (for warning message)
1216
+ """
1217
+ import sys
1218
+
1219
+ # Parse all exported arg names
1220
+ exported_names = []
1221
+ for arg_spec in args:
1222
+ parsed = parse_arg_spec(arg_spec)
1223
+ if parsed.is_exported:
1224
+ exported_names.append(parsed.name)
1225
+
1226
+ # Check for case collisions
1227
+ seen_lower = {}
1228
+ for name in exported_names:
1229
+ lower_name = name.lower()
1230
+ if lower_name in seen_lower:
1231
+ # Found a collision
1232
+ other_name = seen_lower[lower_name]
1233
+ if name != other_name: # Only warn if actual case differs
1234
+ print(
1235
+ f"Warning: Task '{task_name}' has exported arguments that differ only in case: "
1236
+ f"${other_name} and ${name}. "
1237
+ f"This may be confusing on case-sensitive systems.",
1238
+ file=sys.stderr
1239
+ )
1240
+ else:
1241
+ seen_lower[lower_name] = name
1242
+
1243
+
1244
+ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1245
+ """Parse argument specification from YAML.
1246
+
1247
+ Supports both string format and dictionary format:
1248
+
1249
+ String format:
1250
+ - Simple name: "argname"
1251
+ - Exported (becomes env var): "$argname"
1252
+ - With default: "argname=value" or "$argname=value"
1253
+ - Legacy type syntax: "argname:type=value" (for backwards compat)
1254
+
1255
+ Dictionary format:
1256
+ - argname: { default: "value" }
1257
+ - argname: { type: int, default: 42 }
1258
+ - argname: { type: int, min: 1, max: 100 }
1259
+ - argname: { type: str, choices: ["dev", "staging", "prod"] }
1260
+ - $argname: { default: "value" } # Exported
1261
+
1262
+ Args:
1263
+ arg_spec: Argument specification (string or dict with single key)
514
1264
 
515
1265
  Returns:
516
- Tuple of (name, type, default)
1266
+ ArgSpec object containing parsed argument information
517
1267
 
518
1268
  Examples:
519
1269
  >>> parse_arg_spec("environment")
520
- ('environment', 'str', None)
521
- >>> parse_arg_spec("region=eu-west-1")
522
- ('region', 'str', 'eu-west-1')
523
- >>> parse_arg_spec("port:int=8080")
524
- ('port', 'int', '8080')
1270
+ ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
1271
+ >>> parse_arg_spec({"key2": {"default": "foo"}})
1272
+ ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
1273
+ >>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
1274
+ ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
1275
+ >>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
1276
+ ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
1277
+ >>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
1278
+ ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
1279
+
1280
+ Raises:
1281
+ ValueError: If argument specification is invalid
525
1282
  """
1283
+ # Handle dictionary format: { argname: { type: ..., default: ... } }
1284
+ if isinstance(arg_spec, dict):
1285
+ if len(arg_spec) != 1:
1286
+ raise ValueError(
1287
+ f"Argument dictionary must have exactly one key (the argument name), got: {list(arg_spec.keys())}"
1288
+ )
1289
+
1290
+ # Extract the argument name and its configuration
1291
+ arg_name, config = next(iter(arg_spec.items()))
1292
+
1293
+ # Check if argument is exported (name starts with $)
1294
+ is_exported = arg_name.startswith("$")
1295
+ if is_exported:
1296
+ arg_name = arg_name[1:] # Remove $ prefix
1297
+
1298
+ # Validate argument name
1299
+ if not arg_name or not isinstance(arg_name, str):
1300
+ raise ValueError(
1301
+ f"Argument name must be a non-empty string, got: {arg_name!r}"
1302
+ )
1303
+
1304
+ # Config must be a dictionary
1305
+ if not isinstance(config, dict):
1306
+ raise ValueError(
1307
+ f"Argument '{arg_name}' configuration must be a dictionary, got: {type(config).__name__}"
1308
+ )
1309
+
1310
+ return _parse_arg_dict(arg_name, config, is_exported)
1311
+
1312
+ # Handle string format
1313
+ # Check if argument is exported (starts with $)
1314
+ is_exported = arg_spec.startswith("$")
1315
+ if is_exported:
1316
+ arg_spec = arg_spec[1:] # Remove $ prefix
1317
+
526
1318
  # Split on = to separate name:type from default
527
1319
  if "=" in arg_spec:
528
1320
  name_type, default = arg_spec.split("=", 1)
@@ -533,8 +1325,469 @@ def parse_arg_spec(arg_spec: str) -> tuple[str, str, str | None]:
533
1325
  # Split on : to separate name from type
534
1326
  if ":" in name_type:
535
1327
  name, arg_type = name_type.split(":", 1)
1328
+
1329
+ # Exported arguments cannot have type annotations
1330
+ if is_exported:
1331
+ raise ValueError(
1332
+ f"Type annotations not allowed on exported arguments\n"
1333
+ f"In argument: ${name}:{arg_type}\n\n"
1334
+ f"Exported arguments are always strings. Remove the type annotation:\n"
1335
+ f" args: [${name}]"
1336
+ )
536
1337
  else:
537
1338
  name = name_type
538
1339
  arg_type = "str"
539
1340
 
540
- return name, arg_type, default
1341
+ # String format doesn't support min/max/choices
1342
+ return ArgSpec(
1343
+ name=name,
1344
+ arg_type=arg_type,
1345
+ default=default,
1346
+ is_exported=is_exported,
1347
+ min_val=None,
1348
+ max_val=None,
1349
+ choices=None
1350
+ )
1351
+
1352
+
1353
+ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
1354
+ """Parse argument specification from dictionary format.
1355
+
1356
+ Args:
1357
+ arg_name: Name of the argument
1358
+ config: Dictionary with optional keys: type, default, min, max, choices
1359
+ is_exported: Whether argument should be exported to environment
1360
+
1361
+ Returns:
1362
+ ArgSpec object containing the parsed argument specification
1363
+
1364
+ Raises:
1365
+ ValueError: If dictionary format is invalid
1366
+ """
1367
+ # Validate dictionary keys
1368
+ valid_keys = {"type", "default", "min", "max", "choices"}
1369
+ invalid_keys = set(config.keys()) - valid_keys
1370
+ if invalid_keys:
1371
+ raise ValueError(
1372
+ f"Invalid keys in argument '{arg_name}' configuration: {', '.join(sorted(invalid_keys))}\n"
1373
+ f"Valid keys are: {', '.join(sorted(valid_keys))}"
1374
+ )
1375
+
1376
+ # Extract values
1377
+ arg_type = config.get("type")
1378
+ default = config.get("default")
1379
+ min_val = config.get("min")
1380
+ max_val = config.get("max")
1381
+ choices = config.get("choices")
1382
+
1383
+ # Track if an explicit type was provided (for validation later)
1384
+ explicit_type = arg_type
1385
+
1386
+ # Exported arguments cannot have type annotations
1387
+ if is_exported and arg_type is not None:
1388
+ raise ValueError(
1389
+ f"Type annotations not allowed on exported argument '${arg_name}'\n"
1390
+ f"Exported arguments are always strings. Remove the 'type' field"
1391
+ )
1392
+
1393
+ # Validate choices
1394
+ if choices is not None:
1395
+ # Validate choices is a list
1396
+ if not isinstance(choices, list):
1397
+ raise ValueError(
1398
+ f"Argument '{arg_name}': choices must be a list"
1399
+ )
1400
+
1401
+ # Validate choices is not empty
1402
+ if len(choices) == 0:
1403
+ raise ValueError(
1404
+ f"Argument '{arg_name}': choices list cannot be empty"
1405
+ )
1406
+
1407
+ # Check for mutual exclusivity with min/max
1408
+ if min_val is not None or max_val is not None:
1409
+ raise ValueError(
1410
+ f"Argument '{arg_name}': choices and min/max are mutually exclusive.\n"
1411
+ f"Use either choices for discrete values or min/max for ranges, not both."
1412
+ )
1413
+
1414
+ # Infer type from default, min, max, or choices if type not specified
1415
+ if arg_type is None:
1416
+ # Collect all values that can help infer type
1417
+ inferred_types = []
1418
+
1419
+ if default is not None:
1420
+ inferred_types.append(("default", _infer_variable_type(default)))
1421
+ if min_val is not None:
1422
+ inferred_types.append(("min", _infer_variable_type(min_val)))
1423
+ if max_val is not None:
1424
+ inferred_types.append(("max", _infer_variable_type(max_val)))
1425
+ if choices is not None and len(choices) > 0:
1426
+ inferred_types.append(("choices[0]", _infer_variable_type(choices[0])))
1427
+
1428
+ if inferred_types:
1429
+ # Check all inferred types are consistent
1430
+ first_name, first_type = inferred_types[0]
1431
+ for value_name, value_type in inferred_types[1:]:
1432
+ if value_type != first_type:
1433
+ # Build error message showing the conflicting types
1434
+ type_info = ", ".join([f"{name}={vtype}" for name, vtype in inferred_types])
1435
+ raise ValueError(
1436
+ f"Argument '{arg_name}': inconsistent types inferred from min, max, and default.\n"
1437
+ f"All values must have the same type.\n"
1438
+ f"Found: {type_info}"
1439
+ )
1440
+
1441
+ # All types are consistent, use the inferred type
1442
+ arg_type = first_type
1443
+ else:
1444
+ # No values to infer from, default to string
1445
+ arg_type = "str"
1446
+ else:
1447
+ # Explicit type was provided - validate that default matches it
1448
+ # (min/max validation happens later, after the min/max numeric check)
1449
+ if default is not None:
1450
+ default_type = _infer_variable_type(default)
1451
+ if default_type != explicit_type:
1452
+ raise ValueError(
1453
+ f"Default value for argument '{arg_name}' is incompatible with type '{explicit_type}': "
1454
+ f"default has type '{default_type}'"
1455
+ )
1456
+
1457
+ # Validate min/max are only used with numeric types
1458
+ if (min_val is not None or max_val is not None) and arg_type not in ("int", "float"):
1459
+ raise ValueError(
1460
+ f"Argument '{arg_name}': min/max constraints are only supported for 'int' and 'float' types, "
1461
+ f"not '{arg_type}'"
1462
+ )
1463
+
1464
+ # If explicit type was provided, validate min/max match that type
1465
+ if explicit_type is not None and arg_type in ("int", "float"):
1466
+ type_mismatches = []
1467
+ if min_val is not None:
1468
+ min_type = _infer_variable_type(min_val)
1469
+ if min_type != explicit_type:
1470
+ type_mismatches.append(f"min value has type '{min_type}'")
1471
+ if max_val is not None:
1472
+ max_type = _infer_variable_type(max_val)
1473
+ if max_type != explicit_type:
1474
+ type_mismatches.append(f"max value has type '{max_type}'")
1475
+
1476
+ if type_mismatches:
1477
+ raise ValueError(
1478
+ f"Argument '{arg_name}': explicit type '{explicit_type}' does not match value types.\n"
1479
+ + "\n".join([f" - {mismatch}" for mismatch in type_mismatches])
1480
+ )
1481
+
1482
+ # Validate min <= max
1483
+ if min_val is not None and max_val is not None:
1484
+ if min_val > max_val:
1485
+ raise ValueError(
1486
+ f"Argument '{arg_name}': min ({min_val}) must be less than or equal to max ({max_val})"
1487
+ )
1488
+
1489
+ # Validate type name and get validator
1490
+ try:
1491
+ validator = get_click_type(arg_type)
1492
+ except ValueError:
1493
+ raise ValueError(
1494
+ f"Unknown type in argument '{arg_name}': {arg_type}\n"
1495
+ f"Supported types: str, int, float, bool, path, datetime, ip, ipv4, ipv6, email, hostname"
1496
+ )
1497
+
1498
+ # Validate choices
1499
+ if choices is not None:
1500
+ # Boolean types cannot have choices
1501
+ if arg_type == "bool":
1502
+ raise ValueError(
1503
+ f"Argument '{arg_name}': boolean types cannot have choices.\n"
1504
+ f"Boolean values are already limited to true/false."
1505
+ )
1506
+
1507
+ # Validate all choices are the same type
1508
+ if len(choices) > 0:
1509
+ first_choice_type = _infer_variable_type(choices[0])
1510
+
1511
+ # If explicit type was provided, validate choices match it
1512
+ if explicit_type is not None and first_choice_type != explicit_type:
1513
+ raise ValueError(
1514
+ f"Argument '{arg_name}': choice values do not match explicit type '{explicit_type}'.\n"
1515
+ f"First choice has type '{first_choice_type}'"
1516
+ )
1517
+
1518
+ # Check all choices have the same type
1519
+ for i, choice in enumerate(choices[1:], start=1):
1520
+ choice_type = _infer_variable_type(choice)
1521
+ if choice_type != first_choice_type:
1522
+ raise ValueError(
1523
+ f"Argument '{arg_name}': all choice values must have the same type.\n"
1524
+ f"First choice has type '{first_choice_type}', but choice at index {i} has type '{choice_type}'"
1525
+ )
1526
+
1527
+ # Validate all choices are valid for the type
1528
+ for i, choice in enumerate(choices):
1529
+ try:
1530
+ validator.convert(choice, None, None)
1531
+ except Exception as e:
1532
+ raise ValueError(
1533
+ f"Argument '{arg_name}': choice at index {i} ({choice!r}) is invalid for type '{arg_type}': {e}"
1534
+ )
1535
+
1536
+ # Validate and convert default value
1537
+ if default is not None:
1538
+ # Validate that default is compatible with the declared type
1539
+ if arg_type != "str":
1540
+ # Validate that the default value is compatible with the type
1541
+ try:
1542
+ # Use the validator we already retrieved
1543
+ converted_default = validator.convert(default, None, None)
1544
+ except Exception as e:
1545
+ raise ValueError(
1546
+ f"Default value for argument '{arg_name}' is incompatible with type '{arg_type}': {e}"
1547
+ )
1548
+
1549
+ # Validate default is within min/max range
1550
+ if min_val is not None and converted_default < min_val:
1551
+ raise ValueError(
1552
+ f"Default value for argument '{arg_name}' ({default}) is less than min ({min_val})"
1553
+ )
1554
+ if max_val is not None and converted_default > max_val:
1555
+ raise ValueError(
1556
+ f"Default value for argument '{arg_name}' ({default}) is greater than max ({max_val})"
1557
+ )
1558
+
1559
+ # Validate default is in choices list
1560
+ if choices is not None and converted_default not in choices:
1561
+ raise ValueError(
1562
+ f"Default value for argument '{arg_name}' ({default}) is not in the choices list.\n"
1563
+ f"Valid choices: {choices}"
1564
+ )
1565
+
1566
+ # After validation, convert to string for storage
1567
+ default_str = str(default)
1568
+ else:
1569
+ # For string type, validate default is in choices
1570
+ if choices is not None and default not in choices:
1571
+ raise ValueError(
1572
+ f"Default value for argument '{arg_name}' ({default}) is not in the choices list.\n"
1573
+ f"Valid choices: {choices}"
1574
+ )
1575
+ default_str = str(default)
1576
+ else:
1577
+ # None remains None (not the string "None")
1578
+ default_str = None
1579
+
1580
+ return ArgSpec(
1581
+ name=arg_name,
1582
+ arg_type=arg_type,
1583
+ default=default_str,
1584
+ is_exported=is_exported,
1585
+ min_val=min_val,
1586
+ max_val=max_val,
1587
+ choices=choices
1588
+ )
1589
+
1590
+
1591
+ def parse_dependency_spec(dep_spec: str | dict[str, Any], recipe: Recipe) -> DependencyInvocation:
1592
+ """Parse a dependency specification into a DependencyInvocation.
1593
+
1594
+ Supports three forms:
1595
+ 1. Simple string: "task_name" -> DependencyInvocation(task_name, None)
1596
+ 2. Positional args: {"task_name": [arg1, arg2]} -> DependencyInvocation(task_name, {name1: arg1, name2: arg2})
1597
+ 3. Named args: {"task_name": {arg1: val1}} -> DependencyInvocation(task_name, {arg1: val1})
1598
+
1599
+ Args:
1600
+ dep_spec: Dependency specification (string or dict)
1601
+ recipe: Recipe containing task definitions (for arg normalization)
1602
+
1603
+ Returns:
1604
+ DependencyInvocation object with normalized args
1605
+
1606
+ Raises:
1607
+ ValueError: If dependency specification is invalid
1608
+ """
1609
+ # Simple string case
1610
+ if isinstance(dep_spec, str):
1611
+ return DependencyInvocation(task_name=dep_spec, args=None)
1612
+
1613
+ # Dictionary case
1614
+ if not isinstance(dep_spec, dict):
1615
+ raise ValueError(
1616
+ f"Dependency must be a string or dictionary, got: {type(dep_spec).__name__}"
1617
+ )
1618
+
1619
+ # Validate dict has exactly one key
1620
+ if len(dep_spec) != 1:
1621
+ raise ValueError(
1622
+ f"Dependency dictionary must have exactly one key (the task name), got: {list(dep_spec.keys())}"
1623
+ )
1624
+
1625
+ task_name, arg_spec = next(iter(dep_spec.items()))
1626
+
1627
+ # Validate task name
1628
+ if not isinstance(task_name, str) or not task_name:
1629
+ raise ValueError(
1630
+ f"Dependency task name must be a non-empty string, got: {task_name!r}"
1631
+ )
1632
+
1633
+ # Check for empty list (explicitly disallowed)
1634
+ if isinstance(arg_spec, list) and len(arg_spec) == 0:
1635
+ raise ValueError(
1636
+ f"Empty argument list for dependency '{task_name}' is not allowed.\n"
1637
+ f"Use simple string form instead: '{task_name}'"
1638
+ )
1639
+
1640
+ # Positional args (list)
1641
+ if isinstance(arg_spec, list):
1642
+ return _parse_positional_dependency_args(task_name, arg_spec, recipe)
1643
+
1644
+ # Named args (dict)
1645
+ if isinstance(arg_spec, dict):
1646
+ return _parse_named_dependency_args(task_name, arg_spec, recipe)
1647
+
1648
+ # Invalid type
1649
+ raise ValueError(
1650
+ f"Dependency arguments for '{task_name}' must be a list (positional) or dict (named), "
1651
+ f"got: {type(arg_spec).__name__}"
1652
+ )
1653
+
1654
+
1655
+ def _get_validated_task(task_name: str, recipe: Recipe) -> Task:
1656
+ """Get and validate that a task exists in the recipe.
1657
+
1658
+ Args:
1659
+ task_name: Name of the task to retrieve
1660
+ recipe: Recipe containing task definitions
1661
+
1662
+ Returns:
1663
+ The validated Task object
1664
+
1665
+ Raises:
1666
+ ValueError: If task is not found
1667
+ """
1668
+ task = recipe.get_task(task_name)
1669
+ if task is None:
1670
+ raise ValueError(f"Dependency task not found: {task_name}")
1671
+ return task
1672
+
1673
+
1674
+ def _parse_positional_dependency_args(
1675
+ task_name: str, args_list: list[Any], recipe: Recipe
1676
+ ) -> DependencyInvocation:
1677
+ """Parse positional dependency arguments.
1678
+
1679
+ Args:
1680
+ task_name: Name of the dependency task
1681
+ args_list: List of positional argument values
1682
+ recipe: Recipe containing task definitions
1683
+
1684
+ Returns:
1685
+ DependencyInvocation with normalized named args
1686
+
1687
+ Raises:
1688
+ ValueError: If validation fails
1689
+ """
1690
+ # Get the task to validate against
1691
+ task = _get_validated_task(task_name, recipe)
1692
+
1693
+ # Parse task's arg specs
1694
+ if not task.args:
1695
+ raise ValueError(
1696
+ f"Task '{task_name}' takes no arguments, but {len(args_list)} were provided"
1697
+ )
1698
+
1699
+ parsed_specs = [parse_arg_spec(spec) for spec in task.args]
1700
+
1701
+ # Check positional count doesn't exceed task's arg count
1702
+ if len(args_list) > len(parsed_specs):
1703
+ raise ValueError(
1704
+ f"Task '{task_name}' takes {len(parsed_specs)} arguments, got {len(args_list)}"
1705
+ )
1706
+
1707
+ # Map positional args to names with type conversion
1708
+ args_dict = {}
1709
+ for i, value in enumerate(args_list):
1710
+ spec = parsed_specs[i]
1711
+ if isinstance(value, str):
1712
+ # Convert string values using type validator
1713
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
1714
+ args_dict[spec.name] = click_type.convert(value, None, None)
1715
+ else:
1716
+ # Value is already typed (e.g., bool, int from YAML)
1717
+ args_dict[spec.name] = value
1718
+
1719
+ # Fill in defaults for remaining args
1720
+ for i in range(len(args_list), len(parsed_specs)):
1721
+ spec = parsed_specs[i]
1722
+ if spec.default is not None:
1723
+ # Defaults in task specs are always strings, convert them
1724
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
1725
+ args_dict[spec.name] = click_type.convert(spec.default, None, None)
1726
+ else:
1727
+ raise ValueError(
1728
+ f"Task '{task_name}' requires argument '{spec.name}' (no default provided)"
1729
+ )
1730
+
1731
+ return DependencyInvocation(task_name=task_name, args=args_dict)
1732
+
1733
+
1734
+ def _parse_named_dependency_args(
1735
+ task_name: str, args_dict: dict[str, Any], recipe: Recipe
1736
+ ) -> DependencyInvocation:
1737
+ """Parse named dependency arguments.
1738
+
1739
+ Args:
1740
+ task_name: Name of the dependency task
1741
+ args_dict: Dictionary of argument names to values
1742
+ recipe: Recipe containing task definitions
1743
+
1744
+ Returns:
1745
+ DependencyInvocation with normalized args (defaults filled)
1746
+
1747
+ Raises:
1748
+ ValueError: If validation fails
1749
+ """
1750
+ # Get the task to validate against
1751
+ task = _get_validated_task(task_name, recipe)
1752
+
1753
+ # Parse task's arg specs
1754
+ if not task.args:
1755
+ if args_dict:
1756
+ raise ValueError(
1757
+ f"Task '{task_name}' takes no arguments, but {len(args_dict)} were provided"
1758
+ )
1759
+ return DependencyInvocation(task_name=task_name, args={})
1760
+
1761
+ parsed_specs = [parse_arg_spec(spec) for spec in task.args]
1762
+ spec_map = {spec.name: spec for spec in parsed_specs}
1763
+
1764
+ # Validate all provided arg names exist
1765
+ for arg_name in args_dict:
1766
+ if arg_name not in spec_map:
1767
+ raise ValueError(
1768
+ f"Task '{task_name}' has no argument named '{arg_name}'"
1769
+ )
1770
+
1771
+ # Build normalized args dict with defaults
1772
+ normalized_args = {}
1773
+ for spec in parsed_specs:
1774
+ if spec.name in args_dict:
1775
+ # Use provided value with type conversion (only convert strings)
1776
+ value = args_dict[spec.name]
1777
+ if isinstance(value, str):
1778
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
1779
+ normalized_args[spec.name] = click_type.convert(value, None, None)
1780
+ else:
1781
+ # Value is already typed (e.g., bool, int from YAML)
1782
+ normalized_args[spec.name] = value
1783
+ elif spec.default is not None:
1784
+ # Use default value (defaults are always strings in task specs)
1785
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
1786
+ normalized_args[spec.name] = click_type.convert(spec.default, None, None)
1787
+ else:
1788
+ # Required arg not provided
1789
+ raise ValueError(
1790
+ f"Task '{task_name}' requires argument '{spec.name}' (no default provided)"
1791
+ )
1792
+
1793
+ return DependencyInvocation(task_name=task_name, args=normalized_args)