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/cli.py +24 -16
- tasktree/docker.py +438 -0
- tasktree/executor.py +526 -50
- tasktree/graph.py +30 -1
- tasktree/hasher.py +63 -2
- tasktree/parser.py +1099 -32
- tasktree/state.py +1 -1
- 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.6.dist-info/METADATA +0 -699
- tasktree-0.0.6.dist-info/RECORD +0 -13
- {tasktree-0.0.6.dist-info → tasktree-0.0.8.dist-info}/WHEEL +0 -0
- {tasktree-0.0.6.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."""
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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,
|
|
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
|
|
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
|
|
439
|
-
"""
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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("
|
|
456
|
-
('
|
|
457
|
-
>>> parse_arg_spec("
|
|
458
|
-
('
|
|
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
|
-
|
|
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
|
+
)
|