tasktree 0.0.7__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/cli.py +24 -16
- tasktree/docker.py +25 -0
- tasktree/executor.py +261 -22
- tasktree/hasher.py +36 -2
- tasktree/parser.py +1025 -24
- tasktree/substitution.py +195 -0
- tasktree/types.py +11 -2
- tasktree-0.0.8.dist-info/METADATA +1149 -0
- tasktree-0.0.8.dist-info/RECORD +15 -0
- tasktree-0.0.7.dist-info/METADATA +0 -654
- tasktree-0.0.7.dist-info/RECORD +0 -14
- {tasktree-0.0.7.dist-info → tasktree-0.0.8.dist-info}/WHEEL +0 -0
- {tasktree-0.0.7.dist-info → tasktree-0.0.8.dist-info}/entry_points.txt +0 -0
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."""
|
|
@@ -68,15 +76,39 @@ class Task:
|
|
|
68
76
|
self.args = [self.args]
|
|
69
77
|
|
|
70
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
|
+
|
|
71
101
|
@dataclass
|
|
72
102
|
class Recipe:
|
|
73
103
|
"""Represents a parsed recipe file with all tasks."""
|
|
74
104
|
|
|
75
105
|
tasks: dict[str, Task]
|
|
76
106
|
project_root: Path
|
|
107
|
+
recipe_path: Path # Path to the recipe file
|
|
77
108
|
environments: dict[str, Environment] = field(default_factory=dict)
|
|
78
109
|
default_env: str = "" # Name of default environment
|
|
79
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)
|
|
80
112
|
|
|
81
113
|
def get_task(self, name: str) -> Task | None:
|
|
82
114
|
"""Get task by name.
|
|
@@ -168,13 +200,603 @@ def find_recipe_file(start_dir: Path | None = None) -> Path | None:
|
|
|
168
200
|
return None
|
|
169
201
|
|
|
170
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
|
+
|
|
171
793
|
def _parse_file_with_env(
|
|
172
794
|
file_path: Path,
|
|
173
795
|
namespace: str | None,
|
|
174
796
|
project_root: Path,
|
|
175
797
|
import_stack: list[Path] | None = None,
|
|
176
|
-
) -> tuple[dict[str, Task], dict[str, Environment], str]:
|
|
177
|
-
"""Parse file and extract tasks and
|
|
798
|
+
) -> tuple[dict[str, Task], dict[str, Environment], str, dict[str, str]]:
|
|
799
|
+
"""Parse file and extract tasks, environments, and variables.
|
|
178
800
|
|
|
179
801
|
Args:
|
|
180
802
|
file_path: Path to YAML file
|
|
@@ -183,20 +805,38 @@ def _parse_file_with_env(
|
|
|
183
805
|
import_stack: Stack of files being imported (for circular detection)
|
|
184
806
|
|
|
185
807
|
Returns:
|
|
186
|
-
Tuple of (tasks, environments, default_env_name)
|
|
808
|
+
Tuple of (tasks, environments, default_env_name, variables)
|
|
187
809
|
"""
|
|
188
810
|
# Parse tasks normally
|
|
189
811
|
tasks = _parse_file(file_path, namespace, project_root, import_stack)
|
|
190
812
|
|
|
191
|
-
# Load YAML again to extract environments (only from root file)
|
|
813
|
+
# Load YAML again to extract environments and variables (only from root file)
|
|
192
814
|
environments: dict[str, Environment] = {}
|
|
193
815
|
default_env = ""
|
|
816
|
+
variables: dict[str, str] = {}
|
|
194
817
|
|
|
195
|
-
# Only parse environments from the root file (namespace is None)
|
|
818
|
+
# Only parse environments and variables from the root file (namespace is None)
|
|
196
819
|
if namespace is None:
|
|
197
820
|
with open(file_path, "r") as f:
|
|
198
821
|
data = yaml.safe_load(f)
|
|
199
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
|
+
|
|
200
840
|
if data and "environments" in data:
|
|
201
841
|
env_data = data["environments"]
|
|
202
842
|
if isinstance(env_data, dict):
|
|
@@ -219,12 +859,19 @@ def _parse_file_with_env(
|
|
|
219
859
|
preamble = env_config.get("preamble", "")
|
|
220
860
|
working_dir = env_config.get("working_dir", "")
|
|
221
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
|
+
|
|
222
867
|
# Parse Docker-specific fields
|
|
223
868
|
dockerfile = env_config.get("dockerfile", "")
|
|
224
869
|
context = env_config.get("context", "")
|
|
225
870
|
volumes = env_config.get("volumes", [])
|
|
226
871
|
ports = env_config.get("ports", [])
|
|
227
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)
|
|
228
875
|
|
|
229
876
|
# Validate environment type
|
|
230
877
|
if not shell and not dockerfile:
|
|
@@ -278,9 +925,11 @@ def _parse_file_with_env(
|
|
|
278
925
|
ports=ports,
|
|
279
926
|
env_vars=env_vars,
|
|
280
927
|
working_dir=working_dir,
|
|
928
|
+
extra_args=extra_args,
|
|
929
|
+
run_as_root=run_as_root
|
|
281
930
|
)
|
|
282
931
|
|
|
283
|
-
return tasks, environments, default_env
|
|
932
|
+
return tasks, environments, default_env, variables
|
|
284
933
|
|
|
285
934
|
|
|
286
935
|
def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
@@ -308,15 +957,17 @@ def parse_recipe(recipe_path: Path, project_root: Path | None = None) -> Recipe:
|
|
|
308
957
|
project_root = recipe_path.parent
|
|
309
958
|
|
|
310
959
|
# Parse main file - it will recursively handle all imports
|
|
311
|
-
tasks, environments, default_env = _parse_file_with_env(
|
|
960
|
+
tasks, environments, default_env, variables = _parse_file_with_env(
|
|
312
961
|
recipe_path, namespace=None, project_root=project_root
|
|
313
962
|
)
|
|
314
963
|
|
|
315
964
|
return Recipe(
|
|
316
965
|
tasks=tasks,
|
|
317
966
|
project_root=project_root,
|
|
967
|
+
recipe_path=recipe_path,
|
|
318
968
|
environments=environments,
|
|
319
969
|
default_env=default_env,
|
|
970
|
+
variables=variables,
|
|
320
971
|
)
|
|
321
972
|
|
|
322
973
|
|
|
@@ -399,8 +1050,8 @@ def _parse_file(
|
|
|
399
1050
|
|
|
400
1051
|
tasks.update(nested_tasks)
|
|
401
1052
|
|
|
402
|
-
# Validate top-level keys (only imports, environments, and
|
|
403
|
-
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"}
|
|
404
1055
|
|
|
405
1056
|
# Check if tasks key is missing when there appear to be task definitions at root
|
|
406
1057
|
# Do this BEFORE checking for unknown keys, to provide better error message
|
|
@@ -493,6 +1144,10 @@ def _parse_file(
|
|
|
493
1144
|
env=task_data.get("env", ""),
|
|
494
1145
|
)
|
|
495
1146
|
|
|
1147
|
+
# Check for case-sensitive argument collisions
|
|
1148
|
+
if task.args:
|
|
1149
|
+
_check_case_sensitive_arg_collisions(task.args, full_name)
|
|
1150
|
+
|
|
496
1151
|
tasks[full_name] = task
|
|
497
1152
|
|
|
498
1153
|
# Remove current file from stack
|
|
@@ -501,28 +1156,118 @@ def _parse_file(
|
|
|
501
1156
|
return tasks
|
|
502
1157
|
|
|
503
1158
|
|
|
504
|
-
def
|
|
505
|
-
"""
|
|
1159
|
+
def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> None:
|
|
1160
|
+
"""Check for exported arguments that differ only in case.
|
|
506
1161
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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.
|
|
1165
|
+
|
|
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
|
|
511
1214
|
|
|
512
1215
|
Args:
|
|
513
|
-
arg_spec: Argument specification string
|
|
1216
|
+
arg_spec: Argument specification (string or dict with single key)
|
|
514
1217
|
|
|
515
1218
|
Returns:
|
|
516
|
-
|
|
1219
|
+
ArgSpec object containing parsed argument information
|
|
517
1220
|
|
|
518
1221
|
Examples:
|
|
519
1222
|
>>> parse_arg_spec("environment")
|
|
520
|
-
('environment', 'str', None)
|
|
521
|
-
>>> parse_arg_spec("
|
|
522
|
-
('
|
|
523
|
-
>>> parse_arg_spec("
|
|
524
|
-
('
|
|
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
|
|
525
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
|
+
|
|
526
1271
|
# Split on = to separate name:type from default
|
|
527
1272
|
if "=" in arg_spec:
|
|
528
1273
|
name_type, default = arg_spec.split("=", 1)
|
|
@@ -533,8 +1278,264 @@ def parse_arg_spec(arg_spec: str) -> tuple[str, str, str | None]:
|
|
|
533
1278
|
# Split on : to separate name from type
|
|
534
1279
|
if ":" in name_type:
|
|
535
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
|
+
)
|
|
536
1290
|
else:
|
|
537
1291
|
name = name_type
|
|
538
1292
|
arg_type = "str"
|
|
539
1293
|
|
|
540
|
-
|
|
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
|
+
)
|