tasktree 0.0.6__py3-none-any.whl → 0.0.8__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."""
@@ -16,12 +22,26 @@ class CircularImportError(Exception):
16
22
 
17
23
  @dataclass
18
24
  class Environment:
19
- """Represents an execution environment configuration."""
25
+ """Represents an execution environment configuration.
26
+
27
+ Can be either a shell environment or a Docker environment:
28
+ - Shell environment: has 'shell' field, executes directly on host
29
+ - Docker environment: has 'dockerfile' field, executes in container
30
+ """
20
31
 
21
32
  name: str
22
- shell: str
33
+ shell: str = "" # Path to shell (required for shell envs, optional for Docker)
23
34
  args: list[str] = field(default_factory=list)
24
35
  preamble: str = ""
36
+ # Docker-specific fields (presence of dockerfile indicates Docker environment)
37
+ dockerfile: str = "" # Path to Dockerfile
38
+ context: str = "" # Path to build context directory
39
+ volumes: list[str] = field(default_factory=list) # Volume mounts
40
+ ports: list[str] = field(default_factory=list) # Port mappings
41
+ env_vars: dict[str, str] = field(default_factory=dict) # Environment variables
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)
25
45
 
26
46
  def __post_init__(self):
27
47
  """Ensure args is always a list."""
@@ -56,15 +76,39 @@ class Task:
56
76
  self.args = [self.args]
57
77
 
58
78
 
79
+ @dataclass
80
+ class ArgSpec:
81
+ """Represents a parsed argument specification.
82
+
83
+ Attributes:
84
+ name: Argument name
85
+ arg_type: Type of the argument (str, int, float, bool, path)
86
+ default: Default value as a string (None if no default)
87
+ is_exported: Whether the argument is exported as an environment variable
88
+ min_val: Minimum value for numeric arguments (None if not specified)
89
+ max_val: Maximum value for numeric arguments (None if not specified)
90
+ choices: List of valid choices for the argument (None if not specified)
91
+ """
92
+ name: str
93
+ arg_type: str
94
+ default: str | None = None
95
+ is_exported: bool = False
96
+ min_val: int | float | None = None
97
+ max_val: int | float | None = None
98
+ choices: list[Any] | None = None
99
+
100
+
59
101
  @dataclass
60
102
  class Recipe:
61
103
  """Represents a parsed recipe file with all tasks."""
62
104
 
63
105
  tasks: dict[str, Task]
64
106
  project_root: Path
107
+ recipe_path: Path # Path to the recipe file
65
108
  environments: dict[str, Environment] = field(default_factory=dict)
66
109
  default_env: str = "" # Name of default environment
67
110
  global_env_override: str = "" # Global environment override (set via CLI --env)
111
+ variables: dict[str, str] = field(default_factory=dict) # Global variables (resolved at parse time)
68
112
 
69
113
  def get_task(self, name: str) -> Task | None:
70
114
  """Get task by name.
@@ -156,13 +200,603 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
156
200
  return None
157
201
 
158
202
 
