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/cli.py
CHANGED
|
@@ -375,8 +375,8 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
375
375
|
|
|
376
376
|
parsed_specs = []
|
|
377
377
|
for spec in arg_specs:
|
|
378
|
-
|
|
379
|
-
parsed_specs.append(
|
|
378
|
+
parsed = parse_arg_spec(spec)
|
|
379
|
+
parsed_specs.append(parsed)
|
|
380
380
|
|
|
381
381
|
args_dict = {}
|
|
382
382
|
positional_index = 0
|
|
@@ -386,41 +386,49 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
|
|
|
386
386
|
if "=" in value_str:
|
|
387
387
|
arg_name, arg_value = value_str.split("=", 1)
|
|
388
388
|
# Find the spec for this argument
|
|
389
|
-
spec = next((s for s in parsed_specs if s
|
|
389
|
+
spec = next((s for s in parsed_specs if s.name == arg_name), None)
|
|
390
390
|
if spec is None:
|
|
391
391
|
console.print(f"[red]Unknown argument: {arg_name}[/red]")
|
|
392
392
|
raise typer.Exit(1)
|
|
393
|
-
name, arg_type, default = spec
|
|
394
393
|
else:
|
|
395
394
|
# Positional argument
|
|
396
395
|
if positional_index >= len(parsed_specs):
|
|
397
396
|
console.print(f"[red]Too many arguments[/red]")
|
|
398
397
|
raise typer.Exit(1)
|
|
399
|
-
|
|
398
|
+
spec = parsed_specs[positional_index]
|
|
400
399
|
arg_value = value_str
|
|
401
400
|
positional_index += 1
|
|
402
401
|
|
|
403
|
-
# Convert value to appropriate type
|
|
402
|
+
# Convert value to appropriate type (exported args are always strings)
|
|
404
403
|
try:
|
|
405
|
-
click_type = get_click_type(arg_type)
|
|
404
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
406
405
|
converted_value = click_type.convert(arg_value, None, None)
|
|
407
|
-
|
|
406
|
+
|
|
407
|
+
# Validate choices after type conversion
|
|
408
|
+
if spec.choices is not None and converted_value not in spec.choices:
|
|
409
|
+
console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
|
|
410
|
+
console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
|
|
413
|
+
args_dict[spec.name] = converted_value
|
|
414
|
+
except typer.Exit:
|
|
415
|
+
raise # Re-raise typer.Exit without wrapping
|
|
408
416
|
except Exception as e:
|
|
409
|
-
console.print(f"[red]Invalid value for {name}: {e}[/red]")
|
|
417
|
+
console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
|
|
410
418
|
raise typer.Exit(1)
|
|
411
419
|
|
|
412
420
|
# Fill in defaults for missing arguments
|
|
413
|
-
for
|
|
414
|
-
if name not in args_dict:
|
|
415
|
-
if default is not None:
|
|
421
|
+
for spec in parsed_specs:
|
|
422
|
+
if spec.name not in args_dict:
|
|
423
|
+
if spec.default is not None:
|
|
416
424
|
try:
|
|
417
|
-
click_type = get_click_type(arg_type)
|
|
418
|
-
args_dict[name] = click_type.convert(default, None, None)
|
|
425
|
+
click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
|
|
426
|
+
args_dict[spec.name] = click_type.convert(spec.default, None, None)
|
|
419
427
|
except Exception as e:
|
|
420
|
-
console.print(f"[red]Invalid default value for {name}: {e}[/red]")
|
|
428
|
+
console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
|
|
421
429
|
raise typer.Exit(1)
|
|
422
430
|
else:
|
|
423
|
-
console.print(f"[red]Missing required argument: {name}[/red]")
|
|
431
|
+
console.print(f"[red]Missing required argument: {spec.name}[/red]")
|
|
424
432
|
raise typer.Exit(1)
|
|
425
433
|
|
|
426
434
|
return args_dict
|
tasktree/docker.py
CHANGED
|
@@ -6,6 +6,7 @@ Provides Docker image building and container execution capabilities.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
|
+
import platform
|
|
9
10
|
import re
|
|
10
11
|
import subprocess
|
|
11
12
|
import time
|
|
@@ -39,6 +40,22 @@ class DockerManager:
|
|
|
39
40
|
self._project_root = project_root
|
|
40
41
|
self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
|
|
41
42
|
|
|
43
|
+
def _should_add_user_flag(self) -> bool:
|
|
44
|
+
"""Check if --user flag should be added to docker run.
|
|
45
|
+
|
|
46
|
+
Returns False on Windows (where Docker Desktop handles UID mapping automatically).
|
|
47
|
+
Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if --user flag should be added, False otherwise
|
|
51
|
+
"""
|
|
52
|
+
# Skip on Windows - Docker Desktop handles UID mapping differently
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
# Check if os.getuid() and os.getgid() are available (Linux/macOS)
|
|
57
|
+
return hasattr(os, "getuid") and hasattr(os, "getgid")
|
|
58
|
+
|
|
42
59
|
def ensure_image_built(self, env: Environment) -> tuple[str, str]:
|
|
43
60
|
"""Build Docker image if not already built this invocation.
|
|
44
61
|
|
|
@@ -127,6 +144,14 @@ class DockerManager:
|
|
|
127
144
|
# Build docker run command
|
|
128
145
|
docker_cmd = ["docker", "run", "--rm"]
|
|
129
146
|
|
|
147
|
+
# Add user mapping (run as current host user) unless explicitly disabled or on Windows
|
|
148
|
+
if not env.run_as_root and self._should_add_user_flag():
|
|
149
|
+
uid = os.getuid()
|
|
150
|
+
gid = os.getgid()
|
|
151
|
+
docker_cmd.extend(["--user", f"{uid}:{gid}"])
|
|
152
|
+
|
|
153
|
+
docker_cmd.extend(env.extra_args)
|
|
154
|
+
|
|
130
155
|
# Add volume mounts
|
|
131
156
|
for volume in env.volumes:
|
|
132
157
|
# Resolve volume paths
|
tasktree/executor.py
CHANGED
|
@@ -9,14 +9,14 @@ import subprocess
|
|
|
9
9
|
import tempfile
|
|
10
10
|
import time
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
|
-
from datetime import datetime
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from tasktree import docker as docker_module
|
|
17
17
|
from tasktree.graph import get_implicit_inputs, resolve_execution_order
|
|
18
18
|
from tasktree.hasher import hash_args, hash_task, make_cache_key
|
|
19
|
-
from tasktree.parser import Recipe, Task
|
|
19
|
+
from tasktree.parser import Recipe, Task, Environment
|
|
20
20
|
from tasktree.state import StateManager, TaskState
|
|
21
21
|
|
|
22
22
|
|
|
@@ -41,6 +41,18 @@ class ExecutionError(Exception):
|
|
|
41
41
|
class Executor:
|
|
42
42
|
"""Executes tasks with incremental execution logic."""
|
|
43
43
|
|
|
44
|
+
# Protected environment variables that cannot be overridden by exported args
|
|
45
|
+
PROTECTED_ENV_VARS = {
|
|
46
|
+
'PATH',
|
|
47
|
+
'LD_LIBRARY_PATH',
|
|
48
|
+
'LD_PRELOAD',
|
|
49
|
+
'PYTHONPATH',
|
|
50
|
+
'HOME',
|
|
51
|
+
'SHELL',
|
|
52
|
+
'USER',
|
|
53
|
+
'LOGNAME',
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
def __init__(self, recipe: Recipe, state_manager: StateManager):
|
|
45
57
|
"""Initialize executor.
|
|
46
58
|
|
|
@@ -52,6 +64,106 @@ class Executor:
|
|
|
52
64
|
self.state = state_manager
|
|
53
65
|
self.docker_manager = docker_module.DockerManager(recipe.project_root)
|
|
54
66
|
|
|
67
|
+
def _collect_early_builtin_variables(self, task: Task, timestamp: datetime) -> dict[str, str]:
|
|
68
|
+
"""Collect built-in variables that don't depend on working_dir.
|
|
69
|
+
|
|
70
|
+
These variables can be used in the working_dir field itself.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task: Task being executed
|
|
74
|
+
timestamp: Timestamp when task started execution
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dictionary mapping built-in variable names to their string values
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ExecutionError: If any built-in variable fails to resolve
|
|
81
|
+
"""
|
|
82
|
+
import os
|
|
83
|
+
|
|
84
|
+
builtin_vars = {}
|
|
85
|
+
|
|
86
|
+
# {{ tt.project_root }} - Absolute path to project root
|
|
87
|
+
builtin_vars['project_root'] = str(self.recipe.project_root.resolve())
|
|
88
|
+
|
|
89
|
+
# {{ tt.recipe_dir }} - Absolute path to directory containing the recipe file
|
|
90
|
+
builtin_vars['recipe_dir'] = str(self.recipe.recipe_path.parent.resolve())
|
|
91
|
+
|
|
92
|
+
# {{ tt.task_name }} - Name of currently executing task
|
|
93
|
+
builtin_vars['task_name'] = task.name
|
|
94
|
+
|
|
95
|
+
# {{ tt.timestamp }} - ISO8601 timestamp when task started execution
|
|
96
|
+
builtin_vars['timestamp'] = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
97
|
+
|
|
98
|
+
# {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
|
|
99
|
+
builtin_vars['timestamp_unix'] = str(int(timestamp.timestamp()))
|
|
100
|
+
|
|
101
|
+
# {{ tt.user_home }} - Current user's home directory (cross-platform)
|
|
102
|
+
try:
|
|
103
|
+
user_home = Path.home()
|
|
104
|
+
builtin_vars['user_home'] = str(user_home)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise ExecutionError(
|
|
107
|
+
f"Failed to get user home directory for {{ tt.user_home }}: {e}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# {{ tt.user_name }} - Current username (with fallback)
|
|
111
|
+
try:
|
|
112
|
+
user_name = os.getlogin()
|
|
113
|
+
except OSError:
|
|
114
|
+
# Fallback to environment variables if os.getlogin() fails
|
|
115
|
+
user_name = os.environ.get('USER') or os.environ.get('USERNAME') or 'unknown'
|
|
116
|
+
builtin_vars['user_name'] = user_name
|
|
117
|
+
|
|
118
|
+
return builtin_vars
|
|
119
|
+
|
|
120
|
+
def _collect_builtin_variables(self, task: Task, working_dir: Path, timestamp: datetime) -> dict[str, str]:
|
|
121
|
+
"""Collect built-in variables for task execution.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
task: Task being executed
|
|
125
|
+
working_dir: Resolved working directory for the task
|
|
126
|
+
timestamp: Timestamp when task started execution
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Dictionary mapping built-in variable names to their string values
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ExecutionError: If any built-in variable fails to resolve
|
|
133
|
+
"""
|
|
134
|
+
# Get early builtin vars (those that don't depend on working_dir)
|
|
135
|
+
builtin_vars = self._collect_early_builtin_variables(task, timestamp)
|
|
136
|
+
|
|
137
|
+
# {{ tt.working_dir }} - Absolute path to task's effective working directory
|
|
138
|
+
# This is added after working_dir is resolved to avoid circular dependency
|
|
139
|
+
builtin_vars['working_dir'] = str(working_dir.resolve())
|
|
140
|
+
|
|
141
|
+
return builtin_vars
|
|
142
|
+
|
|
143
|
+
def _prepare_env_with_exports(self, exported_env_vars: dict[str, str] | None = None) -> dict[str, str]:
|
|
144
|
+
"""Prepare environment with exported arguments.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Environment dict with exported args merged
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If an exported arg attempts to override a protected environment variable
|
|
154
|
+
"""
|
|
155
|
+
env = os.environ.copy()
|
|
156
|
+
if exported_env_vars:
|
|
157
|
+
# Check for protected environment variable overrides
|
|
158
|
+
for key in exported_env_vars:
|
|
159
|
+
if key in self.PROTECTED_ENV_VARS:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"Cannot override protected environment variable: {key}\n"
|
|
162
|
+
f"Protected variables are: {', '.join(sorted(self.PROTECTED_ENV_VARS))}"
|
|
163
|
+
)
|
|
164
|
+
env.update(exported_env_vars)
|
|
165
|
+
return env
|
|
166
|
+
|
|
55
167
|
def _get_platform_default_environment(self) -> tuple[str, list[str]]:
|
|
56
168
|
"""Get default shell and args for current platform.
|
|
57
169
|
|
|
@@ -301,11 +413,47 @@ class Executor:
|
|
|
301
413
|
Raises:
|
|
302
414
|
ExecutionError: If task execution fails
|
|
303
415
|
"""
|
|
304
|
-
#
|
|
305
|
-
|
|
416
|
+
# Capture timestamp at task start for consistency (in UTC)
|
|
417
|
+
task_start_time = datetime.now(timezone.utc)
|
|
418
|
+
|
|
419
|
+
# Parse task arguments to identify exported args
|
|
420
|
+
# Note: args_dict already has defaults applied by CLI (cli.py:413-424)
|
|
421
|
+
from tasktree.parser import parse_arg_spec
|
|
422
|
+
exported_args = set()
|
|
423
|
+
regular_args = {}
|
|
424
|
+
exported_env_vars = {}
|
|
425
|
+
|
|
426
|
+
for arg_spec in task.args:
|
|
427
|
+
parsed = parse_arg_spec(arg_spec)
|
|
428
|
+
if parsed.is_exported:
|
|
429
|
+
exported_args.add(parsed.name)
|
|
430
|
+
# Get value and convert to string for environment variable
|
|
431
|
+
# Value should always be in args_dict (CLI applies defaults)
|
|
432
|
+
if parsed.name in args_dict:
|
|
433
|
+
exported_env_vars[parsed.name] = str(args_dict[parsed.name])
|
|
434
|
+
else:
|
|
435
|
+
if parsed.name in args_dict:
|
|
436
|
+
regular_args[parsed.name] = args_dict[parsed.name]
|
|
437
|
+
|
|
438
|
+
# Collect early built-in variables (those that don't depend on working_dir)
|
|
439
|
+
# These can be used in the working_dir field itself
|
|
440
|
+
early_builtin_vars = self._collect_early_builtin_variables(task, task_start_time)
|
|
441
|
+
|
|
442
|
+
# Resolve working directory
|
|
443
|
+
# Validate that working_dir doesn't contain {{ tt.working_dir }} (circular dependency)
|
|
444
|
+
self._validate_no_working_dir_circular_ref(task.working_dir)
|
|
445
|
+
working_dir_str = self._substitute_builtin(task.working_dir, early_builtin_vars)
|
|
446
|
+
working_dir_str = self._substitute_args(working_dir_str, regular_args, exported_args)
|
|
447
|
+
working_dir_str = self._substitute_env(working_dir_str)
|
|
448
|
+
working_dir = self.recipe.project_root / working_dir_str
|
|
306
449
|
|
|
307
|
-
#
|
|
308
|
-
|
|
450
|
+
# Collect all built-in variables (including tt.working_dir now that it's resolved)
|
|
451
|
+
builtin_vars = self._collect_builtin_variables(task, working_dir, task_start_time)
|
|
452
|
+
|
|
453
|
+
# Substitute built-in variables, arguments, and environment variables in command
|
|
454
|
+
cmd = self._substitute_builtin(task.cmd, builtin_vars)
|
|
455
|
+
cmd = self._substitute_args(cmd, regular_args, exported_args)
|
|
456
|
+
cmd = self._substitute_env(cmd)
|
|
309
457
|
|
|
310
458
|
# Check if task uses Docker environment
|
|
311
459
|
env_name = self._get_effective_env_name(task)
|
|
@@ -319,22 +467,23 @@ class Executor:
|
|
|
319
467
|
# Route to Docker execution or regular execution
|
|
320
468
|
if env and env.dockerfile:
|
|
321
469
|
# Docker execution path
|
|
322
|
-
self._run_task_in_docker(task, env, cmd, working_dir)
|
|
470
|
+
self._run_task_in_docker(task, env, cmd, working_dir, exported_env_vars)
|
|
323
471
|
else:
|
|
324
472
|
# Regular execution path
|
|
325
473
|
shell, shell_args, preamble = self._resolve_environment(task)
|
|
326
474
|
|
|
327
475
|
# Detect multi-line commands (ignore trailing newlines from YAML folded blocks)
|
|
328
476
|
if "\n" in cmd.rstrip():
|
|
329
|
-
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble)
|
|
477
|
+
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble, exported_env_vars)
|
|
330
478
|
else:
|
|
331
|
-
self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args)
|
|
479
|
+
self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args, exported_env_vars)
|
|
332
480
|
|
|
333
481
|
# Update state
|
|
334
482
|
self._update_state(task, args_dict)
|
|
335
483
|
|
|
336
484
|
def _run_single_line_command(
|
|
337
|
-
self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str]
|
|
485
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str],
|
|
486
|
+
exported_env_vars: dict[str, str] | None = None
|
|
338
487
|
) -> None:
|
|
339
488
|
"""Execute a single-line command via shell.
|
|
340
489
|
|
|
@@ -344,10 +493,14 @@ class Executor:
|
|
|
344
493
|
task_name: Task name (for error messages)
|
|
345
494
|
shell: Shell executable to use
|
|
346
495
|
shell_args: Arguments to pass to shell
|
|
496
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
347
497
|
|
|
348
498
|
Raises:
|
|
349
499
|
ExecutionError: If command execution fails
|
|
350
500
|
"""
|
|
501
|
+
# Prepare environment with exported args
|
|
502
|
+
env = self._prepare_env_with_exports(exported_env_vars)
|
|
503
|
+
|
|
351
504
|
try:
|
|
352
505
|
# Build command: shell + args + cmd
|
|
353
506
|
full_cmd = [shell] + shell_args + [cmd]
|
|
@@ -356,6 +509,7 @@ class Executor:
|
|
|
356
509
|
cwd=working_dir,
|
|
357
510
|
check=True,
|
|
358
511
|
capture_output=False,
|
|
512
|
+
env=env,
|
|
359
513
|
)
|
|
360
514
|
except subprocess.CalledProcessError as e:
|
|
361
515
|
raise ExecutionError(
|
|
@@ -363,7 +517,8 @@ class Executor:
|
|
|
363
517
|
)
|
|
364
518
|
|
|
365
519
|
def _run_multiline_command(
|
|
366
|
-
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str
|
|
520
|
+
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str,
|
|
521
|
+
exported_env_vars: dict[str, str] | None = None
|
|
367
522
|
) -> None:
|
|
368
523
|
"""Execute a multi-line command via temporary script file.
|
|
369
524
|
|
|
@@ -373,10 +528,14 @@ class Executor:
|
|
|
373
528
|
task_name: Task name (for error messages)
|
|
374
529
|
shell: Shell to use for script execution
|
|
375
530
|
preamble: Preamble text to prepend to script
|
|
531
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
376
532
|
|
|
377
533
|
Raises:
|
|
378
534
|
ExecutionError: If command execution fails
|
|
379
535
|
"""
|
|
536
|
+
# Prepare environment with exported args
|
|
537
|
+
env = self._prepare_env_with_exports(exported_env_vars)
|
|
538
|
+
|
|
380
539
|
# Determine file extension based on platform
|
|
381
540
|
is_windows = platform.system() == "Windows"
|
|
382
541
|
script_ext = ".bat" if is_windows else ".sh"
|
|
@@ -417,6 +576,7 @@ class Executor:
|
|
|
417
576
|
cwd=working_dir,
|
|
418
577
|
check=True,
|
|
419
578
|
capture_output=False,
|
|
579
|
+
env=env,
|
|
420
580
|
)
|
|
421
581
|
except subprocess.CalledProcessError as e:
|
|
422
582
|
raise ExecutionError(
|
|
@@ -430,7 +590,8 @@ class Executor:
|
|
|
430
590
|
pass # Ignore cleanup errors
|
|
431
591
|
|
|
432
592
|
def _run_task_in_docker(
|
|
433
|
-
self, task: Task, env: Any, cmd: str, working_dir: Path
|
|
593
|
+
self, task: Task, env: Any, cmd: str, working_dir: Path,
|
|
594
|
+
exported_env_vars: dict[str, str] | None = None
|
|
434
595
|
) -> None:
|
|
435
596
|
"""Execute task inside Docker container.
|
|
436
597
|
|
|
@@ -439,6 +600,7 @@ class Executor:
|
|
|
439
600
|
env: Docker environment configuration
|
|
440
601
|
cmd: Command to execute
|
|
441
602
|
working_dir: Host working directory
|
|
603
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
442
604
|
|
|
443
605
|
Raises:
|
|
444
606
|
ExecutionError: If Docker execution fails
|
|
@@ -448,10 +610,26 @@ class Executor:
|
|
|
448
610
|
env.working_dir, task.working_dir
|
|
449
611
|
)
|
|
450
612
|
|
|
613
|
+
# Validate and merge exported args with env vars (exported args take precedence)
|
|
614
|
+
docker_env_vars = env.env_vars.copy() if env.env_vars else {}
|
|
615
|
+
if exported_env_vars:
|
|
616
|
+
# Check for protected environment variable overrides
|
|
617
|
+
for key in exported_env_vars:
|
|
618
|
+
if key in self.PROTECTED_ENV_VARS:
|
|
619
|
+
raise ValueError(
|
|
620
|
+
f"Cannot override protected environment variable: {key}\n"
|
|
621
|
+
f"Protected variables are: {', '.join(sorted(self.PROTECTED_ENV_VARS))}"
|
|
622
|
+
)
|
|
623
|
+
docker_env_vars.update(exported_env_vars)
|
|
624
|
+
|
|
625
|
+
# Create modified environment with merged env vars using dataclass replace
|
|
626
|
+
from dataclasses import replace
|
|
627
|
+
modified_env = replace(env, env_vars=docker_env_vars)
|
|
628
|
+
|
|
451
629
|
# Execute in container
|
|
452
630
|
try:
|
|
453
631
|
self.docker_manager.run_in_container(
|
|
454
|
-
env=
|
|
632
|
+
env=modified_env,
|
|
455
633
|
cmd=cmd,
|
|
456
634
|
working_dir=working_dir,
|
|
457
635
|
container_working_dir=container_working_dir,
|
|
@@ -459,21 +637,82 @@ class Executor:
|
|
|
459
637
|
except docker_module.DockerError as e:
|
|
460
638
|
raise ExecutionError(str(e)) from e
|
|
461
639
|
|
|
462
|
-
def
|
|
463
|
-
"""
|
|
640
|
+
def _validate_no_working_dir_circular_ref(self, text: str) -> None:
|
|
641
|
+
"""Validate that working_dir field does not contain {{ tt.working_dir }}.
|
|
642
|
+
|
|
643
|
+
Using {{ tt.working_dir }} in the working_dir field creates a circular dependency.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
text: The working_dir field value to validate
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
ExecutionError: If {{ tt.working_dir }} placeholder is found
|
|
650
|
+
"""
|
|
651
|
+
import re
|
|
652
|
+
# Pattern to match {{ tt.working_dir }} specifically
|
|
653
|
+
pattern = re.compile(r'\{\{\s*tt\s*\.\s*working_dir\s*\}\}')
|
|
654
|
+
|
|
655
|
+
if pattern.search(text):
|
|
656
|
+
raise ExecutionError(
|
|
657
|
+
f"Cannot use {{{{ tt.working_dir }}}} in the 'working_dir' field.\n\n"
|
|
658
|
+
f"This creates a circular dependency (working_dir cannot reference itself).\n"
|
|
659
|
+
f"Other built-in variables like {{{{ tt.task_name }}}} or {{{{ tt.timestamp }}}} are allowed."
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def _substitute_builtin(self, text: str, builtin_vars: dict[str, str]) -> str:
|
|
663
|
+
"""Substitute {{ tt.name }} placeholders in text.
|
|
664
|
+
|
|
665
|
+
Built-in variables are resolved at execution time.
|
|
464
666
|
|
|
465
667
|
Args:
|
|
466
|
-
|
|
467
|
-
|
|
668
|
+
text: Text with {{ tt.name }} placeholders
|
|
669
|
+
builtin_vars: Built-in variable values
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Text with built-in variables substituted
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
ValueError: If built-in variable is not defined
|
|
676
|
+
"""
|
|
677
|
+
from tasktree.substitution import substitute_builtin_variables
|
|
678
|
+
return substitute_builtin_variables(text, builtin_vars)
|
|
679
|
+
|
|
680
|
+
def _substitute_args(self, cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None) -> str:
|
|
681
|
+
"""Substitute {{ arg.name }} placeholders in command string.
|
|
682
|
+
|
|
683
|
+
Variables are already substituted at parse time by the parser.
|
|
684
|
+
This only handles runtime argument substitution.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
cmd: Command with {{ arg.name }} placeholders
|
|
688
|
+
args_dict: Argument values to substitute (only regular args)
|
|
689
|
+
exported_args: Set of argument names that are exported (not available for substitution)
|
|
468
690
|
|
|
469
691
|
Returns:
|
|
470
692
|
Command with arguments substituted
|
|
693
|
+
|
|
694
|
+
Raises:
|
|
695
|
+
ValueError: If an exported argument is used in template substitution
|
|
696
|
+
"""
|
|
697
|
+
from tasktree.substitution import substitute_arguments
|
|
698
|
+
return substitute_arguments(cmd, args_dict, exported_args)
|
|
699
|
+
|
|
700
|
+
def _substitute_env(self, text: str) -> str:
|
|
701
|
+
"""Substitute {{ env.NAME }} placeholders in text.
|
|
702
|
+
|
|
703
|
+
Environment variables are resolved at execution time from os.environ.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
text: Text with {{ env.NAME }} placeholders
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Text with environment variables substituted
|
|
710
|
+
|
|
711
|
+
Raises:
|
|
712
|
+
ValueError: If environment variable is not set
|
|
471
713
|
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
placeholder = f"{{{{{key}}}}}"
|
|
475
|
-
result = result.replace(placeholder, str(value))
|
|
476
|
-
return result
|
|
714
|
+
from tasktree.substitution import substitute_environment
|
|
715
|
+
return substitute_environment(text)
|
|
477
716
|
|
|
478
717
|
def _get_all_inputs(self, task: Task) -> list[str]:
|
|
479
718
|
"""Get all inputs for a task (explicit + implicit from dependencies).
|
tasktree/hasher.py
CHANGED
|
@@ -3,12 +3,46 @@ import json
|
|
|
3
3
|
from typing import Any, Optional
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def
|
|
6
|
+
def _arg_sort_key(arg: str | dict[str, Any]) -> str:
|
|
7
|
+
"""Extract the sort key from an arg for deterministic hashing.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
arg: Either a string arg or dict arg specification
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The argument name to use as a sort key
|
|
14
|
+
"""
|
|
15
|
+
if isinstance(arg, dict):
|
|
16
|
+
# Dict args have exactly one key - the argument name
|
|
17
|
+
# This is validated by parse_arg_spec in parser.py
|
|
18
|
+
return next(iter(arg.keys()))
|
|
19
|
+
return arg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | dict[str, Any]]:
|
|
23
|
+
normalized_args = []
|
|
24
|
+
for arg in args:
|
|
25
|
+
if isinstance(arg, dict):
|
|
26
|
+
# Deep copy and sort choices if present
|
|
27
|
+
normalized = {}
|
|
28
|
+
for key, value in arg.items():
|
|
29
|
+
if isinstance(value, dict) and 'choices' in value:
|
|
30
|
+
normalized[key] = {**value, 'choices': sorted(value['choices'], key=str)}
|
|
31
|
+
else:
|
|
32
|
+
normalized[key] = value
|
|
33
|
+
normalized_args.append(normalized)
|
|
34
|
+
else:
|
|
35
|
+
normalized_args.append(arg)
|
|
36
|
+
|
|
37
|
+
return normalized_args
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str | dict[str, Any]], env: str = "") -> str:
|
|
7
41
|
data = {
|
|
8
42
|
"cmd": cmd,
|
|
9
43
|
"outputs": sorted(outputs),
|
|
10
44
|
"working_dir": working_dir,
|
|
11
|
-
"args": sorted(args),
|
|
45
|
+
"args": sorted(_normalize_choices_lists(args), key=_arg_sort_key),
|
|
12
46
|
"env": env,
|
|
13
47
|
}
|
|
14
48
|
|