tasktree 0.0.21__py3-none-any.whl → 0.0.22__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 +91 -31
- tasktree/docker.py +24 -17
- tasktree/executor.py +263 -211
- tasktree/graph.py +15 -10
- tasktree/hasher.py +13 -6
- tasktree/parser.py +220 -121
- tasktree/state.py +7 -8
- tasktree/substitution.py +27 -15
- tasktree/types.py +29 -12
- {tasktree-0.0.21.dist-info → tasktree-0.0.22.dist-info}/METADATA +13 -15
- tasktree-0.0.22.dist-info/RECORD +14 -0
- tasktree-0.0.21.dist-info/RECORD +0 -14
- {tasktree-0.0.21.dist-info → tasktree-0.0.22.dist-info}/WHEEL +0 -0
- {tasktree-0.0.21.dist-info → tasktree-0.0.22.dist-info}/entry_points.txt +0 -0
tasktree/executor.py
CHANGED
|
@@ -14,10 +14,16 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from tasktree import docker as docker_module
|
|
17
|
-
from tasktree.graph import
|
|
17
|
+
from tasktree.graph import (
|
|
18
|
+
get_implicit_inputs,
|
|
19
|
+
resolve_execution_order,
|
|
20
|
+
resolve_dependency_output_references,
|
|
21
|
+
resolve_self_references,
|
|
22
|
+
)
|
|
18
23
|
from tasktree.hasher import hash_args, hash_task, make_cache_key
|
|
19
24
|
from tasktree.parser import Recipe, Task, Environment
|
|
20
25
|
from tasktree.state import StateManager, TaskState
|
|
26
|
+
from tasktree.hasher import hash_environment_definition
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
@dataclass
|
|
@@ -47,19 +53,19 @@ class ExecutionError(Exception):
|
|
|
47
53
|
class Executor:
|
|
48
54
|
"""
|
|
49
55
|
Executes tasks with incremental execution logic.
|
|
50
|
-
@athena:
|
|
56
|
+
@athena: 88e82151721d
|
|
51
57
|
"""
|
|
52
58
|
|
|
53
59
|
# Protected environment variables that cannot be overridden by exported args
|
|
54
60
|
PROTECTED_ENV_VARS = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
"PATH",
|
|
62
|
+
"LD_LIBRARY_PATH",
|
|
63
|
+
"LD_PRELOAD",
|
|
64
|
+
"PYTHONPATH",
|
|
65
|
+
"HOME",
|
|
66
|
+
"SHELL",
|
|
67
|
+
"USER",
|
|
68
|
+
"LOGNAME",
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
def __init__(self, recipe: Recipe, state_manager: StateManager):
|
|
@@ -75,7 +81,8 @@ class Executor:
|
|
|
75
81
|
self.state = state_manager
|
|
76
82
|
self.docker_manager = docker_module.DockerManager(recipe.project_root)
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _has_regular_args(task: Task) -> bool:
|
|
79
86
|
"""
|
|
80
87
|
Check if a task has any regular (non-exported) arguments.
|
|
81
88
|
|
|
@@ -84,7 +91,7 @@ class Executor:
|
|
|
84
91
|
|
|
85
92
|
Returns:
|
|
86
93
|
True if task has at least one regular (non-exported) argument, False otherwise
|
|
87
|
-
@athena:
|
|
94
|
+
@athena: a4c7816bfe61
|
|
88
95
|
"""
|
|
89
96
|
if not task.args:
|
|
90
97
|
return False
|
|
@@ -94,18 +101,19 @@ class Executor:
|
|
|
94
101
|
# Handle both string and dict arg specs
|
|
95
102
|
if isinstance(arg_spec, str):
|
|
96
103
|
# Remove default value part if present
|
|
97
|
-
arg_name = arg_spec.split(
|
|
98
|
-
if not arg_name.startswith(
|
|
104
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
105
|
+
if not arg_name.startswith("$"):
|
|
99
106
|
return True
|
|
100
107
|
elif isinstance(arg_spec, dict):
|
|
101
108
|
# Dict format: { argname: { ... } } or { $argname: { ... } }
|
|
102
109
|
for key in arg_spec.keys():
|
|
103
|
-
if not key.startswith(
|
|
110
|
+
if not key.startswith("$"):
|
|
104
111
|
return True
|
|
105
112
|
|
|
106
113
|
return False
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _filter_regular_args(task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
|
|
109
117
|
"""
|
|
110
118
|
Filter task_args to only include regular (non-exported) arguments.
|
|
111
119
|
|
|
@@ -115,7 +123,7 @@ class Executor:
|
|
|
115
123
|
|
|
116
124
|
Returns:
|
|
117
125
|
Dictionary containing only regular (non-exported) arguments
|
|
118
|
-
@athena:
|
|
126
|
+
@athena: 974e5e32bbd7
|
|
119
127
|
"""
|
|
120
128
|
if not task.args or not task_args:
|
|
121
129
|
return {}
|
|
@@ -124,18 +132,20 @@ class Executor:
|
|
|
124
132
|
exported_names = set()
|
|
125
133
|
for arg_spec in task.args:
|
|
126
134
|
if isinstance(arg_spec, str):
|
|
127
|
-
arg_name = arg_spec.split(
|
|
128
|
-
if arg_name.startswith(
|
|
135
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
136
|
+
if arg_name.startswith("$"):
|
|
129
137
|
exported_names.add(arg_name[1:]) # Remove $ prefix
|
|
130
138
|
elif isinstance(arg_spec, dict):
|
|
131
139
|
for key in arg_spec.keys():
|
|
132
|
-
if key.startswith(
|
|
140
|
+
if key.startswith("$"):
|
|
133
141
|
exported_names.add(key[1:]) # Remove $ prefix
|
|
134
142
|
|
|
135
143
|
# Filter out exported args
|
|
136
144
|
return {k: v for k, v in task_args.items() if k not in exported_names}
|
|
137
145
|
|
|
138
|
-
def _collect_early_builtin_variables(
|
|
146
|
+
def _collect_early_builtin_variables(
|
|
147
|
+
self, task: Task, timestamp: datetime
|
|
148
|
+
) -> dict[str, str]:
|
|
139
149
|
"""
|
|
140
150
|
Collect built-in variables that don't depend on working_dir.
|
|
141
151
|
|
|
@@ -150,31 +160,27 @@ class Executor:
|
|
|
150
160
|
|
|
151
161
|
Raises:
|
|
152
162
|
ExecutionError: If any built-in variable fails to resolve
|
|
153
|
-
@athena:
|
|
163
|
+
@athena: 3b4c0ec70ad7
|
|
154
164
|
"""
|
|
155
165
|
import os
|
|
156
166
|
|
|
157
|
-
builtin_vars = {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
builtin_vars['timestamp'] = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
170
|
-
|
|
171
|
-
# {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
|
|
172
|
-
builtin_vars['timestamp_unix'] = str(int(timestamp.timestamp()))
|
|
167
|
+
builtin_vars = {
|
|
168
|
+
# {{ tt.project_root }} - Absolute path to project root
|
|
169
|
+
"project_root": str(self.recipe.project_root.resolve()),
|
|
170
|
+
# {{ tt.recipe_dir }} - Absolute path to directory containing the recipe file
|
|
171
|
+
"recipe_dir": str(self.recipe.recipe_path.parent.resolve()),
|
|
172
|
+
# {{ tt.task_name }} - Name of currently executing task
|
|
173
|
+
"task_name": task.name,
|
|
174
|
+
# {{ tt.timestamp }} - ISO8601 timestamp when task started execution
|
|
175
|
+
"timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
176
|
+
# {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
|
|
177
|
+
"timestamp_unix": str(int(timestamp.timestamp())),
|
|
178
|
+
}
|
|
173
179
|
|
|
174
180
|
# {{ tt.user_home }} - Current user's home directory (cross-platform)
|
|
175
181
|
try:
|
|
176
182
|
user_home = Path.home()
|
|
177
|
-
builtin_vars[
|
|
183
|
+
builtin_vars["user_home"] = str(user_home)
|
|
178
184
|
except Exception as e:
|
|
179
185
|
raise ExecutionError(
|
|
180
186
|
f"Failed to get user home directory for {{ tt.user_home }}: {e}"
|
|
@@ -185,12 +191,16 @@ class Executor:
|
|
|
185
191
|
user_name = os.getlogin()
|
|
186
192
|
except OSError:
|
|
187
193
|
# Fallback to environment variables if os.getlogin() fails
|
|
188
|
-
user_name =
|
|
189
|
-
|
|
194
|
+
user_name = (
|
|
195
|
+
os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
|
196
|
+
)
|
|
197
|
+
builtin_vars["user_name"] = user_name
|
|
190
198
|
|
|
191
199
|
return builtin_vars
|
|
192
200
|
|
|
193
|
-
def _collect_builtin_variables(
|
|
201
|
+
def _collect_builtin_variables(
|
|
202
|
+
self, task: Task, working_dir: Path, timestamp: datetime
|
|
203
|
+
) -> dict[str, str]:
|
|
194
204
|
"""
|
|
195
205
|
Collect built-in variables for task execution.
|
|
196
206
|
|
|
@@ -211,11 +221,13 @@ class Executor:
|
|
|
211
221
|
|
|
212
222
|
# {{ tt.working_dir }} - Absolute path to task's effective working directory
|
|
213
223
|
# This is added after working_dir is resolved to avoid circular dependency
|
|
214
|
-
builtin_vars[
|
|
224
|
+
builtin_vars["working_dir"] = str(working_dir.resolve())
|
|
215
225
|
|
|
216
226
|
return builtin_vars
|
|
217
227
|
|
|
218
|
-
def _prepare_env_with_exports(
|
|
228
|
+
def _prepare_env_with_exports(
|
|
229
|
+
self, exported_env_vars: dict[str, str] | None = None
|
|
230
|
+
) -> dict[str, str]:
|
|
219
231
|
"""
|
|
220
232
|
Prepare environment with exported arguments.
|
|
221
233
|
|
|
@@ -241,19 +253,20 @@ class Executor:
|
|
|
241
253
|
env.update(exported_env_vars)
|
|
242
254
|
return env
|
|
243
255
|
|
|
244
|
-
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _get_platform_default_environment() -> tuple[str, list[str]]:
|
|
245
258
|
"""
|
|
246
259
|
Get default shell and args for current platform.
|
|
247
260
|
|
|
248
261
|
Returns:
|
|
249
262
|
Tuple of (shell, args) for platform default
|
|
250
|
-
@athena:
|
|
263
|
+
@athena: 8b7fa81073af
|
|
251
264
|
"""
|
|
252
265
|
is_windows = platform.system() == "Windows"
|
|
253
266
|
if is_windows:
|
|
254
|
-
return
|
|
267
|
+
return "cmd", ["/c"]
|
|
255
268
|
else:
|
|
256
|
-
return
|
|
269
|
+
return "bash", ["-c"]
|
|
257
270
|
|
|
258
271
|
def _get_effective_env_name(self, task: Task) -> str:
|
|
259
272
|
"""
|
|
@@ -287,7 +300,7 @@ class Executor:
|
|
|
287
300
|
# Platform default (no env name)
|
|
288
301
|
return ""
|
|
289
302
|
|
|
290
|
-
def _resolve_environment(self, task: Task) -> tuple[str,
|
|
303
|
+
def _resolve_environment(self, task: Task) -> tuple[str, str]:
|
|
291
304
|
"""
|
|
292
305
|
Resolve which environment to use for a task.
|
|
293
306
|
|
|
@@ -301,8 +314,8 @@ class Executor:
|
|
|
301
314
|
task: Task to resolve environment for
|
|
302
315
|
|
|
303
316
|
Returns:
|
|
304
|
-
Tuple of (shell,
|
|
305
|
-
@athena:
|
|
317
|
+
Tuple of (shell, preamble)
|
|
318
|
+
@athena: 15cad76d7c80
|
|
306
319
|
"""
|
|
307
320
|
# Check for global override first
|
|
308
321
|
env_name = self.recipe.global_env_override
|
|
@@ -319,12 +332,12 @@ class Executor:
|
|
|
319
332
|
if env_name:
|
|
320
333
|
env = self.recipe.get_environment(env_name)
|
|
321
334
|
if env:
|
|
322
|
-
return
|
|
335
|
+
return env.shell, env.preamble
|
|
323
336
|
# If env not found, fall through to platform default
|
|
324
337
|
|
|
325
338
|
# Use platform default
|
|
326
|
-
shell,
|
|
327
|
-
return
|
|
339
|
+
shell, _ = self._get_platform_default_environment()
|
|
340
|
+
return shell, ""
|
|
328
341
|
|
|
329
342
|
def check_task_status(
|
|
330
343
|
self,
|
|
@@ -364,7 +377,14 @@ class Executor:
|
|
|
364
377
|
|
|
365
378
|
# Compute hashes (include effective environment and dependencies)
|
|
366
379
|
effective_env = self._get_effective_env_name(task)
|
|
367
|
-
task_hash = hash_task(
|
|
380
|
+
task_hash = hash_task(
|
|
381
|
+
task.cmd,
|
|
382
|
+
task.outputs,
|
|
383
|
+
task.working_dir,
|
|
384
|
+
task.args,
|
|
385
|
+
effective_env,
|
|
386
|
+
task.deps,
|
|
387
|
+
)
|
|
368
388
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
369
389
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
370
390
|
|
|
@@ -487,13 +507,20 @@ class Executor:
|
|
|
487
507
|
# Only include regular (non-exported) args in status key for parameterized dependencies
|
|
488
508
|
# For the root task (invoked from CLI), status key is always just the task name
|
|
489
509
|
# For dependencies with parameterized invocations, include the regular args
|
|
490
|
-
is_root_task =
|
|
491
|
-
if
|
|
510
|
+
is_root_task = name == task_name
|
|
511
|
+
if (
|
|
512
|
+
not is_root_task
|
|
513
|
+
and args_dict_for_execution
|
|
514
|
+
and self._has_regular_args(task)
|
|
515
|
+
):
|
|
492
516
|
import json
|
|
517
|
+
|
|
493
518
|
# Filter to only include regular (non-exported) args
|
|
494
519
|
regular_args = self._filter_regular_args(task, args_dict_for_execution)
|
|
495
520
|
if regular_args:
|
|
496
|
-
args_str = json.dumps(
|
|
521
|
+
args_str = json.dumps(
|
|
522
|
+
regular_args, sort_keys=True, separators=(",", ":")
|
|
523
|
+
)
|
|
497
524
|
status_key = f"{name}({args_str})"
|
|
498
525
|
else:
|
|
499
526
|
status_key = name
|
|
@@ -506,6 +533,7 @@ class Executor:
|
|
|
506
533
|
# Warn if re-running due to missing outputs
|
|
507
534
|
if status.reason == "outputs_missing":
|
|
508
535
|
import sys
|
|
536
|
+
|
|
509
537
|
print(
|
|
510
538
|
f"Warning: Re-running task '{name}' because declared outputs are missing",
|
|
511
539
|
file=sys.stderr,
|
|
@@ -525,7 +553,7 @@ class Executor:
|
|
|
525
553
|
|
|
526
554
|
Raises:
|
|
527
555
|
ExecutionError: If task execution fails
|
|
528
|
-
@athena:
|
|
556
|
+
@athena: 4b49652a7afd
|
|
529
557
|
"""
|
|
530
558
|
# Capture timestamp at task start for consistency (in UTC)
|
|
531
559
|
task_start_time = datetime.now(timezone.utc)
|
|
@@ -533,6 +561,7 @@ class Executor:
|
|
|
533
561
|
# Parse task arguments to identify exported args
|
|
534
562
|
# Note: args_dict already has defaults applied by CLI (cli.py:413-424)
|
|
535
563
|
from tasktree.parser import parse_arg_spec
|
|
564
|
+
|
|
536
565
|
exported_args = set()
|
|
537
566
|
regular_args = {}
|
|
538
567
|
exported_env_vars = {}
|
|
@@ -551,18 +580,24 @@ class Executor:
|
|
|
551
580
|
|
|
552
581
|
# Collect early built-in variables (those that don't depend on working_dir)
|
|
553
582
|
# These can be used in the working_dir field itself
|
|
554
|
-
early_builtin_vars = self._collect_early_builtin_variables(
|
|
583
|
+
early_builtin_vars = self._collect_early_builtin_variables(
|
|
584
|
+
task, task_start_time
|
|
585
|
+
)
|
|
555
586
|
|
|
556
587
|
# Resolve working directory
|
|
557
588
|
# Validate that working_dir doesn't contain {{ tt.working_dir }} (circular dependency)
|
|
558
589
|
self._validate_no_working_dir_circular_ref(task.working_dir)
|
|
559
590
|
working_dir_str = self._substitute_builtin(task.working_dir, early_builtin_vars)
|
|
560
|
-
working_dir_str = self._substitute_args(
|
|
591
|
+
working_dir_str = self._substitute_args(
|
|
592
|
+
working_dir_str, regular_args, exported_args
|
|
593
|
+
)
|
|
561
594
|
working_dir_str = self._substitute_env(working_dir_str)
|
|
562
595
|
working_dir = self.recipe.project_root / working_dir_str
|
|
563
596
|
|
|
564
597
|
# Collect all built-in variables (including tt.working_dir now that it's resolved)
|
|
565
|
-
builtin_vars = self._collect_builtin_variables(
|
|
598
|
+
builtin_vars = self._collect_builtin_variables(
|
|
599
|
+
task, working_dir, task_start_time
|
|
600
|
+
)
|
|
566
601
|
|
|
567
602
|
# Substitute built-in variables, arguments, and environment variables in command
|
|
568
603
|
cmd = self._substitute_builtin(task.cmd, builtin_vars)
|
|
@@ -583,64 +618,33 @@ class Executor:
|
|
|
583
618
|
# Docker execution path
|
|
584
619
|
self._run_task_in_docker(task, env, cmd, working_dir, exported_env_vars)
|
|
585
620
|
else:
|
|
586
|
-
# Regular execution path
|
|
587
|
-
shell,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble, exported_env_vars)
|
|
592
|
-
else:
|
|
593
|
-
self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args, exported_env_vars)
|
|
621
|
+
# Regular execution path - use unified script-based execution
|
|
622
|
+
shell, preamble = self._resolve_environment(task)
|
|
623
|
+
self._run_command_as_script(
|
|
624
|
+
cmd, working_dir, task.name, shell, preamble, exported_env_vars
|
|
625
|
+
)
|
|
594
626
|
|
|
595
627
|
# Update state
|
|
596
628
|
self._update_state(task, args_dict)
|
|
597
629
|
|
|
598
|
-
def
|
|
599
|
-
self,
|
|
600
|
-
|
|
630
|
+
def _run_command_as_script(
|
|
631
|
+
self,
|
|
632
|
+
cmd: str,
|
|
633
|
+
working_dir: Path,
|
|
634
|
+
task_name: str,
|
|
635
|
+
shell: str,
|
|
636
|
+
preamble: str,
|
|
637
|
+
exported_env_vars: dict[str, str] | None = None,
|
|
601
638
|
) -> None:
|
|
602
639
|
"""
|
|
603
|
-
Execute a
|
|
604
|
-
|
|
605
|
-
Args:
|
|
606
|
-
cmd: Command string
|
|
607
|
-
working_dir: Working directory
|
|
608
|
-
task_name: Task name (for error messages)
|
|
609
|
-
shell: Shell executable to use
|
|
610
|
-
shell_args: Arguments to pass to shell
|
|
611
|
-
exported_env_vars: Exported arguments to set as environment variables
|
|
612
|
-
|
|
613
|
-
Raises:
|
|
614
|
-
ExecutionError: If command execution fails
|
|
615
|
-
@athena: 46849e6a0bbb
|
|
616
|
-
"""
|
|
617
|
-
# Prepare environment with exported args
|
|
618
|
-
env = self._prepare_env_with_exports(exported_env_vars)
|
|
640
|
+
Execute a command via temporary script file (unified execution path).
|
|
619
641
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
subprocess.run(
|
|
624
|
-
full_cmd,
|
|
625
|
-
cwd=working_dir,
|
|
626
|
-
check=True,
|
|
627
|
-
capture_output=False,
|
|
628
|
-
env=env,
|
|
629
|
-
)
|
|
630
|
-
except subprocess.CalledProcessError as e:
|
|
631
|
-
raise ExecutionError(
|
|
632
|
-
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
633
|
-
)
|
|
634
|
-
|
|
635
|
-
def _run_multiline_command(
|
|
636
|
-
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str,
|
|
637
|
-
exported_env_vars: dict[str, str] | None = None
|
|
638
|
-
) -> None:
|
|
639
|
-
"""
|
|
640
|
-
Execute a multi-line command via temporary script file.
|
|
642
|
+
This method handles both single-line and multi-line commands by writing
|
|
643
|
+
them to a temporary script file and executing the script. This provides
|
|
644
|
+
consistent behavior and allows preamble to work with all commands.
|
|
641
645
|
|
|
642
646
|
Args:
|
|
643
|
-
cmd:
|
|
647
|
+
cmd: Command string (single-line or multi-line)
|
|
644
648
|
working_dir: Working directory
|
|
645
649
|
task_name: Task name (for error messages)
|
|
646
650
|
shell: Shell to use for script execution
|
|
@@ -649,7 +653,8 @@ class Executor:
|
|
|
649
653
|
|
|
650
654
|
Raises:
|
|
651
655
|
ExecutionError: If command execution fails
|
|
652
|
-
@athena:
|
|
656
|
+
@athena: TBD
|
|
657
|
+
@athena: 96e85dc15b5c
|
|
653
658
|
"""
|
|
654
659
|
# Prepare environment with exported args
|
|
655
660
|
env = self._prepare_env_with_exports(exported_env_vars)
|
|
@@ -707,7 +712,9 @@ class Executor:
|
|
|
707
712
|
except OSError:
|
|
708
713
|
pass # Ignore cleanup errors
|
|
709
714
|
|
|
710
|
-
def _substitute_builtin_in_environment(
|
|
715
|
+
def _substitute_builtin_in_environment(
|
|
716
|
+
self, env: Environment, builtin_vars: dict[str, str]
|
|
717
|
+
) -> Environment:
|
|
711
718
|
"""
|
|
712
719
|
Substitute builtin and environment variables in environment fields.
|
|
713
720
|
|
|
@@ -725,29 +732,51 @@ class Executor:
|
|
|
725
732
|
from dataclasses import replace
|
|
726
733
|
|
|
727
734
|
# Substitute in volumes (builtin vars first, then env vars)
|
|
728
|
-
substituted_volumes =
|
|
729
|
-
|
|
730
|
-
|
|
735
|
+
substituted_volumes = (
|
|
736
|
+
[
|
|
737
|
+
self._substitute_env(self._substitute_builtin(vol, builtin_vars))
|
|
738
|
+
for vol in env.volumes
|
|
739
|
+
]
|
|
740
|
+
if env.volumes
|
|
741
|
+
else []
|
|
742
|
+
)
|
|
731
743
|
|
|
732
744
|
# Substitute in env_vars values (builtin vars first, then env vars)
|
|
733
|
-
substituted_env_vars =
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
745
|
+
substituted_env_vars = (
|
|
746
|
+
{
|
|
747
|
+
key: self._substitute_env(self._substitute_builtin(value, builtin_vars))
|
|
748
|
+
for key, value in env.env_vars.items()
|
|
749
|
+
}
|
|
750
|
+
if env.env_vars
|
|
751
|
+
else {}
|
|
752
|
+
)
|
|
737
753
|
|
|
738
754
|
# Substitute in ports (builtin vars first, then env vars)
|
|
739
|
-
substituted_ports =
|
|
740
|
-
|
|
741
|
-
|
|
755
|
+
substituted_ports = (
|
|
756
|
+
[
|
|
757
|
+
self._substitute_env(self._substitute_builtin(port, builtin_vars))
|
|
758
|
+
for port in env.ports
|
|
759
|
+
]
|
|
760
|
+
if env.ports
|
|
761
|
+
else []
|
|
762
|
+
)
|
|
742
763
|
|
|
743
764
|
# Substitute in working_dir (builtin vars first, then env vars)
|
|
744
|
-
substituted_working_dir =
|
|
765
|
+
substituted_working_dir = (
|
|
766
|
+
self._substitute_env(
|
|
767
|
+
self._substitute_builtin(env.working_dir, builtin_vars)
|
|
768
|
+
)
|
|
769
|
+
if env.working_dir
|
|
770
|
+
else ""
|
|
771
|
+
)
|
|
745
772
|
|
|
746
773
|
# Substitute in build args (for Docker environments, args is a dict)
|
|
747
774
|
# Apply builtin vars first, then env vars
|
|
748
775
|
if isinstance(env.args, dict):
|
|
749
776
|
substituted_args = {
|
|
750
|
-
key: self._substitute_env(
|
|
777
|
+
key: self._substitute_env(
|
|
778
|
+
self._substitute_builtin(str(value), builtin_vars)
|
|
779
|
+
)
|
|
751
780
|
for key, value in env.args.items()
|
|
752
781
|
}
|
|
753
782
|
else:
|
|
@@ -760,12 +789,16 @@ class Executor:
|
|
|
760
789
|
env_vars=substituted_env_vars,
|
|
761
790
|
ports=substituted_ports,
|
|
762
791
|
working_dir=substituted_working_dir,
|
|
763
|
-
args=substituted_args
|
|
792
|
+
args=substituted_args,
|
|
764
793
|
)
|
|
765
794
|
|
|
766
795
|
def _run_task_in_docker(
|
|
767
|
-
self,
|
|
768
|
-
|
|
796
|
+
self,
|
|
797
|
+
task: Task,
|
|
798
|
+
env: Any,
|
|
799
|
+
cmd: str,
|
|
800
|
+
working_dir: Path,
|
|
801
|
+
exported_env_vars: dict[str, str] | None = None,
|
|
769
802
|
) -> None:
|
|
770
803
|
"""
|
|
771
804
|
Execute task inside Docker container.
|
|
@@ -783,7 +816,9 @@ class Executor:
|
|
|
783
816
|
"""
|
|
784
817
|
# Get builtin variables for substitution in environment fields
|
|
785
818
|
task_start_time = datetime.now(timezone.utc)
|
|
786
|
-
builtin_vars = self._collect_builtin_variables(
|
|
819
|
+
builtin_vars = self._collect_builtin_variables(
|
|
820
|
+
task, working_dir, task_start_time
|
|
821
|
+
)
|
|
787
822
|
|
|
788
823
|
# Substitute builtin variables in environment fields (volumes, env_vars, etc.)
|
|
789
824
|
env = self._substitute_builtin_in_environment(env, builtin_vars)
|
|
@@ -807,6 +842,7 @@ class Executor:
|
|
|
807
842
|
|
|
808
843
|
# Create modified environment with merged env vars using dataclass replace
|
|
809
844
|
from dataclasses import replace
|
|
845
|
+
|
|
810
846
|
modified_env = replace(env, env_vars=docker_env_vars)
|
|
811
847
|
|
|
812
848
|
# Execute in container
|
|
@@ -820,7 +856,8 @@ class Executor:
|
|
|
820
856
|
except docker_module.DockerError as e:
|
|
821
857
|
raise ExecutionError(str(e)) from e
|
|
822
858
|
|
|
823
|
-
|
|
859
|
+
@staticmethod
|
|
860
|
+
def _validate_no_working_dir_circular_ref(text: str) -> None:
|
|
824
861
|
"""
|
|
825
862
|
Validate that working_dir field does not contain {{ tt.working_dir }}.
|
|
826
863
|
|
|
@@ -831,20 +868,22 @@ class Executor:
|
|
|
831
868
|
|
|
832
869
|
Raises:
|
|
833
870
|
ExecutionError: If {{ tt.working_dir }} placeholder is found
|
|
834
|
-
@athena:
|
|
871
|
+
@athena: 617a0c609f4d
|
|
835
872
|
"""
|
|
836
873
|
import re
|
|
874
|
+
|
|
837
875
|
# Pattern to match {{ tt.working_dir }} specifically
|
|
838
|
-
pattern = re.compile(r
|
|
876
|
+
pattern = re.compile(r"\{\{\s*tt\s*\.\s*working_dir\s*}}")
|
|
839
877
|
|
|
840
878
|
if pattern.search(text):
|
|
841
879
|
raise ExecutionError(
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
880
|
+
"Cannot use {{ tt.working_dir }} in the 'working_dir' field.\n\n"
|
|
881
|
+
"This creates a circular dependency (working_dir cannot reference itself).\n"
|
|
882
|
+
"Other built-in variables like {{ tt.task_name }} or {{ tt.timestamp }} are allowed."
|
|
845
883
|
)
|
|
846
884
|
|
|
847
|
-
|
|
885
|
+
@staticmethod
|
|
886
|
+
def _substitute_builtin(text: str, builtin_vars: dict[str, str]) -> str:
|
|
848
887
|
"""
|
|
849
888
|
Substitute {{ tt.name }} placeholders in text.
|
|
850
889
|
|
|
@@ -859,12 +898,16 @@ class Executor:
|
|
|
859
898
|
|
|
860
899
|
Raises:
|
|
861
900
|
ValueError: If built-in variable is not defined
|
|
862
|
-
@athena:
|
|
901
|
+
@athena: fe47afe87b52
|
|
863
902
|
"""
|
|
864
903
|
from tasktree.substitution import substitute_builtin_variables
|
|
904
|
+
|
|
865
905
|
return substitute_builtin_variables(text, builtin_vars)
|
|
866
906
|
|
|
867
|
-
|
|
907
|
+
@staticmethod
|
|
908
|
+
def _substitute_args(
|
|
909
|
+
cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None
|
|
910
|
+
) -> str:
|
|
868
911
|
"""
|
|
869
912
|
Substitute {{ arg.name }} placeholders in command string.
|
|
870
913
|
|
|
@@ -881,12 +924,14 @@ class Executor:
|
|
|
881
924
|
|
|
882
925
|
Raises:
|
|
883
926
|
ValueError: If an exported argument is used in template substitution
|
|
884
|
-
@athena:
|
|
927
|
+
@athena: 9a931179f270
|
|
885
928
|
"""
|
|
886
929
|
from tasktree.substitution import substitute_arguments
|
|
930
|
+
|
|
887
931
|
return substitute_arguments(cmd, args_dict, exported_args)
|
|
888
932
|
|
|
889
|
-
|
|
933
|
+
@staticmethod
|
|
934
|
+
def _substitute_env(text: str) -> str:
|
|
890
935
|
"""
|
|
891
936
|
Substitute {{ env.NAME }} placeholders in text.
|
|
892
937
|
|
|
@@ -900,9 +945,10 @@ class Executor:
|
|
|
900
945
|
|
|
901
946
|
Raises:
|
|
902
947
|
ValueError: If environment variable is not set
|
|
903
|
-
@athena:
|
|
948
|
+
@athena: 1bbe24759451
|
|
904
949
|
"""
|
|
905
950
|
from tasktree.substitution import substitute_environment
|
|
951
|
+
|
|
906
952
|
return substitute_environment(text)
|
|
907
953
|
|
|
908
954
|
def _get_all_inputs(self, task: Task) -> list[str]:
|
|
@@ -929,6 +975,7 @@ class Executor:
|
|
|
929
975
|
all_inputs.extend(implicit_inputs)
|
|
930
976
|
return all_inputs
|
|
931
977
|
|
|
978
|
+
# TODO: Understand why task isn't used
|
|
932
979
|
def _check_environment_changed(
|
|
933
980
|
self, task: Task, cached_state: TaskState, env_name: str
|
|
934
981
|
) -> bool:
|
|
@@ -997,12 +1044,12 @@ class Executor:
|
|
|
997
1044
|
|
|
998
1045
|
Returns:
|
|
999
1046
|
True if image ID changed, False otherwise
|
|
1000
|
-
@athena:
|
|
1047
|
+
@athena: 0443710cf356
|
|
1001
1048
|
"""
|
|
1002
1049
|
# Build/ensure image is built and get its ID
|
|
1003
1050
|
try:
|
|
1004
1051
|
image_tag, current_image_id = self.docker_manager.ensure_image_built(env)
|
|
1005
|
-
except Exception
|
|
1052
|
+
except Exception:
|
|
1006
1053
|
# If we can't build, treat as changed (will fail later with better error)
|
|
1007
1054
|
return True
|
|
1008
1055
|
|
|
@@ -1095,7 +1142,9 @@ class Executor:
|
|
|
1095
1142
|
|
|
1096
1143
|
# Check if digests changed
|
|
1097
1144
|
if current_digests != cached_digests:
|
|
1098
|
-
changed_files.append(
|
|
1145
|
+
changed_files.append(
|
|
1146
|
+
f"Docker base image digests in {dockerfile_name}"
|
|
1147
|
+
)
|
|
1099
1148
|
except (OSError, IOError):
|
|
1100
1149
|
# Can't read Dockerfile - consider changed
|
|
1101
1150
|
changed_files.append(f"Dockerfile: {dockerfile_name}")
|
|
@@ -1115,7 +1164,8 @@ class Executor:
|
|
|
1115
1164
|
|
|
1116
1165
|
return changed_files
|
|
1117
1166
|
|
|
1118
|
-
|
|
1167
|
+
@staticmethod
|
|
1168
|
+
def _expand_output_paths(task: Task) -> list[str]:
|
|
1119
1169
|
"""
|
|
1120
1170
|
Extract all output paths from task outputs (both named and anonymous).
|
|
1121
1171
|
|
|
@@ -1124,7 +1174,7 @@ class Executor:
|
|
|
1124
1174
|
|
|
1125
1175
|
Returns:
|
|
1126
1176
|
List of output path patterns (glob patterns as strings)
|
|
1127
|
-
@athena:
|
|
1177
|
+
@athena: 21da23ad5dcf
|
|
1128
1178
|
"""
|
|
1129
1179
|
paths = []
|
|
1130
1180
|
for output in task.outputs:
|
|
@@ -1193,25 +1243,42 @@ class Executor:
|
|
|
1193
1243
|
def _update_state(self, task: Task, args_dict: dict[str, Any]) -> None:
|
|
1194
1244
|
"""
|
|
1195
1245
|
Update state after task execution.
|
|
1196
|
-
|
|
1197
|
-
Args:
|
|
1198
|
-
task: Task that was executed
|
|
1199
|
-
args_dict: Arguments used for execution
|
|
1200
1246
|
@athena: 1fcfdfcb9be9
|
|
1201
1247
|
"""
|
|
1202
|
-
|
|
1248
|
+
cache_key = self._cache_key(task, args_dict)
|
|
1249
|
+
input_state = self._input_files_to_modified_times(task)
|
|
1250
|
+
|
|
1251
|
+
env_name = self._get_effective_env_name(task)
|
|
1252
|
+
if env_name:
|
|
1253
|
+
env = self.recipe.get_environment(env_name)
|
|
1254
|
+
if env:
|
|
1255
|
+
input_state[f"_env_hash_{env_name}"] = hash_environment_definition(env)
|
|
1256
|
+
if env.dockerfile:
|
|
1257
|
+
input_state |= self._docker_inputs_to_modified_times(env_name, env)
|
|
1258
|
+
|
|
1259
|
+
new_state = TaskState(last_run=time.time(), input_state=input_state)
|
|
1260
|
+
self.state.set(cache_key, new_state)
|
|
1261
|
+
self.state.save()
|
|
1262
|
+
|
|
1263
|
+
def _cache_key(self, task: Task, args_dict: dict[str, Any]) -> str:
|
|
1203
1264
|
effective_env = self._get_effective_env_name(task)
|
|
1204
|
-
task_hash = hash_task(
|
|
1265
|
+
task_hash = hash_task(
|
|
1266
|
+
task.cmd,
|
|
1267
|
+
task.outputs,
|
|
1268
|
+
task.working_dir,
|
|
1269
|
+
task.args,
|
|
1270
|
+
effective_env,
|
|
1271
|
+
task.deps,
|
|
1272
|
+
)
|
|
1205
1273
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
1206
|
-
|
|
1274
|
+
return make_cache_key(task_hash, args_hash)
|
|
1207
1275
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
input_files = self._expand_globs(all_inputs, task.working_dir)
|
|
1276
|
+
def _input_files_to_modified_times(self, task: Task) -> dict[str, float]:
|
|
1277
|
+
input_files = self._expand_globs(self._get_all_inputs(task), task.working_dir)
|
|
1211
1278
|
|
|
1212
1279
|
input_state = {}
|
|
1213
1280
|
for file_path in input_files:
|
|
1214
|
-
# Skip Docker special markers (handled separately
|
|
1281
|
+
# Skip Docker special markers (handled separately)
|
|
1215
1282
|
if file_path.startswith("_docker_"):
|
|
1216
1283
|
continue
|
|
1217
1284
|
|
|
@@ -1219,59 +1286,44 @@ class Executor:
|
|
|
1219
1286
|
if file_path_obj.exists():
|
|
1220
1287
|
input_state[file_path] = file_path_obj.stat().st_mtime
|
|
1221
1288
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
# Record context check timestamp
|
|
1242
|
-
input_state[f"_context_{env.context}"] = time.time()
|
|
1243
|
-
|
|
1244
|
-
# Parse and record base image digests from Dockerfile
|
|
1245
|
-
try:
|
|
1246
|
-
dockerfile_content = dockerfile_path.read_text()
|
|
1247
|
-
digests = docker_module.parse_base_image_digests(dockerfile_content)
|
|
1248
|
-
for digest in digests:
|
|
1249
|
-
# Store digest with Dockerfile's mtime
|
|
1250
|
-
input_state[f"_digest_{digest}"] = dockerfile_path.stat().st_mtime
|
|
1251
|
-
except (OSError, IOError):
|
|
1252
|
-
# If we can't read Dockerfile, skip digest tracking
|
|
1253
|
-
pass
|
|
1254
|
-
|
|
1255
|
-
# Record environment definition hash for all environments (shell and Docker)
|
|
1256
|
-
if env:
|
|
1257
|
-
from tasktree.hasher import hash_environment_definition
|
|
1258
|
-
|
|
1259
|
-
env_hash = hash_environment_definition(env)
|
|
1260
|
-
input_state[f"_env_hash_{env_name}"] = env_hash
|
|
1289
|
+
return input_state
|
|
1290
|
+
|
|
1291
|
+
def _docker_inputs_to_modified_times(
|
|
1292
|
+
self, env_name: str, env: Environment
|
|
1293
|
+
) -> dict[str, float]:
|
|
1294
|
+
input_state = dict()
|
|
1295
|
+
# Record Dockerfile mtime
|
|
1296
|
+
dockerfile_path = self.recipe.project_root / env.dockerfile
|
|
1297
|
+
if dockerfile_path.exists():
|
|
1298
|
+
input_state[env.dockerfile] = dockerfile_path.stat().st_mtime
|
|
1299
|
+
|
|
1300
|
+
# Record .dockerignore mtime if exists
|
|
1301
|
+
context_path = self.recipe.project_root / env.context
|
|
1302
|
+
dockerignore_path = context_path / ".dockerignore"
|
|
1303
|
+
if dockerignore_path.exists():
|
|
1304
|
+
relative_dockerignore = str(
|
|
1305
|
+
dockerignore_path.relative_to(self.recipe.project_root)
|
|
1306
|
+
)
|
|
1307
|
+
input_state[relative_dockerignore] = dockerignore_path.stat().st_mtime
|
|
1261
1308
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
# Image was already built during check phase or task execution
|
|
1265
|
-
if env_name in self.docker_manager._built_images:
|
|
1266
|
-
image_tag, image_id = self.docker_manager._built_images[env_name]
|
|
1267
|
-
input_state[f"_docker_image_id_{env_name}"] = image_id
|
|
1268
|
-
|
|
1269
|
-
# Create new state
|
|
1270
|
-
state = TaskState(
|
|
1271
|
-
last_run=time.time(),
|
|
1272
|
-
input_state=input_state,
|
|
1273
|
-
)
|
|
1309
|
+
# Record context check timestamp
|
|
1310
|
+
input_state[f"_context_{env.context}"] = time.time()
|
|
1274
1311
|
|
|
1275
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1312
|
+
# Parse and record base image digests from Dockerfile
|
|
1313
|
+
try:
|
|
1314
|
+
dockerfile_content = dockerfile_path.read_text()
|
|
1315
|
+
digests = docker_module.parse_base_image_digests(dockerfile_content)
|
|
1316
|
+
for digest in digests:
|
|
1317
|
+
# Store digest with Dockerfile's mtime
|
|
1318
|
+
input_state[f"_digest_{digest}"] = dockerfile_path.stat().st_mtime
|
|
1319
|
+
except (OSError, IOError):
|
|
1320
|
+
# If we can't read Dockerfile, skip digest tracking
|
|
1321
|
+
pass
|
|
1322
|
+
|
|
1323
|
+
# For Docker environments, also store the image ID
|
|
1324
|
+
# Image was already built during check phase or task execution
|
|
1325
|
+
if env_name in self.docker_manager._built_images:
|
|
1326
|
+
image_tag, image_id = self.docker_manager._built_images[env_name]
|
|
1327
|
+
input_state[f"_docker_image_id_{env_name}"] = image_id
|
|
1328
|
+
|
|
1329
|
+
return input_state
|