203
+ def _validate_variable_name(name: str) -> None:
204
+ """Validate that a variable name is a valid identifier.
205
+
206
+ Args:
207
+ name: Variable name to validate
208
+
209
+ Raises:
210
+ ValueError: If name is not a valid identifier
211
+ """
212
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
213
+ raise ValueError(
214
+ f"Variable name '{name}' is invalid. Names must start with "
215
+ f"letter/underscore and contain only alphanumerics and underscores."
216
+ )
217
+
218
+
219
+ def _infer_variable_type(value: Any) -> str:
220
+ """Infer type name from Python value.
221
+
222
+ Args:
223
+ value: Python value from YAML
224
+
225
+ Returns:
226
+ Type name string (str, int, float, bool)
227
+
228
+ Raises:
229
+ ValueError: If value type is not supported
230
+ """
231
+ type_map = {
232
+ str: "str",
233
+ int: "int",
234
+ float: "float",
235
+ bool: "bool"
236
+ }
237
+ python_type = type(value)
238
+ if python_type not in type_map:
239
+ raise ValueError(
240
+ f"Variable has unsupported type '{python_type.__name__}'. "
241
+ f"Supported types: str, int, float, bool, path, datetime, ip, ipv4, ipv6, email, hostname"
242
+ )
243
+ return type_map[python_type]
244
+
245
+
246
+ def _is_env_variable_reference(value: Any) -> bool:
247
+ """Check if value is an environment variable reference.
248
+
249
+ Args:
250
+ value: Raw value from YAML
251
+
252
+ Returns:
253
+ True if value is { env: VAR_NAME } dict
254
+ """
255
+ return isinstance(value, dict) and "env" in value
256
+
257
+
258
+ def _validate_env_variable_reference(var_name: str, value: dict) -> str:
259
+ """Validate and extract environment variable name from reference.
260
+
261
+ Args:
262
+ var_name: Name of the variable being defined
263
+ value: Dict that should be { env: ENV_VAR_NAME }
264
+
265
+ Returns:
266
+ Environment variable name
267
+
268
+ Raises:
269
+ ValueError: If reference is invalid
270
+ """
271
+ # Validate dict structure
272
+ if len(value) != 1:
273
+ extra_keys = [k for k in value.keys() if k != "env"]
274
+ raise ValueError(
275
+ f"Invalid environment variable reference in variable '{var_name}'.\n"
276
+ f"Expected: {{ env: VARIABLE_NAME }}\n"
277
+ f"Found extra keys: {', '.join(extra_keys)}"
278
+ )
279
+
280
+ env_var_name = value["env"]
281
+
282
+ # Validate env var name is provided
283
+ if not env_var_name or not isinstance(env_var_name, str):
284
+ raise ValueError(
285
+ f"Invalid environment variable reference in variable '{var_name}'.\n"
286
+ f"Expected: {{ env: VARIABLE_NAME }}\n"
287
+ f"Found: {{ env: {env_var_name!r} }}"
288
+ )
289
+
290
+ # Validate env var name format (allow both uppercase and mixed case for flexibility)
291
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', env_var_name):
292
+ raise ValueError(
293
+ f"Invalid environment variable name '{env_var_name}' in variable '{var_name}'.\n"
294
+ f"Environment variable names must start with a letter or underscore,\n"
295
+ f"and contain only alphanumerics and underscores."
296
+ )
297
+
298
+ return env_var_name
299
+
300
+
301
+ def _resolve_env_variable(var_name: str, env_var_name: str) -> str:
302
+ """Resolve environment variable value.
303
+
304
+ Args:
305
+ var_name: Name of the variable being defined
306
+ env_var_name: Name of environment variable to read
307
+
308
+ Returns:
309
+ Environment variable value as string
310
+
311
+ Raises:
312
+ ValueError: If environment variable is not set
313
+ """
314
+ value = os.environ.get(env_var_name)
315
+
316
+ if value is None:
317
+ raise ValueError(
318
+ f"Environment variable '{env_var_name}' (referenced by variable '{var_name}') is not set.\n\n"
319
+ f"Hint: Set it before running tt:\n"
320
+ f" {env_var_name}=value tt task\n\n"
321
+ f"Or export it in your shell:\n"
322
+ f" export {env_var_name}=value\n"
323
+ f" tt task"
324
+ )
325
+
326
+ return value
327
+
328
+
329
+ def _is_file_read_reference(value: Any) -> bool:
330
+ """Check if value is a file read reference.
331
+
332
+ Args:
333
+ value: Raw value from YAML
334
+
335
+ Returns:
336
+ True if value is { read: filepath } dict
337
+ """
338
+ return isinstance(value, dict) and "read" in value
339
+
340
+
341
+ def _validate_file_read_reference(var_name: str, value: dict) -> str:
342
+ """Validate and extract filepath from file read reference.
343
+
344
+ Args:
345
+ var_name: Name of the variable being defined
346
+ value: Dict that should be { read: filepath }
347
+
348
+ Returns:
349
+ Filepath string
350
+
351
+ Raises:
352
+ ValueError: If reference is invalid
353
+ """
354
+ # Validate dict structure (only "read" key allowed)
355
+ if len(value) != 1:
356
+ extra_keys = [k for k in value.keys() if k != "read"]
357
+ raise ValueError(
358
+ f"Invalid file read reference in variable '{var_name}'.\n"
359
+ f"Expected: {{ read: filepath }}\n"
360
+ f"Found extra keys: {', '.join(extra_keys)}"
361
+ )
362
+
363
+ filepath = value["read"]
364
+
365
+ # Validate filepath is provided and is a string
366
+ if not filepath or not isinstance(filepath, str):
367
+ raise ValueError(
368
+ f"Invalid file read reference in variable '{var_name}'.\n"
369
+ f"Expected: {{ read: filepath }}\n"
370
+ f"Found: {{ read: {filepath!r} }}\n\n"
371
+ f"Filepath must be a non-empty string."
372
+ )
373
+
374
+ return filepath
375
+
376
+
377
+ def _resolve_file_path(filepath: str, recipe_file_path: Path) -> Path:
378
+ """Resolve file path relative to recipe file location.
379
+
380
+ Handles three path types:
381
+ 1. Tilde paths (~): Expand to user home directory
382
+ 2. Absolute paths: Use as-is
383
+ 3. Relative paths: Resolve relative to recipe file's directory
384
+
385
+ Args:
386
+ filepath: Path string from YAML (may be relative, absolute, or tilde)
387
+ recipe_file_path: Path to the recipe file containing the variable
388
+
389
+ Returns:
390
+ Resolved absolute Path object
391
+ """
392
+ # Expand tilde to home directory
393
+ if filepath.startswith("~"):
394
+ return Path(os.path.expanduser(filepath))
395
+
396
+ # Convert to Path for is_absolute check
397
+ path_obj = Path(filepath)
398
+
399
+ # Absolute paths used as-is
400
+ if path_obj.is_absolute():
401
+ return path_obj
402
+
403
+ # Relative paths resolved from recipe file's directory
404
+ return recipe_file_path.parent / filepath
405
+
406
+
407
+ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) -> str:
408
+ """Read file contents for variable value.
409
+
410
+ Args:
411
+ var_name: Name of the variable being defined
412
+ filepath: Original filepath string (for error messages)
413
+ resolved_path: Resolved absolute path to the file
414
+
415
+ Returns:
416
+ File contents as string (with trailing newline stripped)
417
+
418
+ Raises:
419
+ ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
420
+ """
421
+ # Check file exists
422
+ if not resolved_path.exists():
423
+ raise ValueError(
424
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
425
+ f"File not found: {resolved_path}\n\n"
426
+ f"Note: Relative paths are resolved from the recipe file location."
427
+ )
428
+
429
+ # Check it's a file (not directory)
430
+ if not resolved_path.is_file():
431
+ raise ValueError(
432
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
433
+ f"Path is not a file: {resolved_path}"
434
+ )
435
+
436
+ # Read file with UTF-8 error handling
437
+ try:
438
+ content = resolved_path.read_text(encoding='utf-8')
439
+ except PermissionError:
440
+ raise ValueError(
441
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
442
+ f"Permission denied: {resolved_path}\n\n"
443
+ f"Ensure the file is readable by the current user."
444
+ )
445
+ except UnicodeDecodeError as e:
446
+ raise ValueError(
447
+ f"Failed to read file for variable '{var_name}': {filepath}\n"
448
+ f"File contains invalid UTF-8 data: {resolved_path}\n\n"
449
+ f"The {{ read: ... }} syntax only supports text files.\n"
450
+ f"Error: {e}"
451
+ )
452
+
453
+ # Strip single trailing newline if present
454
+ if content.endswith('\n'):
455
+ content = content[:-1]
456
+
457
+ return content
458
+
459
+
460
+ def _is_eval_reference(value: Any) -> bool:
461
+ """Check if value is an eval command reference.
462
+
463
+ Args:
464
+ value: Raw value from YAML
465
+
466
+ Returns:
467
+ True if value is { eval: command } dict
468
+ """
469
+ return isinstance(value, dict) and "eval" in value
470
+
471
+
472
+ def _validate_eval_reference(var_name: str, value: dict) -> str:
473
+ """Validate and extract command from eval reference.
474
+
475
+ Args:
476
+ var_name: Name of the variable being defined
477
+ value: Dict that should be { eval: command }
478
+
479
+ Returns:
480
+ Command string
481
+
482
+ Raises:
483
+ ValueError: If reference is invalid
484
+ """
485
+ # Validate dict structure (only "eval" key allowed)
486
+ if len(value) != 1:
487
+ extra_keys = [k for k in value.keys() if k != "eval"]
488
+ raise ValueError(
489
+ f"Invalid eval reference in variable '{var_name}'.\n"
490
+ f"Expected: {{ eval: command }}\n"
491
+ f"Found extra keys: {', '.join(extra_keys)}"
492
+ )
493
+
494
+ command = value["eval"]
495
+
496
+ # Validate command is provided and is a string
497
+ if not command or not isinstance(command, str):
498
+ raise ValueError(
499
+ f"Invalid eval reference in variable '{var_name}'.\n"
500
+ f"Expected: {{ eval: command }}\n"
501
+ f"Found: {{ eval: {command!r} }}\n\n"
502
+ f"Command must be a non-empty string."
503
+ )
504
+
505
+ return command
506
+
507
+
508
+ def _get_default_shell_and_args() -> tuple[str, list[str]]:
509
+ """Get default shell and args for current platform.
510
+
511
+ Returns:
512
+ Tuple of (shell, args) for platform default
513
+ """
514
+ is_windows = platform.system() == "Windows"
515
+ if is_windows:
516
+ return ("cmd", ["/c"])
517
+ else:
518
+ return ("bash", ["-c"])
519
+
520
+
521
+ def _resolve_eval_variable(
522
+ var_name: str,
523
+ command: str,
524
+ recipe_file_path: Path,
525
+ recipe_data: dict
526
+ ) -> str:
527
+ """Execute command and capture output for variable value.
528
+
529
+ Args:
530
+ var_name: Name of the variable being defined
531
+ command: Command to execute
532
+ recipe_file_path: Path to recipe file (for working directory)
533
+ recipe_data: Parsed YAML data (for accessing default_env)
534
+
535
+ Returns:
536
+ Command stdout as string (with trailing newline stripped)
537
+
538
+ Raises:
539
+ ValueError: If command fails or cannot be executed
540
+ """
541
+ # Determine shell to use
542
+ shell = None
543
+ shell_args = []
544
+
545
+ # Check if recipe has default_env specified
546
+ if recipe_data and "environments" in recipe_data:
547
+ env_data = recipe_data["environments"]
548
+ if isinstance(env_data, dict):
549
+ default_env_name = env_data.get("default", "")
550
+ if default_env_name and default_env_name in env_data:
551
+ env_config = env_data[default_env_name]
552
+ if isinstance(env_config, dict):
553
+ shell = env_config.get("shell", "")
554
+ shell_args = env_config.get("args", [])
555
+ if isinstance(shell_args, str):
556
+ shell_args = [shell_args]
557
+
558
+ # Use platform default if no environment specified or not found
559
+ if not shell:
560
+ shell, shell_args = _get_default_shell_and_args()
561
+
562
+ # Build command list
563
+ cmd_list = [shell] + shell_args + [command]
564
+
565
+ # Execute from recipe file directory
566
+ working_dir = recipe_file_path.parent
567
+
568
+ try:
569
+ result = subprocess.run(
570
+ cmd_list,
571
+ capture_output=True,
572
+ text=True,
573
+ cwd=working_dir,
574
+ check=False,
575
+ )
576
+ except FileNotFoundError:
577
+ raise ValueError(
578
+ f"Failed to execute command for variable '{var_name}'.\n"
579
+ f"Shell not found: {shell}\n\n"
580
+ f"Command: {command}\n\n"
581
+ f"Ensure the shell is installed and available in PATH."
582
+ )
583
+ except Exception as e:
584
+ raise ValueError(
585
+ f"Failed to execute command for variable '{var_name}'.\n"
586
+ f"Command: {command}\n"
587
+ f"Error: {e}"
588
+ )
589
+
590
+ # Check exit code
591
+ if result.returncode != 0:
592
+ stderr_output = result.stderr.strip() if result.stderr else "(no stderr output)"
593
+ raise ValueError(
594
+ f"Command failed for variable '{var_name}': {command}\n"
595
+ f"Exit code: {result.returncode}\n"
596
+ f"stderr: {stderr_output}\n\n"
597
+ f"Ensure the command succeeds when run from the recipe file location."
598
+ )
599
+
600
+ # Get stdout and strip trailing newline
601
+ output = result.stdout
602
+
603
+ # Strip single trailing newline if present
604
+ if output.endswith('\n'):
605
+ output = output[:-1]
606
+
607
+ return output
608
+
609
+
610
+ def _resolve_variable_value(
611
+ name: str,
612
+ raw_value: Any,
613
+ resolved: dict[str, str],
614
+ resolution_stack: list[str],
615
+ file_path: Path,
616
+ recipe_data: dict | None = None
617
+ ) -> str:
618
+ """Resolve a single variable value with circular reference detection.
619
+
620
+ Args:
621
+ name: Variable name being resolved
622
+ raw_value: Raw value from YAML (int, str, bool, float, dict with env/read/eval)
623
+ resolved: Dictionary of already-resolved variables
624
+ resolution_stack: Stack of variables currently being resolved (for circular detection)
625
+ file_path: Path to recipe file (for resolving relative file paths in { read: ... })
626
+ recipe_data: Parsed YAML data (for accessing default_env in { eval: ... })
627
+
628
+ Returns:
629
+ Resolved string value
630
+
631
+ Raises:
632
+ ValueError: If circular reference detected or validation fails
633
+ """
634
+ # Check for circular reference
635
+ if name in resolution_stack:
636
+ cycle = " -> ".join(resolution_stack + [name])
637
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
638
+
639
+ resolution_stack.append(name)
640
+
641
+ try:
642
+ # Check if this is an eval reference
643
+ if _is_eval_reference(raw_value):
644
+ # Validate and extract command
645
+ command = _validate_eval_reference(name, raw_value)
646
+
647
+ # Execute command and capture output
648
+ string_value = _resolve_eval_variable(name, command, file_path, recipe_data)
649
+
650
+ # Still perform variable-in-variable substitution
651
+ from tasktree.substitution import substitute_variables
652
+ try:
653
+ resolved_value = substitute_variables(string_value, resolved)
654
+ except ValueError as e:
655
+ # Check if the undefined variable is in the resolution stack (circular reference)
656
+ error_msg = str(e)
657
+ if "not defined" in error_msg:
658
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
659
+ if match:
660
+ undefined_var = match.group(1)
661
+ if undefined_var in resolution_stack:
662
+ cycle = " -> ".join(resolution_stack + [undefined_var])
663
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
664
+ # Re-raise the original error if not circular
665
+ raise
666
+
667
+ return resolved_value
668
+
669
+ # Check if this is a file read reference
670
+ if _is_file_read_reference(raw_value):
671
+ # Validate and extract filepath
672
+ filepath = _validate_file_read_reference(name, raw_value)
673
+
674
+ # Resolve path (handles tilde, absolute, relative)
675
+ resolved_path = _resolve_file_path(filepath, file_path)
676
+
677
+ # Read file contents
678
+ string_value = _resolve_file_variable(name, filepath, resolved_path)
679
+
680
+ # Still perform variable-in-variable substitution
681
+ from tasktree.substitution import substitute_variables
682
+ try:
683
+ resolved_value = substitute_variables(string_value, resolved)
684
+ except ValueError as e:
685
+ # Check if the undefined variable is in the resolution stack (circular reference)
686
+ error_msg = str(e)
687
+ if "not defined" in error_msg:
688
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
689
+ if match:
690
+ undefined_var = match.group(1)
691
+ if undefined_var in resolution_stack:
692
+ cycle = " -> ".join(resolution_stack + [undefined_var])
693
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
694
+ # Re-raise the original error if not circular
695
+ raise
696
+
697
+ return resolved_value
698
+
699
+ # Check if this is an environment variable reference
700
+ if _is_env_variable_reference(raw_value):
701
+ # Validate and extract env var name
702
+ env_var_name = _validate_env_variable_reference(name, raw_value)
703
+
704
+ # Resolve from os.environ
705
+ string_value = _resolve_env_variable(name, env_var_name)
706
+
707
+ # Still perform variable-in-variable substitution
708
+ from tasktree.substitution import substitute_variables
709
+ try:
710
+ resolved_value = substitute_variables(string_value, resolved)
711
+ except ValueError as e:
712
+ # Check if the undefined variable is in the resolution stack (circular reference)
713
+ error_msg = str(e)
714
+ if "not defined" in error_msg:
715
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
716
+ if match:
717
+ undefined_var = match.group(1)
718
+ if undefined_var in resolution_stack:
719
+ cycle = " -> ".join(resolution_stack + [undefined_var])
720
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
721
+ # Re-raise the original error if not circular
722
+ raise
723
+
724
+ return resolved_value
725
+
726
+ # Validate and infer type
727
+ type_name = _infer_variable_type(raw_value)
728
+ from tasktree.types import get_click_type
729
+ validator = get_click_type(type_name)
730
+
731
+ # Validate and stringify the value
732
+ string_value = validator.convert(raw_value, None, None)
733
+
734
+ # Substitute any {{ var.name }} references in the string value
735
+ from tasktree.substitution import substitute_variables
736
+ try:
737
+ resolved_value = substitute_variables(str(string_value), resolved)
738
+ except ValueError as e:
739
+ # Check if the undefined variable is in the resolution stack (circular reference)
740
+ error_msg = str(e)
741
+ if "not defined" in error_msg:
742
+ # Extract the variable name from the error message
743
+ import re
744
+ match = re.search(r"Variable '(\w+)' is not defined", error_msg)
745
+ if match:
746
+ undefined_var = match.group(1)
747
+ if undefined_var in resolution_stack:
748
+ cycle = " -> ".join(resolution_stack + [undefined_var])
749
+ raise ValueError(f"Circular reference detected in variables: {cycle}")
750
+ # Re-raise the original error if not circular
751
+ raise
752
+
753
+ return resolved_value
754
+ finally:
755
+ resolution_stack.pop()
756
+
757
+
758
+ def _parse_variables_section(data: dict, file_path: Path) -> dict[str, str]:
759
+ """Parse and resolve the variables section from YAML data.
760
+
761
+ Variables are resolved in order, allowing variables to reference
762
+ previously-defined variables using {{ var.name }} syntax.
763
+
764
+ Args:
765
+ data: Parsed YAML data (root level)
766
+ file_path: Path to the recipe file (for resolving relative file paths)
767
+
768
+ Returns:
769
+ Dictionary mapping variable names to resolved string values
770
+
771
+ Raises:
772
+ ValueError: For validation errors, undefined refs, or circular refs
773
+ """
774
+ if "variables" not in data:
775
+ return {}
776
+
777
+ vars_data = data["variables"]
778
+ if not isinstance(vars_data, dict):
779
+ raise ValueError("'variables' must be a dictionary")
780
+
781
+ resolved = {} # name -> resolved string value
782
+ resolution_stack = [] # For circular detection
783
+
784
+ for var_name, raw_value in vars_data.items():
785
+ _validate_variable_name(var_name)
786
+ resolved[var_name] = _resolve_variable_value(
787
+ var_name, raw_value, resolved, resolution_stack, file_path, data
788
+ )
789
+
790
+ return resolved
791
+
792
+
159
793
  def _parse_file_with_env(
160
794
  file_path: Path,
161
795
  namespace: str | None,
162
796
  project_root: Path,
163
797
  import_stack: list[Path] | None = None,
164
- ) -> tuple[dict[str, Task], dict[str, Environment], str]:
165
- """Parse file and extract tasks and environments.
798
+ ) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, str]]:
799
+ """Parse file and extract tasks, environments, and variables.
166
800
 
