tasktree 0.0.20__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/__init__.py +4 -1
- tasktree/cli.py +198 -60
- tasktree/docker.py +105 -64
- tasktree/executor.py +427 -310
- tasktree/graph.py +138 -82
- tasktree/hasher.py +81 -25
- tasktree/parser.py +554 -344
- tasktree/state.py +50 -22
- tasktree/substitution.py +188 -117
- tasktree/types.py +80 -25
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/METADATA +147 -21
- tasktree-0.0.22.dist-info/RECORD +14 -0
- tasktree-0.0.20.dist-info/RECORD +0 -14
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/WHEEL +0 -0
- {tasktree-0.0.20.dist-info → tasktree-0.0.22.dist-info}/entry_points.txt +0 -0
tasktree/executor.py
CHANGED
|
@@ -14,15 +14,24 @@ 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
|
|
24
30
|
class TaskStatus:
|
|
25
|
-
"""
|
|
31
|
+
"""
|
|
32
|
+
Status of a task for execution planning.
|
|
33
|
+
@athena: a718e784981d
|
|
34
|
+
"""
|
|
26
35
|
|
|
27
36
|
task_name: str
|
|
28
37
|
will_run: bool
|
|
@@ -33,45 +42,56 @@ class TaskStatus:
|
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
class ExecutionError(Exception):
|
|
36
|
-
"""
|
|
45
|
+
"""
|
|
46
|
+
Raised when task execution fails.
|
|
47
|
+
@athena: f22d72903ee4
|
|
48
|
+
"""
|
|
37
49
|
|
|
38
50
|
pass
|
|
39
51
|
|
|
40
52
|
|
|
41
53
|
class Executor:
|
|
42
|
-
"""
|
|
54
|
+
"""
|
|
55
|
+
Executes tasks with incremental execution logic.
|
|
56
|
+
@athena: 88e82151721d
|
|
57
|
+
"""
|
|
43
58
|
|
|
44
59
|
# Protected environment variables that cannot be overridden by exported args
|
|
45
60
|
PROTECTED_ENV_VARS = {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
"PATH",
|
|
62
|
+
"LD_LIBRARY_PATH",
|
|
63
|
+
"LD_PRELOAD",
|
|
64
|
+
"PYTHONPATH",
|
|
65
|
+
"HOME",
|
|
66
|
+
"SHELL",
|
|
67
|
+
"USER",
|
|
68
|
+
"LOGNAME",
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
def __init__(self, recipe: Recipe, state_manager: StateManager):
|
|
57
|
-
"""
|
|
72
|
+
"""
|
|
73
|
+
Initialize executor.
|
|
58
74
|
|
|
59
75
|
Args:
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
recipe: Parsed recipe containing all tasks
|
|
77
|
+
state_manager: State manager for tracking task execution
|
|
78
|
+
@athena: 21b65db48bca
|
|
62
79
|
"""
|
|
63
80
|
self.recipe = recipe
|
|
64
81
|
self.state = state_manager
|
|
65
82
|
self.docker_manager = docker_module.DockerManager(recipe.project_root)
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _has_regular_args(task: Task) -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Check if a task has any regular (non-exported) arguments.
|
|
69
88
|
|
|
70
89
|
Args:
|
|
71
|
-
|
|
90
|
+
task: Task to check
|
|
72
91
|
|
|
73
92
|
Returns:
|
|
74
|
-
|
|
93
|
+
True if task has at least one regular (non-exported) argument, False otherwise
|
|
94
|
+
@athena: a4c7816bfe61
|
|
75
95
|
"""
|
|
76
96
|
if not task.args:
|
|
77
97
|
return False
|
|
@@ -81,26 +101,29 @@ class Executor:
|
|
|
81
101
|
# Handle both string and dict arg specs
|
|
82
102
|
if isinstance(arg_spec, str):
|
|
83
103
|
# Remove default value part if present
|
|
84
|
-
arg_name = arg_spec.split(
|
|
85
|
-
if not arg_name.startswith(
|
|
104
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
105
|
+
if not arg_name.startswith("$"):
|
|
86
106
|
return True
|
|
87
107
|
elif isinstance(arg_spec, dict):
|
|
88
108
|
# Dict format: { argname: { ... } } or { $argname: { ... } }
|
|
89
109
|
for key in arg_spec.keys():
|
|
90
|
-
if not key.startswith(
|
|
110
|
+
if not key.startswith("$"):
|
|
91
111
|
return True
|
|
92
112
|
|
|
93
113
|
return False
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _filter_regular_args(task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Filter task_args to only include regular (non-exported) arguments.
|
|
97
119
|
|
|
98
120
|
Args:
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
task: Task definition
|
|
122
|
+
task_args: Dictionary of all task arguments
|
|
101
123
|
|
|
102
124
|
Returns:
|
|
103
|
-
|
|
125
|
+
Dictionary containing only regular (non-exported) arguments
|
|
126
|
+
@athena: 974e5e32bbd7
|
|
104
127
|
"""
|
|
105
128
|
if not task.args or not task_args:
|
|
106
129
|
return {}
|
|
@@ -109,55 +132,55 @@ class Executor:
|
|
|
109
132
|
exported_names = set()
|
|
110
133
|
for arg_spec in task.args:
|
|
111
134
|
if isinstance(arg_spec, str):
|
|
112
|
-
arg_name = arg_spec.split(
|
|
113
|
-
if arg_name.startswith(
|
|
135
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
136
|
+
if arg_name.startswith("$"):
|
|
114
137
|
exported_names.add(arg_name[1:]) # Remove $ prefix
|
|
115
138
|
elif isinstance(arg_spec, dict):
|
|
116
139
|
for key in arg_spec.keys():
|
|
117
|
-
if key.startswith(
|
|
140
|
+
if key.startswith("$"):
|
|
118
141
|
exported_names.add(key[1:]) # Remove $ prefix
|
|
119
142
|
|
|
120
143
|
# Filter out exported args
|
|
121
144
|
return {k: v for k, v in task_args.items() if k not in exported_names}
|
|
122
145
|
|
|
123
|
-
def _collect_early_builtin_variables(
|
|
124
|
-
|
|
146
|
+
def _collect_early_builtin_variables(
|
|
147
|
+
self, task: Task, timestamp: datetime
|
|
148
|
+
) -> dict[str, str]:
|
|
149
|
+
"""
|
|
150
|
+
Collect built-in variables that don't depend on working_dir.
|
|
125
151
|
|
|
126
152
|
These variables can be used in the working_dir field itself.
|
|
127
153
|
|
|
128
154
|
Args:
|
|
129
|
-
|
|
130
|
-
|
|
155
|
+
task: Task being executed
|
|
156
|
+
timestamp: Timestamp when task started execution
|
|
131
157
|
|
|
132
158
|
Returns:
|
|
133
|
-
|
|
159
|
+
Dictionary mapping built-in variable names to their string values
|
|
134
160
|
|
|
135
161
|
Raises:
|
|
136
|
-
|
|
162
|
+
ExecutionError: If any built-in variable fails to resolve
|
|
163
|
+
@athena: 3b4c0ec70ad7
|
|
137
164
|
"""
|
|
138
165
|
import os
|
|
139
166
|
|
|
140
|
-
builtin_vars = {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
builtin_vars['timestamp'] = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
153
|
-
|
|
154
|
-
# {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
|
|
155
|
-
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
|
+
}
|
|
156
179
|
|
|
157
180
|
# {{ tt.user_home }} - Current user's home directory (cross-platform)
|
|
158
181
|
try:
|
|
159
182
|
user_home = Path.home()
|
|
160
|
-
builtin_vars[
|
|
183
|
+
builtin_vars["user_home"] = str(user_home)
|
|
161
184
|
except Exception as e:
|
|
162
185
|
raise ExecutionError(
|
|
163
186
|
f"Failed to get user home directory for {{ tt.user_home }}: {e}"
|
|
@@ -168,45 +191,55 @@ class Executor:
|
|
|
168
191
|
user_name = os.getlogin()
|
|
169
192
|
except OSError:
|
|
170
193
|
# Fallback to environment variables if os.getlogin() fails
|
|
171
|
-
user_name =
|
|
172
|
-
|
|
194
|
+
user_name = (
|
|
195
|
+
os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
|
196
|
+
)
|
|
197
|
+
builtin_vars["user_name"] = user_name
|
|
173
198
|
|
|
174
199
|
return builtin_vars
|
|
175
200
|
|
|
176
|
-
def _collect_builtin_variables(
|
|
177
|
-
|
|
201
|
+
def _collect_builtin_variables(
|
|
202
|
+
self, task: Task, working_dir: Path, timestamp: datetime
|
|
203
|
+
) -> dict[str, str]:
|
|
204
|
+
"""
|
|
205
|
+
Collect built-in variables for task execution.
|
|
178
206
|
|
|
179
207
|
Args:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
task: Task being executed
|
|
209
|
+
working_dir: Resolved working directory for the task
|
|
210
|
+
timestamp: Timestamp when task started execution
|
|
183
211
|
|
|
184
212
|
Returns:
|
|
185
|
-
|
|
213
|
+
Dictionary mapping built-in variable names to their string values
|
|
186
214
|
|
|
187
215
|
Raises:
|
|
188
|
-
|
|
216
|
+
ExecutionError: If any built-in variable fails to resolve
|
|
217
|
+
@athena: bb8c385cb0a5
|
|
189
218
|
"""
|
|
190
219
|
# Get early builtin vars (those that don't depend on working_dir)
|
|
191
220
|
builtin_vars = self._collect_early_builtin_variables(task, timestamp)
|
|
192
221
|
|
|
193
222
|
# {{ tt.working_dir }} - Absolute path to task's effective working directory
|
|
194
223
|
# This is added after working_dir is resolved to avoid circular dependency
|
|
195
|
-
builtin_vars[
|
|
224
|
+
builtin_vars["working_dir"] = str(working_dir.resolve())
|
|
196
225
|
|
|
197
226
|
return builtin_vars
|
|
198
227
|
|
|
199
|
-
def _prepare_env_with_exports(
|
|
200
|
-
|
|
228
|
+
def _prepare_env_with_exports(
|
|
229
|
+
self, exported_env_vars: dict[str, str] | None = None
|
|
230
|
+
) -> dict[str, str]:
|
|
231
|
+
"""
|
|
232
|
+
Prepare environment with exported arguments.
|
|
201
233
|
|
|
202
234
|
Args:
|
|
203
|
-
|
|
235
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
204
236
|
|
|
205
237
|
Returns:
|
|
206
|
-
|
|
238
|
+
Environment dict with exported args merged
|
|
207
239
|
|
|
208
240
|
Raises:
|
|
209
|
-
|
|
241
|
+
ValueError: If an exported arg attempts to override a protected environment variable
|
|
242
|
+
@athena: 5340be771194
|
|
210
243
|
"""
|
|
211
244
|
env = os.environ.copy()
|
|
212
245
|
if exported_env_vars:
|
|
@@ -220,20 +253,24 @@ class Executor:
|
|
|
220
253
|
env.update(exported_env_vars)
|
|
221
254
|
return env
|
|
222
255
|
|
|
223
|
-
|
|
224
|
-
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _get_platform_default_environment() -> tuple[str, list[str]]:
|
|
258
|
+
"""
|
|
259
|
+
Get default shell and args for current platform.
|
|
225
260
|
|
|
226
261
|
Returns:
|
|
227
|
-
|
|
262
|
+
Tuple of (shell, args) for platform default
|
|
263
|
+
@athena: 8b7fa81073af
|
|
228
264
|
"""
|
|
229
265
|
is_windows = platform.system() == "Windows"
|
|
230
266
|
if is_windows:
|
|
231
|
-
return
|
|
267
|
+
return "cmd", ["/c"]
|
|
232
268
|
else:
|
|
233
|
-
return
|
|
269
|
+
return "bash", ["-c"]
|
|
234
270
|
|
|
235
271
|
def _get_effective_env_name(self, task: Task) -> str:
|
|
236
|
-
"""
|
|
272
|
+
"""
|
|
273
|
+
Get the effective environment name for a task.
|
|
237
274
|
|
|
238
275
|
Resolution order:
|
|
239
276
|
1. Recipe's global_env_override (from CLI --env)
|
|
@@ -242,10 +279,11 @@ class Executor:
|
|
|
242
279
|
4. Empty string (for platform default)
|
|
243
280
|
|
|
244
281
|
Args:
|
|
245
|
-
|
|
282
|
+
task: Task to get environment name for
|
|
246
283
|
|
|
247
284
|
Returns:
|
|
248
|
-
|
|
285
|
+
Environment name (empty string if using platform default)
|
|
286
|
+
@athena: e5bface8a3a2
|
|
249
287
|
"""
|
|
250
288
|
# Check for global override first
|
|
251
289
|
if self.recipe.global_env_override:
|
|
@@ -262,8 +300,9 @@ class Executor:
|
|
|
262
300
|
# Platform default (no env name)
|
|
263
301
|
return ""
|
|
264
302
|
|
|
265
|
-
def _resolve_environment(self, task: Task) -> tuple[str,
|
|
266
|
-
"""
|
|
303
|
+
def _resolve_environment(self, task: Task) -> tuple[str, str]:
|
|
304
|
+
"""
|
|
305
|
+
Resolve which environment to use for a task.
|
|
267
306
|
|
|
268
307
|
Resolution order:
|
|
269
308
|
1. Recipe's global_env_override (from CLI --env)
|
|
@@ -272,10 +311,11 @@ class Executor:
|
|
|
272
311
|
4. Platform default (bash on Unix, cmd on Windows)
|
|
273
312
|
|
|
274
313
|
Args:
|
|
275
|
-
|
|
314
|
+
task: Task to resolve environment for
|
|
276
315
|
|
|
277
316
|
Returns:
|
|
278
|
-
|
|
317
|
+
Tuple of (shell, preamble)
|
|
318
|
+
@athena: 15cad76d7c80
|
|
279
319
|
"""
|
|
280
320
|
# Check for global override first
|
|
281
321
|
env_name = self.recipe.global_env_override
|
|
@@ -292,12 +332,12 @@ class Executor:
|
|
|
292
332
|
if env_name:
|
|
293
333
|
env = self.recipe.get_environment(env_name)
|
|
294
334
|
if env:
|
|
295
|
-
return
|
|
335
|
+
return env.shell, env.preamble
|
|
296
336
|
# If env not found, fall through to platform default
|
|
297
337
|
|
|
298
338
|
# Use platform default
|
|
299
|
-
shell,
|
|
300
|
-
return
|
|
339
|
+
shell, _ = self._get_platform_default_environment()
|
|
340
|
+
return shell, ""
|
|
301
341
|
|
|
302
342
|
def check_task_status(
|
|
303
343
|
self,
|
|
@@ -305,7 +345,8 @@ class Executor:
|
|
|
305
345
|
args_dict: dict[str, Any],
|
|
306
346
|
force: bool = False,
|
|
307
347
|
) -> TaskStatus:
|
|
308
|
-
"""
|
|
348
|
+
"""
|
|
349
|
+
Check if a task needs to run.
|
|
309
350
|
|
|
310
351
|
A task executes if ANY of these conditions are met:
|
|
311
352
|
1. Force flag is set (--force)
|
|
@@ -318,12 +359,13 @@ class Executor:
|
|
|
318
359
|
8. Different arguments than any cached execution
|
|
319
360
|
|
|
320
361
|
Args:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
362
|
+
task: Task to check
|
|
363
|
+
args_dict: Arguments for this task execution
|
|
364
|
+
force: If True, ignore freshness and force execution
|
|
324
365
|
|
|
325
366
|
Returns:
|
|
326
|
-
|
|
367
|
+
TaskStatus indicating whether task will run and why
|
|
368
|
+
@athena: 7252f5db8a4d
|
|
327
369
|
"""
|
|
328
370
|
# If force flag is set, always run
|
|
329
371
|
if force:
|
|
@@ -335,7 +377,14 @@ class Executor:
|
|
|
335
377
|
|
|
336
378
|
# Compute hashes (include effective environment and dependencies)
|
|
337
379
|
effective_env = self._get_effective_env_name(task)
|
|
338
|
-
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
|
+
)
|
|
339
388
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
340
389
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
341
390
|
|
|
@@ -404,19 +453,21 @@ class Executor:
|
|
|
404
453
|
force: bool = False,
|
|
405
454
|
only: bool = False,
|
|
406
455
|
) -> dict[str, TaskStatus]:
|
|
407
|
-
"""
|
|
456
|
+
"""
|
|
457
|
+
Execute a task and its dependencies.
|
|
408
458
|
|
|
409
459
|
Args:
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
460
|
+
task_name: Name of task to execute
|
|
461
|
+
args_dict: Arguments to pass to the task
|
|
462
|
+
force: If True, ignore freshness and re-run all tasks
|
|
463
|
+
only: If True, run only the specified task without dependencies (implies force=True)
|
|
414
464
|
|
|
415
465
|
Returns:
|
|
416
|
-
|
|
466
|
+
Dictionary of task names to their execution status
|
|
417
467
|
|
|
418
468
|
Raises:
|
|
419
|
-
|
|
469
|
+
ExecutionError: If task execution fails
|
|
470
|
+
@athena: 1c293ee6a6fa
|
|
420
471
|
"""
|
|
421
472
|
if args_dict is None:
|
|
422
473
|
args_dict = {}
|
|
@@ -456,13 +507,20 @@ class Executor:
|
|
|
456
507
|
# Only include regular (non-exported) args in status key for parameterized dependencies
|
|
457
508
|
# For the root task (invoked from CLI), status key is always just the task name
|
|
458
509
|
# For dependencies with parameterized invocations, include the regular args
|
|
459
|
-
is_root_task =
|
|
460
|
-
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
|
+
):
|
|
461
516
|
import json
|
|
517
|
+
|
|
462
518
|
# Filter to only include regular (non-exported) args
|
|
463
519
|
regular_args = self._filter_regular_args(task, args_dict_for_execution)
|
|
464
520
|
if regular_args:
|
|
465
|
-
args_str = json.dumps(
|
|
521
|
+
args_str = json.dumps(
|
|
522
|
+
regular_args, sort_keys=True, separators=(",", ":")
|
|
523
|
+
)
|
|
466
524
|
status_key = f"{name}({args_str})"
|
|
467
525
|
else:
|
|
468
526
|
status_key = name
|
|
@@ -475,6 +533,7 @@ class Executor:
|
|
|
475
533
|
# Warn if re-running due to missing outputs
|
|
476
534
|
if status.reason == "outputs_missing":
|
|
477
535
|
import sys
|
|
536
|
+
|
|
478
537
|
print(
|
|
479
538
|
f"Warning: Re-running task '{name}' because declared outputs are missing",
|
|
480
539
|
file=sys.stderr,
|
|
@@ -485,14 +544,16 @@ class Executor:
|
|
|
485
544
|
return statuses
|
|
486
545
|
|
|
487
546
|
def _run_task(self, task: Task, args_dict: dict[str, Any]) -> None:
|
|
488
|
-
"""
|
|
547
|
+
"""
|
|
548
|
+
Execute a single task.
|
|
489
549
|
|
|
490
550
|
Args:
|
|
491
|
-
|
|
492
|
-
|
|
551
|
+
task: Task to execute
|
|
552
|
+
args_dict: Arguments to substitute in command
|
|
493
553
|
|
|
494
554
|
Raises:
|
|
495
|
-
|
|
555
|
+
ExecutionError: If task execution fails
|
|
556
|
+
@athena: 4b49652a7afd
|
|
496
557
|
"""
|
|
497
558
|
# Capture timestamp at task start for consistency (in UTC)
|
|
498
559
|
task_start_time = datetime.now(timezone.utc)
|
|
@@ -500,6 +561,7 @@ class Executor:
|
|
|
500
561
|
# Parse task arguments to identify exported args
|
|
501
562
|
# Note: args_dict already has defaults applied by CLI (cli.py:413-424)
|
|
502
563
|
from tasktree.parser import parse_arg_spec
|
|
564
|
+
|
|
503
565
|
exported_args = set()
|
|
504
566
|
regular_args = {}
|
|
505
567
|
exported_env_vars = {}
|
|
@@ -518,18 +580,24 @@ class Executor:
|
|
|
518
580
|
|
|
519
581
|
# Collect early built-in variables (those that don't depend on working_dir)
|
|
520
582
|
# These can be used in the working_dir field itself
|
|
521
|
-
early_builtin_vars = self._collect_early_builtin_variables(
|
|
583
|
+
early_builtin_vars = self._collect_early_builtin_variables(
|
|
584
|
+
task, task_start_time
|
|
585
|
+
)
|
|
522
586
|
|
|
523
587
|
# Resolve working directory
|
|
524
588
|
# Validate that working_dir doesn't contain {{ tt.working_dir }} (circular dependency)
|
|
525
589
|
self._validate_no_working_dir_circular_ref(task.working_dir)
|
|
526
590
|
working_dir_str = self._substitute_builtin(task.working_dir, early_builtin_vars)
|
|
527
|
-
working_dir_str = self._substitute_args(
|
|
591
|
+
working_dir_str = self._substitute_args(
|
|
592
|
+
working_dir_str, regular_args, exported_args
|
|
593
|
+
)
|
|
528
594
|
working_dir_str = self._substitute_env(working_dir_str)
|
|
529
595
|
working_dir = self.recipe.project_root / working_dir_str
|
|
530
596
|
|
|
531
597
|
# Collect all built-in variables (including tt.working_dir now that it's resolved)
|
|
532
|
-
builtin_vars = self._collect_builtin_variables(
|
|
598
|
+
builtin_vars = self._collect_builtin_variables(
|
|
599
|
+
task, working_dir, task_start_time
|
|
600
|
+
)
|
|
533
601
|
|
|
534
602
|
# Substitute built-in variables, arguments, and environment variables in command
|
|
535
603
|
cmd = self._substitute_builtin(task.cmd, builtin_vars)
|
|
@@ -550,69 +618,43 @@ class Executor:
|
|
|
550
618
|
# Docker execution path
|
|
551
619
|
self._run_task_in_docker(task, env, cmd, working_dir, exported_env_vars)
|
|
552
620
|
else:
|
|
553
|
-
# Regular execution path
|
|
554
|
-
shell,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
self._run_multiline_command(cmd, working_dir, task.name, shell, preamble, exported_env_vars)
|
|
559
|
-
else:
|
|
560
|
-
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
|
+
)
|
|
561
626
|
|
|
562
627
|
# Update state
|
|
563
628
|
self._update_state(task, args_dict)
|
|
564
629
|
|
|
565
|
-
def
|
|
566
|
-
self,
|
|
567
|
-
|
|
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,
|
|
568
638
|
) -> None:
|
|
569
|
-
"""Execute a single-line command via shell.
|
|
570
|
-
|
|
571
|
-
Args:
|
|
572
|
-
cmd: Command string
|
|
573
|
-
working_dir: Working directory
|
|
574
|
-
task_name: Task name (for error messages)
|
|
575
|
-
shell: Shell executable to use
|
|
576
|
-
shell_args: Arguments to pass to shell
|
|
577
|
-
exported_env_vars: Exported arguments to set as environment variables
|
|
578
|
-
|
|
579
|
-
Raises:
|
|
580
|
-
ExecutionError: If command execution fails
|
|
581
639
|
"""
|
|
582
|
-
|
|
583
|
-
env = self._prepare_env_with_exports(exported_env_vars)
|
|
640
|
+
Execute a command via temporary script file (unified execution path).
|
|
584
641
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
subprocess.run(
|
|
589
|
-
full_cmd,
|
|
590
|
-
cwd=working_dir,
|
|
591
|
-
check=True,
|
|
592
|
-
capture_output=False,
|
|
593
|
-
env=env,
|
|
594
|
-
)
|
|
595
|
-
except subprocess.CalledProcessError as e:
|
|
596
|
-
raise ExecutionError(
|
|
597
|
-
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
def _run_multiline_command(
|
|
601
|
-
self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str,
|
|
602
|
-
exported_env_vars: dict[str, str] | None = None
|
|
603
|
-
) -> None:
|
|
604
|
-
"""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.
|
|
605
645
|
|
|
606
646
|
Args:
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
647
|
+
cmd: Command string (single-line or multi-line)
|
|
648
|
+
working_dir: Working directory
|
|
649
|
+
task_name: Task name (for error messages)
|
|
650
|
+
shell: Shell to use for script execution
|
|
651
|
+
preamble: Preamble text to prepend to script
|
|
652
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
613
653
|
|
|
614
654
|
Raises:
|
|
615
|
-
|
|
655
|
+
ExecutionError: If command execution fails
|
|
656
|
+
@athena: TBD
|
|
657
|
+
@athena: 96e85dc15b5c
|
|
616
658
|
"""
|
|
617
659
|
# Prepare environment with exported args
|
|
618
660
|
env = self._prepare_env_with_exports(exported_env_vars)
|
|
@@ -670,45 +712,71 @@ class Executor:
|
|
|
670
712
|
except OSError:
|
|
671
713
|
pass # Ignore cleanup errors
|
|
672
714
|
|
|
673
|
-
def _substitute_builtin_in_environment(
|
|
674
|
-
|
|
715
|
+
def _substitute_builtin_in_environment(
|
|
716
|
+
self, env: Environment, builtin_vars: dict[str, str]
|
|
717
|
+
) -> Environment:
|
|
718
|
+
"""
|
|
719
|
+
Substitute builtin and environment variables in environment fields.
|
|
675
720
|
|
|
676
721
|
Args:
|
|
677
|
-
|
|
678
|
-
|
|
722
|
+
env: Environment to process
|
|
723
|
+
builtin_vars: Built-in variable values
|
|
679
724
|
|
|
680
725
|
Returns:
|
|
681
|
-
|
|
726
|
+
New Environment with builtin and environment variables substituted
|
|
682
727
|
|
|
683
728
|
Raises:
|
|
684
|
-
|
|
729
|
+
ValueError: If builtin variable or environment variable is not defined
|
|
730
|
+
@athena: 21e2ccd27dbb
|
|
685
731
|
"""
|
|
686
732
|
from dataclasses import replace
|
|
687
733
|
|
|
688
734
|
# Substitute in volumes (builtin vars first, then env vars)
|
|
689
|
-
substituted_volumes =
|
|
690
|
-
|
|
691
|
-
|
|
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
|
+
)
|
|
692
743
|
|
|
693
744
|
# Substitute in env_vars values (builtin vars first, then env vars)
|
|
694
|
-
substituted_env_vars =
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
+
)
|
|
698
753
|
|
|
699
754
|
# Substitute in ports (builtin vars first, then env vars)
|
|
700
|
-
substituted_ports =
|
|
701
|
-
|
|
702
|
-
|
|
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
|
+
)
|
|
703
763
|
|
|
704
764
|
# Substitute in working_dir (builtin vars first, then env vars)
|
|
705
|
-
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
|
+
)
|
|
706
772
|
|
|
707
773
|
# Substitute in build args (for Docker environments, args is a dict)
|
|
708
774
|
# Apply builtin vars first, then env vars
|
|
709
775
|
if isinstance(env.args, dict):
|
|
710
776
|
substituted_args = {
|
|
711
|
-
key: self._substitute_env(
|
|
777
|
+
key: self._substitute_env(
|
|
778
|
+
self._substitute_builtin(str(value), builtin_vars)
|
|
779
|
+
)
|
|
712
780
|
for key, value in env.args.items()
|
|
713
781
|
}
|
|
714
782
|
else:
|
|
@@ -721,28 +789,36 @@ class Executor:
|
|
|
721
789
|
env_vars=substituted_env_vars,
|
|
722
790
|
ports=substituted_ports,
|
|
723
791
|
working_dir=substituted_working_dir,
|
|
724
|
-
args=substituted_args
|
|
792
|
+
args=substituted_args,
|
|
725
793
|
)
|
|
726
794
|
|
|
727
795
|
def _run_task_in_docker(
|
|
728
|
-
self,
|
|
729
|
-
|
|
796
|
+
self,
|
|
797
|
+
task: Task,
|
|
798
|
+
env: Any,
|
|
799
|
+
cmd: str,
|
|
800
|
+
working_dir: Path,
|
|
801
|
+
exported_env_vars: dict[str, str] | None = None,
|
|
730
802
|
) -> None:
|
|
731
|
-
"""
|
|
803
|
+
"""
|
|
804
|
+
Execute task inside Docker container.
|
|
732
805
|
|
|
733
806
|
Args:
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
807
|
+
task: Task to execute
|
|
808
|
+
env: Docker environment configuration
|
|
809
|
+
cmd: Command to execute
|
|
810
|
+
working_dir: Host working directory
|
|
811
|
+
exported_env_vars: Exported arguments to set as environment variables
|
|
739
812
|
|
|
740
813
|
Raises:
|
|
741
|
-
|
|
814
|
+
ExecutionError: If Docker execution fails
|
|
815
|
+
@athena: fe972e4c97a3
|
|
742
816
|
"""
|
|
743
817
|
# Get builtin variables for substitution in environment fields
|
|
744
818
|
task_start_time = datetime.now(timezone.utc)
|
|
745
|
-
builtin_vars = self._collect_builtin_variables(
|
|
819
|
+
builtin_vars = self._collect_builtin_variables(
|
|
820
|
+
task, working_dir, task_start_time
|
|
821
|
+
)
|
|
746
822
|
|
|
747
823
|
# Substitute builtin variables in environment fields (volumes, env_vars, etc.)
|
|
748
824
|
env = self._substitute_builtin_in_environment(env, builtin_vars)
|
|
@@ -766,6 +842,7 @@ class Executor:
|
|
|
766
842
|
|
|
767
843
|
# Create modified environment with merged env vars using dataclass replace
|
|
768
844
|
from dataclasses import replace
|
|
845
|
+
|
|
769
846
|
modified_env = replace(env, env_vars=docker_env_vars)
|
|
770
847
|
|
|
771
848
|
# Execute in container
|
|
@@ -779,91 +856,111 @@ class Executor:
|
|
|
779
856
|
except docker_module.DockerError as e:
|
|
780
857
|
raise ExecutionError(str(e)) from e
|
|
781
858
|
|
|
782
|
-
|
|
783
|
-
|
|
859
|
+
@staticmethod
|
|
860
|
+
def _validate_no_working_dir_circular_ref(text: str) -> None:
|
|
861
|
+
"""
|
|
862
|
+
Validate that working_dir field does not contain {{ tt.working_dir }}.
|
|
784
863
|
|
|
785
864
|
Using {{ tt.working_dir }} in the working_dir field creates a circular dependency.
|
|
786
865
|
|
|
787
866
|
Args:
|
|
788
|
-
|
|
867
|
+
text: The working_dir field value to validate
|
|
789
868
|
|
|
790
869
|
Raises:
|
|
791
|
-
|
|
870
|
+
ExecutionError: If {{ tt.working_dir }} placeholder is found
|
|
871
|
+
@athena: 617a0c609f4d
|
|
792
872
|
"""
|
|
793
873
|
import re
|
|
874
|
+
|
|
794
875
|
# Pattern to match {{ tt.working_dir }} specifically
|
|
795
|
-
pattern = re.compile(r
|
|
876
|
+
pattern = re.compile(r"\{\{\s*tt\s*\.\s*working_dir\s*}}")
|
|
796
877
|
|
|
797
878
|
if pattern.search(text):
|
|
798
879
|
raise ExecutionError(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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."
|
|
802
883
|
)
|
|
803
884
|
|
|
804
|
-
|
|
805
|
-
|
|
885
|
+
@staticmethod
|
|
886
|
+
def _substitute_builtin(text: str, builtin_vars: dict[str, str]) -> str:
|
|
887
|
+
"""
|
|
888
|
+
Substitute {{ tt.name }} placeholders in text.
|
|
806
889
|
|
|
807
890
|
Built-in variables are resolved at execution time.
|
|
808
891
|
|
|
809
892
|
Args:
|
|
810
|
-
|
|
811
|
-
|
|
893
|
+
text: Text with {{ tt.name }} placeholders
|
|
894
|
+
builtin_vars: Built-in variable values
|
|
812
895
|
|
|
813
896
|
Returns:
|
|
814
|
-
|
|
897
|
+
Text with built-in variables substituted
|
|
815
898
|
|
|
816
899
|
Raises:
|
|
817
|
-
|
|
900
|
+
ValueError: If built-in variable is not defined
|
|
901
|
+
@athena: fe47afe87b52
|
|
818
902
|
"""
|
|
819
903
|
from tasktree.substitution import substitute_builtin_variables
|
|
904
|
+
|
|
820
905
|
return substitute_builtin_variables(text, builtin_vars)
|
|
821
906
|
|
|
822
|
-
|
|
823
|
-
|
|
907
|
+
@staticmethod
|
|
908
|
+
def _substitute_args(
|
|
909
|
+
cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None
|
|
910
|
+
) -> str:
|
|
911
|
+
"""
|
|
912
|
+
Substitute {{ arg.name }} placeholders in command string.
|
|
824
913
|
|
|
825
914
|
Variables are already substituted at parse time by the parser.
|
|
826
915
|
This only handles runtime argument substitution.
|
|
827
916
|
|
|
828
917
|
Args:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
918
|
+
cmd: Command with {{ arg.name }} placeholders
|
|
919
|
+
args_dict: Argument values to substitute (only regular args)
|
|
920
|
+
exported_args: Set of argument names that are exported (not available for substitution)
|
|
832
921
|
|
|
833
922
|
Returns:
|
|
834
|
-
|
|
923
|
+
Command with arguments substituted
|
|
835
924
|
|
|
836
925
|
Raises:
|
|
837
|
-
|
|
926
|
+
ValueError: If an exported argument is used in template substitution
|
|
927
|
+
@athena: 9a931179f270
|
|
838
928
|
"""
|
|
839
929
|
from tasktree.substitution import substitute_arguments
|
|
930
|
+
|
|
840
931
|
return substitute_arguments(cmd, args_dict, exported_args)
|
|
841
932
|
|
|
842
|
-
|
|
843
|
-
|
|
933
|
+
@staticmethod
|
|
934
|
+
def _substitute_env(text: str) -> str:
|
|
935
|
+
"""
|
|
936
|
+
Substitute {{ env.NAME }} placeholders in text.
|
|
844
937
|
|
|
845
938
|
Environment variables are resolved at execution time from os.environ.
|
|
846
939
|
|
|
847
940
|
Args:
|
|
848
|
-
|
|
941
|
+
text: Text with {{ env.NAME }} placeholders
|
|
849
942
|
|
|
850
943
|
Returns:
|
|
851
|
-
|
|
944
|
+
Text with environment variables substituted
|
|
852
945
|
|
|
853
946
|
Raises:
|
|
854
|
-
|
|
947
|
+
ValueError: If environment variable is not set
|
|
948
|
+
@athena: 1bbe24759451
|
|
855
949
|
"""
|
|
856
950
|
from tasktree.substitution import substitute_environment
|
|
951
|
+
|
|
857
952
|
return substitute_environment(text)
|
|
858
953
|
|
|
859
954
|
def _get_all_inputs(self, task: Task) -> list[str]:
|
|
860
|
-
"""
|
|
955
|
+
"""
|
|
956
|
+
Get all inputs for a task (explicit + implicit from dependencies).
|
|
861
957
|
|
|
862
958
|
Args:
|
|
863
|
-
|
|
959
|
+
task: Task to get inputs for
|
|
864
960
|
|
|
865
961
|
Returns:
|
|
866
|
-
|
|
962
|
+
List of input glob patterns
|
|
963
|
+
@athena: ca7ed7a6682f
|
|
867
964
|
"""
|
|
868
965
|
# Extract paths from inputs (handle both anonymous strings and named dicts)
|
|
869
966
|
all_inputs = []
|
|
@@ -878,21 +975,24 @@ class Executor:
|
|
|
878
975
|
all_inputs.extend(implicit_inputs)
|
|
879
976
|
return all_inputs
|
|
880
977
|
|
|
978
|
+
# TODO: Understand why task isn't used
|
|
881
979
|
def _check_environment_changed(
|
|
882
980
|
self, task: Task, cached_state: TaskState, env_name: str
|
|
883
981
|
) -> bool:
|
|
884
|
-
"""
|
|
982
|
+
"""
|
|
983
|
+
Check if environment definition has changed since last run.
|
|
885
984
|
|
|
886
985
|
For shell environments: checks YAML definition hash
|
|
887
986
|
For Docker environments: checks YAML hash AND Docker image ID
|
|
888
987
|
|
|
889
988
|
Args:
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
989
|
+
task: Task to check
|
|
990
|
+
cached_state: Cached state from previous run
|
|
991
|
+
env_name: Effective environment name (from _get_effective_env_name)
|
|
893
992
|
|
|
894
993
|
Returns:
|
|
895
|
-
|
|
994
|
+
True if environment definition changed, False otherwise
|
|
995
|
+
@athena: 052561b75455
|
|
896
996
|
"""
|
|
897
997
|
# If using platform default (no environment), no definition to track
|
|
898
998
|
if not env_name:
|
|
@@ -931,23 +1031,25 @@ class Executor:
|
|
|
931
1031
|
def _check_docker_image_changed(
|
|
932
1032
|
self, env: Environment, cached_state: TaskState, env_name: str
|
|
933
1033
|
) -> bool:
|
|
934
|
-
"""
|
|
1034
|
+
"""
|
|
1035
|
+
Check if Docker image ID has changed.
|
|
935
1036
|
|
|
936
1037
|
Builds the image and compares the resulting image ID with the cached ID.
|
|
937
1038
|
This detects changes from unpinned base images, network-dependent builds, etc.
|
|
938
1039
|
|
|
939
1040
|
Args:
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1041
|
+
env: Docker environment definition
|
|
1042
|
+
cached_state: Cached state from previous run
|
|
1043
|
+
env_name: Environment name
|
|
943
1044
|
|
|
944
1045
|
Returns:
|
|
945
|
-
|
|
1046
|
+
True if image ID changed, False otherwise
|
|
1047
|
+
@athena: 0443710cf356
|
|
946
1048
|
"""
|
|
947
1049
|
# Build/ensure image is built and get its ID
|
|
948
1050
|
try:
|
|
949
1051
|
image_tag, current_image_id = self.docker_manager.ensure_image_built(env)
|
|
950
|
-
except Exception
|
|
1052
|
+
except Exception:
|
|
951
1053
|
# If we can't build, treat as changed (will fail later with better error)
|
|
952
1054
|
return True
|
|
953
1055
|
|
|
@@ -965,7 +1067,8 @@ class Executor:
|
|
|
965
1067
|
def _check_inputs_changed(
|
|
966
1068
|
self, task: Task, cached_state: TaskState, all_inputs: list[str]
|
|
967
1069
|
) -> list[str]:
|
|
968
|
-
"""
|
|
1070
|
+
"""
|
|
1071
|
+
Check if any input files have changed since last run.
|
|
969
1072
|
|
|
970
1073
|
Handles both regular file inputs and Docker-specific inputs:
|
|
971
1074
|
- Regular files: checked via mtime
|
|
@@ -973,12 +1076,13 @@ class Executor:
|
|
|
973
1076
|
- Dockerfile digests: checked via parsing and comparison
|
|
974
1077
|
|
|
975
1078
|
Args:
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1079
|
+
task: Task to check
|
|
1080
|
+
cached_state: Cached state from previous run
|
|
1081
|
+
all_inputs: All input glob patterns
|
|
979
1082
|
|
|
980
1083
|
Returns:
|
|
981
|
-
|
|
1084
|
+
List of changed file paths
|
|
1085
|
+
@athena: 15b13fd181bf
|
|
982
1086
|
"""
|
|
983
1087
|
changed_files = []
|
|
984
1088
|
|
|
@@ -1038,7 +1142,9 @@ class Executor:
|
|
|
1038
1142
|
|
|
1039
1143
|
# Check if digests changed
|
|
1040
1144
|
if current_digests != cached_digests:
|
|
1041
|
-
changed_files.append(
|
|
1145
|
+
changed_files.append(
|
|
1146
|
+
f"Docker base image digests in {dockerfile_name}"
|
|
1147
|
+
)
|
|
1042
1148
|
except (OSError, IOError):
|
|
1043
1149
|
# Can't read Dockerfile - consider changed
|
|
1044
1150
|
changed_files.append(f"Dockerfile: {dockerfile_name}")
|
|
@@ -1058,14 +1164,17 @@ class Executor:
|
|
|
1058
1164
|
|
|
1059
1165
|
return changed_files
|
|
1060
1166
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1167
|
+
@staticmethod
|
|
1168
|
+
def _expand_output_paths(task: Task) -> list[str]:
|
|
1169
|
+
"""
|
|
1170
|
+
Extract all output paths from task outputs (both named and anonymous).
|
|
1063
1171
|
|
|
1064
1172
|
Args:
|
|
1065
|
-
|
|
1173
|
+
task: Task with outputs to extract
|
|
1066
1174
|
|
|
1067
1175
|
Returns:
|
|
1068
|
-
|
|
1176
|
+
List of output path patterns (glob patterns as strings)
|
|
1177
|
+
@athena: 21da23ad5dcf
|
|
1069
1178
|
"""
|
|
1070
1179
|
paths = []
|
|
1071
1180
|
for output in task.outputs:
|
|
@@ -1078,13 +1187,15 @@ class Executor:
|
|
|
1078
1187
|
return paths
|
|
1079
1188
|
|
|
1080
1189
|
def _check_outputs_missing(self, task: Task) -> list[str]:
|
|
1081
|
-
"""
|
|
1190
|
+
"""
|
|
1191
|
+
Check if any declared outputs are missing.
|
|
1082
1192
|
|
|
1083
1193
|
Args:
|
|
1084
|
-
|
|
1194
|
+
task: Task to check
|
|
1085
1195
|
|
|
1086
1196
|
Returns:
|
|
1087
|
-
|
|
1197
|
+
List of output patterns that have no matching files
|
|
1198
|
+
@athena: 9ceac49b4e68
|
|
1088
1199
|
"""
|
|
1089
1200
|
if not task.outputs:
|
|
1090
1201
|
return []
|
|
@@ -1104,14 +1215,16 @@ class Executor:
|
|
|
1104
1215
|
return missing_patterns
|
|
1105
1216
|
|
|
1106
1217
|
def _expand_globs(self, patterns: list[str], working_dir: str) -> list[str]:
|
|
1107
|
-
"""
|
|
1218
|
+
"""
|
|
1219
|
+
Expand glob patterns to actual file paths.
|
|
1108
1220
|
|
|
1109
1221
|
Args:
|
|
1110
|
-
|
|
1111
|
-
|
|
1222
|
+
patterns: List of glob patterns
|
|
1223
|
+
working_dir: Working directory to resolve patterns from
|
|
1112
1224
|
|
|
1113
1225
|
Returns:
|
|
1114
|
-
|
|
1226
|
+
List of file paths (relative to working_dir)
|
|
1227
|
+
@athena: 5ba093866558
|
|
1115
1228
|
"""
|
|
1116
1229
|
files = []
|
|
1117
1230
|
base_path = self.recipe.project_root / working_dir
|
|
@@ -1128,25 +1241,44 @@ class Executor:
|
|
|
1128
1241
|
return files
|
|
1129
1242
|
|
|
1130
1243
|
def _update_state(self, task: Task, args_dict: dict[str, Any]) -> None:
|
|
1131
|
-
"""Update state after task execution.
|
|
1132
|
-
|
|
1133
|
-
Args:
|
|
1134
|
-
task: Task that was executed
|
|
1135
|
-
args_dict: Arguments used for execution
|
|
1136
1244
|
"""
|
|
1137
|
-
|
|
1245
|
+
Update state after task execution.
|
|
1246
|
+
@athena: 1fcfdfcb9be9
|
|
1247
|
+
"""
|
|
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:
|
|
1138
1264
|
effective_env = self._get_effective_env_name(task)
|
|
1139
|
-
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
|
+
)
|
|
1140
1273
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
1141
|
-
|
|
1274
|
+
return make_cache_key(task_hash, args_hash)
|
|
1142
1275
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
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)
|
|
1146
1278
|
|
|
1147
1279
|
input_state = {}
|
|
1148
1280
|
for file_path in input_files:
|
|
1149
|
-
# Skip Docker special markers (handled separately
|
|
1281
|
+
# Skip Docker special markers (handled separately)
|
|
1150
1282
|
if file_path.startswith("_docker_"):
|
|
1151
1283
|
continue
|
|
1152
1284
|
|
|
@@ -1154,59 +1286,44 @@ class Executor:
|
|
|
1154
1286
|
if file_path_obj.exists():
|
|
1155
1287
|
input_state[file_path] = file_path_obj.stat().st_mtime
|
|
1156
1288
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
# Record context check timestamp
|
|
1177
|
-
input_state[f"_context_{env.context}"] = time.time()
|
|
1178
|
-
|
|
1179
|
-
# Parse and record base image digests from Dockerfile
|
|
1180
|
-
try:
|
|
1181
|
-
dockerfile_content = dockerfile_path.read_text()
|
|
1182
|
-
digests = docker_module.parse_base_image_digests(dockerfile_content)
|
|
1183
|
-
for digest in digests:
|
|
1184
|
-
# Store digest with Dockerfile's mtime
|
|
1185
|
-
input_state[f"_digest_{digest}"] = dockerfile_path.stat().st_mtime
|
|
1186
|
-
except (OSError, IOError):
|
|
1187
|
-
# If we can't read Dockerfile, skip digest tracking
|
|
1188
|
-
pass
|
|
1189
|
-
|
|
1190
|
-
# Record environment definition hash for all environments (shell and Docker)
|
|
1191
|
-
if env:
|
|
1192
|
-
from tasktree.hasher import hash_environment_definition
|
|
1193
|
-
|
|
1194
|
-
env_hash = hash_environment_definition(env)
|
|
1195
|
-
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
|
|
1196
1308
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
# Image was already built during check phase or task execution
|
|
1200
|
-
if env_name in self.docker_manager._built_images:
|
|
1201
|
-
image_tag, image_id = self.docker_manager._built_images[env_name]
|
|
1202
|
-
input_state[f"_docker_image_id_{env_name}"] = image_id
|
|
1203
|
-
|
|
1204
|
-
# Create new state
|
|
1205
|
-
state = TaskState(
|
|
1206
|
-
last_run=time.time(),
|
|
1207
|
-
input_state=input_state,
|
|
1208
|
-
)
|
|
1309
|
+
# Record context check timestamp
|
|
1310
|
+
input_state[f"_context_{env.context}"] = time.time()
|
|
1209
1311
|
|
|
1210
|
-
#
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|