167
801
  Args:
168
802
  file_path: Path to YAML file
@@ -171,20 +805,38 @@ def _parse_file_with_env(
171
805
  import_stack: Stack of files being imported (for circular detection)
172
806
 
173
807
  Returns:
174
- Tuple of (tasks, environments, default_env_name)
808
+ Tuple of (tasks, environments, default_env_name, variables)
175
809
  """
176
810
  # Parse tasks normally
177
811
  tasks = _parse_file(file_path, namespace, project_root, import_stack)
178
812
 
179
- # Load YAML again to extract environments (only from root file)
813
+ # Load YAML again to extract environments and variables (only from root file)
180
814
  environments: dict[str, Environment] = {}
181
815
  default_env = ""
816
+ variables: dict[str, str] = {}
182
817
 
183
- # Only parse environments from the root file (namespace is None)
818
+ # Only parse environments and variables from the root file (namespace is None)
184
819
  if namespace is None:
185
820
  with open(file_path, "r") as f:
186
821
  data = yaml.safe_load(f)
187
822
 
823
+ # Parse variables first (so they can be used in environment preambles and tasks)
824
+ if data:
825
+ variables = _parse_variables_section(data, file_path)
826
+
827
+ # Apply variable substitution to all tasks
828
+ if variables:
829
+ from tasktree.substitution import substitute_variables
830
+
831
+ for task in tasks.values():
832
+ task.cmd = substitute_variables(task.cmd, variables)
833
+ task.desc = substitute_variables(task.desc, variables)
834
+ task.working_dir = substitute_variables(task.working_dir, variables)
835
+ task.inputs = [substitute_variables(inp, variables) for inp in task.inputs]
836
+ task.outputs = [substitute_variables(out, variables) for out in task.outputs]
837
+ # Substitute in argument default values (in arg spec strings)
838
+ task.args = [substitute_variables(arg, variables) for arg in task.args]
839
+
188
840
  if data and "environments" in data:
189
841
  env_data = data["environments"]
190
842
  if isinstance(env_data, dict):
@@ -201,21 +853,83 @@ def _parse_file_with_env(
201
853
  f"Environment '{env_name}' must be a dictionary"
202
854
  )
203
855
 
204
- # Parse environment configuration
856
+ # Parse common environment configuration
205
857
  shell = env_config.get("shell", "")
206
- if not shell:
858
+ args = env_config.get("args", [])
859
+ preamble = env_config.get("preamble", "")
860
+ working_dir = env_config.get("working_dir", "")
861
+
862
+ # Substitute variables in preamble
863
+ if preamble and variables:
864
+ from tasktree.substitution import substitute_variables
865
+ preamble = substitute_variables(preamble, variables)
866
+
867
+ # Parse Docker-specific fields
868
+ dockerfile = env_config.get("dockerfile", "")
869
+ context = env_config.get("context", "")
870
+ volumes = env_config.get("volumes", [])
871
+ ports = env_config.get("ports", [])
872
+ env_vars = env_config.get("env_vars", {})
873
+ extra_args = env_config.get("extra_args", [])
874
+ run_as_root = env_config.get("run_as_root", False)
875
+
876
+ # Validate environment type
877
+ if not shell and not dockerfile:
207
878
  raise ValueError(
208
- f"Environment '{env_name}' must specify 'shell'"
879
+ f"Environment '{env_name}' must specify either 'shell' "
880
+ f"(for shell environments) or 'dockerfile' (for Docker environments)"
209
881
  )
210
882
 
211
- args = env_config.get("args", [])
212
- preamble = env_config.get("preamble", "")
883
+ # Validate Docker environment requirements
884
+ if dockerfile and not context:
885
+ raise ValueError(
886
+ f"Docker environment '{env_name}' must specify 'context' "
887
+ f"when 'dockerfile' is specified"
888
+ )
889
+
890
+ # Validate that Dockerfile exists if specified
891
+ if dockerfile:
892
+ dockerfile_path = project_root / dockerfile
893
+ if not dockerfile_path.exists():
894
+ raise ValueError(
895
+ f"Environment '{env_name}': Dockerfile not found at {dockerfile_path}"
896
+ )
897
+
898
+ # Validate that context directory exists if specified
899
+ if context:
900
+ context_path = project_root / context
901
+ if not context_path.exists():
902
+ raise ValueError(
903
+ f"Environment '{env_name}': context directory not found at {context_path}"
904
+ )
905
+ if not context_path.is_dir():
906
+ raise ValueError(
907
+ f"Environment '{env_name}': context must be a directory, got {context_path}"
908
+ )
909
+
910
+ # Validate environment name (must be valid Docker tag)
911
+ if not env_name.replace("-", "").replace("_", "").isalnum():
912
+ raise ValueError(
913
+ f"Environment name '{env_name}' must be alphanumeric "
914
+ f"(with optional hyphens and underscores)"
915
+ )
213
916
 
214
917
  environments[env_name] = Environment(
215
- name=env_name, shell=shell, args=args, preamble=preamble
918
+ name=env_name,
919
+ shell=shell,
920
+ args=args,
921
+ preamble=preamble,
922
+ dockerfile=dockerfile,
923
+ context=context,
924
+ volumes=volumes,
925
+ ports=ports,
926
+ env_vars=env_vars,
927
+ working_dir=working_dir,
928
+ extra_args=extra_args,
929
+ run_as_root=run_as_root
216
930
  )
217
931
 
218
- return tasks, environments, default_env
932
+ return tasks, environments, default_env, variables
219
933
 
220
934
 
221
935
  def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
@@ -243,15 +957,17 @@ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
243
957
  project_root = recipe_path.parent
244
958
 
245
959
  # Parse main file - it will recursively handle all imports
246
- tasks, environments, default_env = _parse_file_with_env(
960
+ tasks, environments, default_env, variables = _parse_file_with_env(
247
961
  recipe_path, namespace=None, project_root=project_root
248
962
  )
249
963
 
250
964
  return Recipe(
251
965
  tasks=tasks,
252
966
  project_root=project_root,
967
+ recipe_path=recipe_path,
253
968
  environments=environments,
254
969
  default_env=default_env,
970
+ variables=variables,
255
971
  )
256
972
 
257
973
 
@@ -334,8 +1050,8 @@ def _parse_file(
334
1050
 
335
1051
  tasks.update(nested_tasks)
336
1052
 
337
- # Validate top-level keys (only imports, environments, and tasks are allowed)
338
- VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks"}
1053
+ # Validate top-level keys (only imports, environments, tasks, and variables are allowed)
1054
+ VALID_TOP_LEVEL_KEYS = {"imports", "environments", "tasks", "variables"}
339
1055
 
340
1056
  # Check if tasks key is missing when there appear to be task definitions at root
341
1057
  # Do this BEFORE checking for unknown keys, to provide better error message
@@ -425,8 +1141,13 @@ def _parse_file(
425
1141
  working_dir=working_dir,
426
1142
  args=task_data.get("args", []),
427
1143
  source_file=str(file_path),
1144
+ env=task_data.get("env", ""),
428
1145
  )
429
1146
 
1147
+ # Check for case-sensitive argument collisions
1148
+ if task.args:
1149
+ _check_case_sensitive_arg_collisions(task.args, full_name)
1150
+
430
1151
  tasks[full_name] = task
431
1152
 
432
1153
  # Remove current file from stack
@@ -435,28 +1156,118 @@ def _parse_file(
435
1156
  return tasks
436
1157
 
437
1158
 
438
- def parse_arg_spec(arg_spec: str) -> tuple[str, str, str | None]:
439
- """Parse argument specification.
1159
+ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
1160
+ """Check for exported arguments that differ only in case.
1161
+
1162
+ On Unix systems, environment variables are case-sensitive, but having
1163
+ args that differ only in case (e.g., $Server and $server) can be confusing.
1164
+ This function emits a warning if such collisions are detected.
440
1165
 
441
- Format: name:type=default
442
- - name is required
443
- - type is optional (defaults to 'str')
444
- - default is optional
1166
+ Args:
1167
+ args: List of argument specifications
1168
+ task_name: Name of the task (for warning message)
1169
+ """
1170
+ import sys
1171
+
1172
+ # Parse all exported arg names
1173
+ exported_names = []
1174
+ for arg_spec in args:
1175
+ parsed = parse_arg_spec(arg_spec)
1176
+ if parsed.is_exported:
1177
+ exported_names.append(parsed.name)
1178
+
1179
+ # Check for case collisions
1180
+ seen_lower = {}
1181
+ for name in exported_names:
1182
+ lower_name = name.lower()
1183
+ if lower_name in seen_lower:
1184
+ # Found a collision
1185
+ other_name = seen_lower[lower_name]
1186
+ if name != other_name: # Only warn if actual case differs
1187
+ print(
1188
+ f"Warning: Task '{task_name}' has exported arguments that differ only in case: "
1189
+ f"${other_name} and ${name}. "
1190
+ f"This may be confusing on case-sensitive systems.",
1191
+ file=sys.stderr
1192
+ )
1193
+ else:
1194
+ seen_lower[lower_name] = name
1195
+
1196
+
1197
+ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
1198
+ """Parse argument specification from YAML.
1199
+
1200
+ Supports both string format and dictionary format:
1201
+
1202
+ String format:
1203
+ - Simple name: "argname"
1204
+ - Exported (becomes env var): "$argname"
1205
+ - With default: "argname=value" or "$argname=value"
1206
+ - Legacy type syntax: "argname:type=value" (for backwards compat)
1207
+
1208
+ Dictionary format:
1209
+ - argname: { default: "value" }
1210
+ - argname: { type: int, default: 42 }
1211
+ - argname: { type: int, min: 1, max: 100 }
1212
+ - argname: { type: str, choices: ["dev", "staging", "prod"] }
1213
+ - $argname: { default: "value" } # Exported
445
1214
 
446
1215
  Args:
447
- arg_spec: Argument specification string
1216
+ arg_spec: Argument specification (string or dict with single key)
448
1217
 
449
1218
  Returns:
450
- Tuple of (name, type, default)
1219
+ ArgSpec object containing parsed argument information
451
1220
 
452
1221
  Examples:
453
1222
  >>> parse_arg_spec("environment")
454
- ('environment', 'str', None)
455
- >>> parse_arg_spec("region=eu-west-1")
456
- ('region', 'str', 'eu-west-1')
457
- >>> parse_arg_spec("port:int=8080")
458
- ('port', 'int', '8080')
1223
+ ArgSpec(name='environment', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=None)
1224
+ >>> parse_arg_spec({"key2": {"default": "foo"}})
1225
+ ArgSpec(name='key2', arg_type='str', default='foo', is_exported=False, min_val=None, max_val=None, choices=None)
1226
+ >>> parse_arg_spec({"key3": {"type": "int", "default": 42}})
1227
+ ArgSpec(name='key3', arg_type='int', default='42', is_exported=False, min_val=None, max_val=None, choices=None)
1228
+ >>> parse_arg_spec({"replicas": {"type": "int", "min": 1, "max": 100}})
1229
+ ArgSpec(name='replicas', arg_type='int', default=None, is_exported=False, min_val=1, max_val=100, choices=None)
1230
+ >>> parse_arg_spec({"env": {"type": "str", "choices": ["dev", "prod"]}})
1231
+ ArgSpec(name='env', arg_type='str', default=None, is_exported=False, min_val=None, max_val=None, choices=['dev', 'prod'])
1232
+
1233
+ Raises:
1234
+ ValueError: If argument specification is invalid
459
1235
  """
1236
+ # Handle dictionary format: { argname: { type: ..., default: ... } }
1237
+ if isinstance(arg_spec, dict):
1238
+ if len(arg_spec) != 1:
1239
+ raise ValueError(
1240
+ f"Argument dictionary must have exactly one key (the argument name), got: {list(arg_spec.keys())}"
1241
+ )
1242
+
1243
+ # Extract the argument name and its configuration
1244
+ arg_name, config = next(iter(arg_spec.items()))
1245
+
1246
+ # Check if argument is exported (name starts with $)
1247
+ is_exported = arg_name.startswith("$")
1248
+ if is_exported:
1249
+ arg_name = arg_name[1:] # Remove $ prefix
1250
+
1251
+ # Validate argument name
1252
+ if not arg_name or not isinstance(arg_name, str):
1253
+ raise ValueError(
1254
+ f"Argument name must be a non-empty string, got: {arg_name!r}"
1255
+ )
1256
+
1257
+ # Config must be a dictionary
1258
+ if not isinstance(config, dict):
1259
+ raise ValueError(
1260
+ f"Argument '{arg_name}' configuration must be a dictionary, got: {type(config).__name__}"
1261
+ )
1262
+
1263
+ return _parse_arg_dict(arg_name, config, is_exported)
1264
+
1265
+ # Handle string format
1266
+ # Check if argument is exported (starts with $)
1267
+ is_exported = arg_spec.startswith("$")
1268
+ if is_exported:
1269
+ arg_spec = arg_spec[1:] # Remove $ prefix
1270
+
460
1271
  # Split on = to separate name:type from default
461
1272
  if "=" in arg_spec:
462
1273
  name_type, default = arg_spec.split("=", 1)
@@ -467,8 +1278,264 @@ def parse_arg_spec(arg_spec: str) -> tuple[str, str, str | None]:
467
1278
  # Split on : to separate name from type
468
1279
  if ":" in name_type:
469
1280
  name, arg_type = name_type.split(":", 1)
1281
+
1282
+ # Exported arguments cannot have type annotations
1283
+ if is_exported:
1284
+ raise ValueError(
1285
+ f"Type annotations not allowed on exported arguments\n"
1286
+ f"In argument: ${name}:{arg_type}\n\n"
1287
+ f"Exported arguments are always strings. Remove the type annotation:\n"
1288
+ f" args: [${name}]"
1289
+ )
470
1290
  else:
471
1291
  name = name_type
472
1292
  arg_type = "str"
473
1293
 
474
- return name, arg_type, default
1294
+ # String format doesn't support min/max/choices
1295
+ return ArgSpec(
1296
+ name=name,
1297
+ arg_type=arg_type,
1298
+ default=default,
1299
+ is_exported=is_exported,
1300
+ min_val=None,
1301
+ max_val=None,
1302
+ choices=None
1303
+ )
1304
+
1305
+
1306
+ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
1307
+ """Parse argument specification from dictionary format.
1308
+
1309
+ Args:
1310
+ arg_name: Name of the argument
1311
+ config: Dictionary with optional keys: type, default, min, max, choices
1312
+ is_exported: Whether argument should be exported to environment
1313
+
1314
+ Returns:
1315
+ ArgSpec object containing the parsed argument specification
1316
+
1317
+ Raises:
1318
+ ValueError: If dictionary format is invalid
1319
+ """
1320
+ # Validate dictionary keys
1321
+ valid_keys = {"type", "default", "min", "max", "choices"}
1322
+ invalid_keys = set(config.keys()) - valid_keys
1323
+ if invalid_keys:
1324
+ raise ValueError(
1325
+ f"Invalid keys in argument '{arg_name}' configuration: {', '.join(sorted(invalid_keys))}\n"
1326
+ f"Valid keys are: {', '.join(sorted(valid_keys))}"
1327
+ )
1328
+
1329
+ # Extract values
1330
+ arg_type = config.get("type")
1331
+ default = config.get("default")
1332
+ min_val = config.get("min")
1333
+ max_val = config.get("max")
1334
+ choices = config.get("choices")
1335
+
1336
+ # Track if an explicit type was provided (for validation later)
1337
+ explicit_type = arg_type
1338
+
1339
+ # Exported arguments cannot have type annotations
1340
+ if is_exported and arg_type is not None:
1341
+ raise ValueError(
1342
+ f"Type annotations not allowed on exported argument '${arg_name}'\n"
1343
+ f"Exported arguments are always strings. Remove the 'type' field"
1344
+ )
1345
+
1346
+ # Validate choices
1347
+ if choices is not None:
1348
+ # Validate choices is a list
1349
+ if not isinstance(choices, list):
1350
+ raise ValueError(
1351
+ f"Argument '{arg_name}': choices must be a list"
1352
+ )
1353
+
1354
+ # Validate choices is not empty
1355
+ if len(choices) == 0:
1356
+ raise ValueError(
1357
+ f"Argument '{arg_name}': choices list cannot be empty"
1358
+ )
1359
+
1360
+ # Check for mutual exclusivity with min/max
1361
+ if min_val is not None or max_val is not None:
1362
+ raise ValueError(
1363
+ f"Argument '{arg_name}': choices and min/max are mutually exclusive.\n"
1364
+ f"Use either choices for discrete values or min/max for ranges, not both."
1365
+ )
1366
+
1367
+ # Infer type from default, min, max, or choices if type not specified
1368
+ if arg_type is None:
1369
+ # Collect all values that can help infer type
1370
+ inferred_types = []
1371
+
1372
+ if default is not None:
1373
+ inferred_types.append(("default", _infer_variable_type(default)))
1374
+ if min_val is not None:
1375
+ inferred_types.append(("min", _infer_variable_type(min_val)))
1376
+ if max_val is not None:
1377
+ inferred_types.append(("max", _infer_variable_type(max_val)))
1378
+ if choices is not None and len(choices) > 0:
1379
+ inferred_types.append(("choices[0]", _infer_variable_type(choices[0])))
1380
+
1381
+ if inferred_types:
1382
+ # Check all inferred types are consistent
1383
+ first_name, first_type = inferred_types[0]
1384
+ for value_name, value_type in inferred_types[1:]:
1385
+ if value_type != first_type:
1386
+ # Build error message showing the conflicting types
1387
+ type_info = ", ".join([f"{name}={vtype}" for name, vtype in inferred_types])
1388
+ raise ValueError(
1389
+ f"Argument '{arg_name}': inconsistent types inferred from min, max, and default.\n"
1390
+ f"All values must have the same type.\n"
1391
+ f"Found: {type_info}"
1392
+ )
1393
+
1394
+ # All types are consistent, use the inferred type
1395
+ arg_type = first_type
1396
+ else:
1397
+ # No values to infer from, default to string
1398
+ arg_type = "str"
1399
+ else:
1400
+ # Explicit type was provided - validate that default matches it
1401
+ # (min/max validation happens later, after the min/max numeric check)
1402
+ if default is not None:
1403
+ default_type = _infer_variable_type(default)
1404
+ if default_type != explicit_type:
1405
+ raise ValueError(
1406
+ f"Default value for argument '{arg_name}' is incompatible with type '{explicit_type}': "
1407
+ f"default has type '{default_type}'"
1408
+ )
1409
+
1410
+ # Validate min/max are only used with numeric types
1411
+ if (min_val is not None or max_val is not None) and arg_type not in ("int", "float"):
1412
+ raise ValueError(
1413
+ f"Argument '{arg_name}': min/max constraints are only supported for 'int' and 'float' types, "
1414
+ f"not '{arg_type}'"
1415
+ )
1416
+
1417
+ # If explicit type was provided, validate min/max match that type
1418
+ if explicit_type is not None and arg_type in ("int", "float"):
1419
+ type_mismatches = []
1420
+ if min_val is not None:
1421
+ min_type = _infer_variable_type(min_val)
1422
+ if min_type != explicit_type:
1423
+ type_mismatches.append(f"min value has type '{min_type}'")
1424
+ if max_val is not None:
1425
+ max_type = _infer_variable_type(max_val)
1426
+ if max_type != explicit_type:
1427
+ type_mismatches.append(f"max value has type '{max_type}'")
1428
+
1429
+ if type_mismatches:
1430
+ raise ValueError(
1431
+ f"Argument '{arg_name}': explicit type '{explicit_type}' does not match value types.\n"
1432
+ + "\n".join([f" - {mismatch}" for mismatch in type_mismatches])
1433
+ )
1434
+
1435
+ # Validate min <= max
1436
+ if min_val is not None and max_val is not None:
1437
+ if min_val > max_val:
1438
+ raise ValueError(
1439
+ f"Argument '{arg_name}': min ({min_val}) must be less than or equal to max ({max_val})"
1440
+ )
1441
+
1442
+ # Validate type name and get validator
1443
+ try:
1444
+ validator = get_click_type(arg_type)
1445
+ except ValueError:
1446
+ raise ValueError(
1447
+ f"Unknown type in argument '{arg_name}': {arg_type}\n"
1448
+ f"Supported types: str, int, float, bool, path, datetime, ip, ipv4, ipv6, email, hostname"
1449
+ )
1450
+
1451
+ # Validate choices
1452
+ if choices is not None:
1453
+ # Boolean types cannot have choices
1454
+ if arg_type == "bool":
1455
+ raise ValueError(
1456
+ f"Argument '{arg_name}': boolean types cannot have choices.\n"
1457
+ f"Boolean values are already limited to true/false."
1458
+ )
1459
+
1460
+ # Validate all choices are the same type
1461
+ if len(choices) > 0:
1462
+ first_choice_type = _infer_variable_type(choices[0])
1463
+
1464
+ # If explicit type was provided, validate choices match it
1465
+ if explicit_type is not None and first_choice_type != explicit_type:
1466
+ raise ValueError(
1467
+ f"Argument '{arg_name}': choice values do not match explicit type '{explicit_type}'.\n"
1468
+ f"First choice has type '{first_choice_type}'"
1469
+ )
1470
+
1471
+ # Check all choices have the same type
1472
+ for i, choice in enumerate(choices[1:], start=1):
1473
+ choice_type = _infer_variable_type(choice)
1474
+ if choice_type != first_choice_type:
1475
+ raise ValueError(
1476
+ f"Argument '{arg_name}': all choice values must have the same type.\n"
1477
+ f"First choice has type '{first_choice_type}', but choice at index {i} has type '{choice_type}'"
1478
+ )
1479
+
1480
+ # Validate all choices are valid for the type
1481
+ for i, choice in enumerate(choices):
1482
+ try:
1483
+ validator.convert(choice, None, None)
1484
+ except Exception as e:
1485
+ raise ValueError(
1486
+ f"Argument '{arg_name}': choice at index {i} ({choice!r}) is invalid for type '{arg_type}': {e}"
1487
+ )
1488
+
1489
+ # Validate and convert default value
1490
+ if default is not None:
1491
+ # Validate that default is compatible with the declared type
1492
+ if arg_type != "str":
1493
+ # Validate that the default value is compatible with the type
1494
+ try:
1495
+ # Use the validator we already retrieved
1496
+ converted_default = validator.convert(default, None, None)
1497
+ except Exception as e:
1498
+ raise ValueError(
1499
+ f"Default value for argument '{arg_name}' is incompatible with type '{arg_type}': {e}"
1500
+ )
1501
+
1502
+ # Validate default is within min/max range
1503
+ if min_val is not None and converted_default < min_val:
1504
+ raise ValueError(
1505
+ f"Default value for argument '{arg_name}' ({default}) is less than min ({min_val})"
1506
+ )
1507
+ if max_val is not None and converted_default > max_val:
1508
+ raise ValueError(
1509
+ f"Default value for argument '{arg_name}' ({default}) is greater than max ({max_val})"
1510
+ )
1511
+
1512
+ # Validate default is in choices list
1513
+ if choices is not None and converted_default not in choices:
1514
+ raise ValueError(
1515
+ f"Default value for argument '{arg_name}' ({default}) is not in the choices list.\n"
1516
+ f"Valid choices: {choices}"
1517
+ )
1518
+
1519
+ # After validation, convert to string for storage
1520
+ default_str = str(default)
1521
+ else:
1522
+ # For string type, validate default is in choices
1523
+ if choices is not None and default not in choices:
1524
+ raise ValueError(
1525
+ f"Default value for argument '{arg_name}' ({default}) is not in the choices list.\n"
1526
+ f"Valid choices: {choices}"
1527
+ )
1528
+ default_str = str(default)
1529
+ else:
1530
+ # None remains None (not the string "None")
1531
+ default_str = None
1532
+
1533
+ return ArgSpec(
1534
+ name=arg_name,
1535
+ arg_type=arg_type,
1536
+ default=default_str,
1537
+ is_exported=is_exported,
1538
+ min_val=min_val,
1539
+ max_val=max_val,
1540
+ choices=choices
1541
+ )