tasktree 0.0.21__py3-none-any.whl → 0.0.23__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 +1 -1
- tasktree/cli.py +212 -119
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +36 -23
- tasktree/executor.py +412 -240
- tasktree/graph.py +18 -13
- tasktree/hasher.py +18 -11
- tasktree/logging.py +112 -0
- tasktree/parser.py +237 -135
- tasktree/process_runner.py +411 -0
- tasktree/state.py +7 -8
- tasktree/substitution.py +29 -17
- tasktree/types.py +32 -15
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/METADATA +213 -18
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.21.dist-info/RECORD +0 -14
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.21.dist-info → tasktree-0.0.23.dist-info}/entry_points.txt +0 -0
tasktree/executor.py
CHANGED
|
@@ -2,22 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import io
|
|
5
6
|
import os
|
|
6
7
|
import platform
|
|
7
8
|
import stat
|
|
8
9
|
import subprocess
|
|
10
|
+
import sys
|
|
9
11
|
import tempfile
|
|
10
12
|
import time
|
|
11
13
|
from dataclasses import dataclass, field
|
|
12
14
|
from datetime import datetime, timezone
|
|
13
15
|
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
16
|
+
from typing import Any, Callable
|
|
15
17
|
|
|
16
18
|
from tasktree import docker as docker_module
|
|
17
|
-
from tasktree.graph import
|
|
19
|
+
from tasktree.graph import (
|
|
20
|
+
get_implicit_inputs,
|
|
21
|
+
resolve_execution_order,
|
|
22
|
+
resolve_dependency_output_references,
|
|
23
|
+
resolve_self_references,
|
|
24
|
+
)
|
|
18
25
|
from tasktree.hasher import hash_args, hash_task, make_cache_key
|
|
26
|
+
from tasktree.logging import Logger, LogLevel
|
|
19
27
|
from tasktree.parser import Recipe, Task, Environment
|
|
28
|
+
from tasktree.process_runner import ProcessRunner, TaskOutputTypes
|
|
20
29
|
from tasktree.state import StateManager, TaskState
|
|
30
|
+
from tasktree.hasher import hash_environment_definition
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
@dataclass
|
|
@@ -47,35 +57,46 @@ class ExecutionError(Exception):
|
|
|
47
57
|
class Executor:
|
|
48
58
|
"""
|
|
49
59
|
Executes tasks with incremental execution logic.
|
|
50
|
-
@athena:
|
|
60
|
+
@athena: 779b12944194
|
|
51
61
|
"""
|
|
52
62
|
|
|
53
63
|
# Protected environment variables that cannot be overridden by exported args
|
|
54
64
|
PROTECTED_ENV_VARS = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
"PATH",
|
|
66
|
+
"LD_LIBRARY_PATH",
|
|
67
|
+
"LD_PRELOAD",
|
|
68
|
+
"PYTHONPATH",
|
|
69
|
+
"HOME",
|
|
70
|
+
"SHELL",
|
|
71
|
+
"USER",
|
|
72
|
+
"LOGNAME",
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
def __init__(
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
recipe: Recipe,
|
|
78
|
+
state_manager: StateManager,
|
|
79
|
+
logger: Logger,
|
|
80
|
+
process_runner_factory: Callable[[TaskOutputTypes, Logger], ProcessRunner]
|
|
81
|
+
):
|
|
66
82
|
"""
|
|
67
83
|
Initialize executor.
|
|
68
84
|
|
|
69
85
|
Args:
|
|
70
86
|
recipe: Parsed recipe containing all tasks
|
|
71
87
|
state_manager: State manager for tracking task execution
|
|
72
|
-
|
|
88
|
+
logger_fn: Logger function for output (matches Console.print signature)
|
|
89
|
+
process_runner_factory: Factory function for creating ProcessRunner instances
|
|
90
|
+
@athena: d09e6a537c99
|
|
73
91
|
"""
|
|
74
92
|
self.recipe = recipe
|
|
75
93
|
self.state = state_manager
|
|
94
|
+
self.logger = logger
|
|
95
|
+
self._process_runner_factory = process_runner_factory
|
|
76
96
|
self.docker_manager = docker_module.DockerManager(recipe.project_root)
|
|
77
97
|
|
|
78
|
-
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _has_regular_args(task: Task) -> bool:
|
|
79
100
|
"""
|
|
80
101
|
Check if a task has any regular (non-exported) arguments.
|
|
81
102
|
|
|
@@ -84,7 +105,7 @@ class Executor:
|
|
|
84
105
|
|
|
85
106
|
Returns:
|
|
86
107
|
True if task has at least one regular (non-exported) argument, False otherwise
|
|
87
|
-
@athena:
|
|
108
|
+
@athena: c529cda63cce
|
|
88
109
|
"""
|
|
89
110
|
if not task.args:
|
|
90
111
|
return False
|
|
@@ -94,18 +115,19 @@ class Executor:
|
|
|
94
115
|
# Handle both string and dict arg specs
|
|
95
116
|
if isinstance(arg_spec, str):
|
|
96
117
|
# Remove default value part if present
|
|
97
|
-
arg_name = arg_spec.split(
|
|
98
|
-
if not arg_name.startswith(
|
|
118
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
119
|
+
if not arg_name.startswith("$"):
|
|
99
120
|
return True
|
|
100
121
|
elif isinstance(arg_spec, dict):
|
|
101
122
|
# Dict format: { argname: { ... } } or { $argname: { ... } }
|
|
102
123
|
for key in arg_spec.keys():
|
|
103
|
-
if not key.startswith(
|
|
124
|
+
if not key.startswith("$"):
|
|
104
125
|
return True
|
|
105
126
|
|
|
106
127
|
return False
|
|
107
128
|
|
|
108
|
-
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _filter_regular_args(task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
|
|
109
131
|
"""
|
|
110
132
|
Filter task_args to only include regular (non-exported) arguments.
|
|
111
133
|
|
|
@@ -115,7 +137,7 @@ class Executor:
|
|
|
115
137
|
|
|
116
138
|
Returns:
|
|
117
139
|
Dictionary containing only regular (non-exported) arguments
|
|
118
|
-
@athena:
|
|
140
|
+
@athena: 1ae863406335
|
|
119
141
|
"""
|
|
120
142
|
if not task.args or not task_args:
|
|
121
143
|
return {}
|
|
@@ -124,18 +146,20 @@ class Executor:
|
|
|
124
146
|
exported_names = set()
|
|
125
147
|
for arg_spec in task.args:
|
|
126
148
|
if isinstance(arg_spec, str):
|
|
127
|
-
arg_name = arg_spec.split(
|
|
128
|
-
if arg_name.startswith(
|
|
149
|
+
arg_name = arg_spec.split("=")[0].split(":")[0].strip()
|
|
150
|
+
if arg_name.startswith("$"):
|
|
129
151
|
exported_names.add(arg_name[1:]) # Remove $ prefix
|
|
130
152
|
elif isinstance(arg_spec, dict):
|
|
131
153
|
for key in arg_spec.keys():
|
|
132
|
-
if key.startswith(
|
|
154
|
+
if key.startswith("$"):
|
|
133
155
|
exported_names.add(key[1:]) # Remove $ prefix
|
|
134
156
|
|
|
135
157
|
# Filter out exported args
|
|
136
158
|
return {k: v for k, v in task_args.items() if k not in exported_names}
|
|
137
159
|
|
|
138
|
-
def _collect_early_builtin_variables(
|
|
160
|
+
def _collect_early_builtin_variables(
|
|
161
|
+
self, task: Task, timestamp: datetime
|
|
162
|
+
) -> dict[str, str]:
|
|
139
163
|
"""
|
|
140
164
|
Collect built-in variables that don't depend on working_dir.
|
|
141
165
|
|
|
@@ -150,31 +174,27 @@ class Executor:
|
|
|
150
174
|
|
|
151
175
|
Raises:
|
|
152
176
|
ExecutionError: If any built-in variable fails to resolve
|
|
153
|
-
@athena:
|
|
177
|
+
@athena: a0c1316fd713
|
|
154
178
|
"""
|
|
155
179
|
import os
|
|
156
180
|
|
|
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()))
|
|
181
|
+
builtin_vars = {
|
|
182
|
+
# {{ tt.project_root }} - Absolute path to project root
|
|
183
|
+
"project_root": str(self.recipe.project_root.resolve()),
|
|
184
|
+
# {{ tt.recipe_dir }} - Absolute path to directory containing the recipe file
|
|
185
|
+
"recipe_dir": str(self.recipe.recipe_path.parent.resolve()),
|
|
186
|
+
# {{ tt.task_name }} - Name of currently executing task
|
|
187
|
+
"task_name": task.name,
|
|
188
|
+
# {{ tt.timestamp }} - ISO8601 timestamp when task started execution
|
|
189
|
+
"timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
190
|
+
# {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
|
|
191
|
+
"timestamp_unix": str(int(timestamp.timestamp())),
|
|
192
|
+
}
|
|
173
193
|
|
|
174
194
|
# {{ tt.user_home }} - Current user's home directory (cross-platform)
|
|
175
195
|
try:
|
|
176
196
|
user_home = Path.home()
|
|
177
|
-
builtin_vars[
|
|
197
|
+
builtin_vars["user_home"] = str(user_home)
|
|
178
198
|
except Exception as e:
|
|
179
199
|
raise ExecutionError(
|
|
180
200
|
f"Failed to get user home directory for {{ tt.user_home }}: {e}"
|
|
@@ -185,12 +205,16 @@ class Executor:
|
|
|
185
205
|
user_name = os.getlogin()
|
|
186
206
|
except OSError:
|
|
187
207
|
# Fallback to environment variables if os.getlogin() fails
|
|
188
|
-
user_name =
|
|
189
|
-
|
|
208
|
+
user_name = (
|
|
209
|
+
os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
|
|
210
|
+
)
|
|
211
|
+
builtin_vars["user_name"] = user_name
|
|
190
212
|
|
|
191
213
|
return builtin_vars
|
|
192
214
|
|
|
193
|
-
def _collect_builtin_variables(
|
|
215
|
+
def _collect_builtin_variables(
|
|
216
|
+
self, task: Task, working_dir: Path, timestamp: datetime
|
|
217
|
+
) -> dict[str, str]:
|
|
194
218
|
"""
|
|
195
219
|
Collect built-in variables for task execution.
|
|
196
220
|
|
|
@@ -204,18 +228,20 @@ class Executor:
|
|
|
204
228
|
|
|
205
229
|
Raises:
|
|
206
230
|
ExecutionError: If any built-in variable fails to resolve
|
|
207
|
-
@athena:
|
|
231
|
+
@athena: 7f6203e8d617
|
|
208
232
|
"""
|
|
209
233
|
# Get early builtin vars (those that don't depend on working_dir)
|
|
210
234
|
builtin_vars = self._collect_early_builtin_variables(task, timestamp)
|
|
211
235
|
|
|
212
236
|
# {{ tt.working_dir }} - Absolute path to task's effective working directory
|
|
213
237
|
# This is added after working_dir is resolved to avoid circular dependency
|
|
214
|
-
builtin_vars[
|
|
238
|
+
builtin_vars["working_dir"] = str(working_dir.resolve())
|
|
215
239
|
|
|
216
240
|
return builtin_vars
|
|
217
241
|
|
|
218
|
-
def _prepare_env_with_exports(
|
|
242
|
+
def _prepare_env_with_exports(
|
|
243
|
+
self, exported_env_vars: dict[str, str] | None = None
|
|
244
|
+
) -> dict[str, str]:
|
|
219
245
|
"""
|
|
220
246
|
Prepare environment with exported arguments.
|
|
221
247
|
|
|
@@ -241,19 +267,20 @@ class Executor:
|
|
|
241
267
|
env.update(exported_env_vars)
|
|
242
268
|
return env
|
|
243
269
|
|
|
244
|
-
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _get_platform_default_environment() -> tuple[str, list[str]]:
|
|
245
272
|
"""
|
|
246
273
|
Get default shell and args for current platform.
|
|
247
274
|
|
|
248
275
|
Returns:
|
|
249
276
|
Tuple of (shell, args) for platform default
|
|
250
|
-
@athena:
|
|
277
|
+
@athena: 8b7fa81073af
|
|
251
278
|
"""
|
|
252
279
|
is_windows = platform.system() == "Windows"
|
|
253
280
|
if is_windows:
|
|
254
|
-
return
|
|
281
|
+
return "cmd", ["/c"]
|
|
255
282
|
else:
|
|
256
|
-
return
|
|
283
|
+
return "bash", ["-c"]
|
|
257
284
|
|
|
258
285
|
def _get_effective_env_name(self, task: Task) -> str:
|
|
259
286
|
"""
|
|
@@ -287,7 +314,7 @@ class Executor:
|
|
|
287
314
|
# Platform default (no env name)
|
|
288
315
|
return ""
|
|
289
316
|
|
|
290
|
-
def _resolve_environment(self, task: Task) -> tuple[str,
|
|
317
|
+
def _resolve_environment(self, task: Task) -> tuple[str, str]:
|
|
291
318
|
"""
|
|
292
319
|
Resolve which environment to use for a task.
|
|
293
320
|
|
|
@@ -301,8 +328,8 @@ class Executor:
|
|
|
301
328
|
task: Task to resolve environment for
|
|
302
329
|
|
|
303
330
|
Returns:
|
|
304
|
-
Tuple of (shell,
|
|
305
|
-
@athena:
|
|
331
|
+
Tuple of (shell, preamble)
|
|
332
|
+
@athena: 15cad76d7c80
|
|
306
333
|
"""
|
|
307
334
|
# Check for global override first
|
|
308
335
|
env_name = self.recipe.global_env_override
|
|
@@ -319,17 +346,18 @@ class Executor:
|
|
|
319
346
|
if env_name:
|
|
320
347
|
env = self.recipe.get_environment(env_name)
|
|
321
348
|
if env:
|
|
322
|
-
return
|
|
349
|
+
return env.shell, env.preamble
|
|
323
350
|
# If env not found, fall through to platform default
|
|
324
351
|
|
|
325
352
|
# Use platform default
|
|
326
|
-
shell,
|
|
327
|
-
return
|
|
353
|
+
shell, _ = self._get_platform_default_environment()
|
|
354
|
+
return shell, ""
|
|
328
355
|
|
|
329
356
|
def check_task_status(
|
|
330
357
|
self,
|
|
331
358
|
task: Task,
|
|
332
359
|
args_dict: dict[str, Any],
|
|
360
|
+
process_runner: ProcessRunner,
|
|
333
361
|
force: bool = False,
|
|
334
362
|
) -> TaskStatus:
|
|
335
363
|
"""
|
|
@@ -348,11 +376,12 @@ class Executor:
|
|
|
348
376
|
Args:
|
|
349
377
|
task: Task to check
|
|
350
378
|
args_dict: Arguments for this task execution
|
|
379
|
+
process_runner: ProcessRunner instance for subprocess execution
|
|
351
380
|
force: If True, ignore freshness and force execution
|
|
352
381
|
|
|
353
382
|
Returns:
|
|
354
383
|
TaskStatus indicating whether task will run and why
|
|
355
|
-
@athena:
|
|
384
|
+
@athena: 03922de1bd23
|
|
356
385
|
"""
|
|
357
386
|
# If force flag is set, always run
|
|
358
387
|
if force:
|
|
@@ -364,7 +393,14 @@ class Executor:
|
|
|
364
393
|
|
|
365
394
|
# Compute hashes (include effective environment and dependencies)
|
|
366
395
|
effective_env = self._get_effective_env_name(task)
|
|
367
|
-
task_hash = hash_task(
|
|
396
|
+
task_hash = hash_task(
|
|
397
|
+
task.cmd,
|
|
398
|
+
task.outputs,
|
|
399
|
+
task.working_dir,
|
|
400
|
+
task.args,
|
|
401
|
+
effective_env,
|
|
402
|
+
task.deps,
|
|
403
|
+
)
|
|
368
404
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
369
405
|
cache_key = make_cache_key(task_hash, args_hash)
|
|
370
406
|
|
|
@@ -386,8 +422,9 @@ class Executor:
|
|
|
386
422
|
reason="never_run",
|
|
387
423
|
)
|
|
388
424
|
|
|
389
|
-
|
|
390
|
-
|
|
425
|
+
env_changed = self._check_environment_changed(
|
|
426
|
+
task, cached_state, effective_env, process_runner
|
|
427
|
+
)
|
|
391
428
|
if env_changed:
|
|
392
429
|
return TaskStatus(
|
|
393
430
|
task_name=task.name,
|
|
@@ -426,9 +463,21 @@ class Executor:
|
|
|
426
463
|
last_run=datetime.fromtimestamp(cached_state.last_run),
|
|
427
464
|
)
|
|
428
465
|
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _get_task_output_type(user_inputted_value: TaskOutputTypes | None, task: Task) -> TaskOutputTypes:
|
|
468
|
+
if user_inputted_value is None:
|
|
469
|
+
if task.task_output is not None:
|
|
470
|
+
return task.task_output
|
|
471
|
+
|
|
472
|
+
return TaskOutputTypes.ALL
|
|
473
|
+
|
|
474
|
+
return user_inputted_value
|
|
475
|
+
|
|
476
|
+
|
|
429
477
|
def execute_task(
|
|
430
478
|
self,
|
|
431
479
|
task_name: str,
|
|
480
|
+
user_inputted_task_output_types: TaskOutputTypes | None,
|
|
432
481
|
args_dict: dict[str, Any] | None = None,
|
|
433
482
|
force: bool = False,
|
|
434
483
|
only: bool = False,
|
|
@@ -438,6 +487,7 @@ class Executor:
|
|
|
438
487
|
|
|
439
488
|
Args:
|
|
440
489
|
task_name: Name of task to execute
|
|
490
|
+
task_output_type: TaskOutputTypes enum value for controlling subprocess output
|
|
441
491
|
args_dict: Arguments to pass to the task
|
|
442
492
|
force: If True, ignore freshness and re-run all tasks
|
|
443
493
|
only: If True, run only the specified task without dependencies (implies force=True)
|
|
@@ -447,7 +497,7 @@ class Executor:
|
|
|
447
497
|
|
|
448
498
|
Raises:
|
|
449
499
|
ExecutionError: If task execution fails
|
|
450
|
-
@athena:
|
|
500
|
+
@athena: 4773fc590d9a
|
|
451
501
|
"""
|
|
452
502
|
if args_dict is None:
|
|
453
503
|
args_dict = {}
|
|
@@ -480,20 +530,31 @@ class Executor:
|
|
|
480
530
|
# Convert None to {} for internal use (None is used to distinguish simple deps in graph)
|
|
481
531
|
args_dict_for_execution = task_args if task_args is not None else {}
|
|
482
532
|
|
|
533
|
+
process_runner = self._process_runner_factory(self._get_task_output_type(user_inputted_task_output_types, task), self.logger)
|
|
534
|
+
|
|
483
535
|
# Check if task needs to run (based on CURRENT filesystem state)
|
|
484
|
-
status = self.check_task_status(
|
|
536
|
+
status = self.check_task_status(
|
|
537
|
+
task, args_dict_for_execution, process_runner, force=force
|
|
538
|
+
)
|
|
485
539
|
|
|
486
540
|
# Use a key that includes args for status tracking
|
|
487
541
|
# Only include regular (non-exported) args in status key for parameterized dependencies
|
|
488
542
|
# For the root task (invoked from CLI), status key is always just the task name
|
|
489
543
|
# For dependencies with parameterized invocations, include the regular args
|
|
490
|
-
is_root_task =
|
|
491
|
-
if
|
|
544
|
+
is_root_task = name == task_name
|
|
545
|
+
if (
|
|
546
|
+
not is_root_task
|
|
547
|
+
and args_dict_for_execution
|
|
548
|
+
and self._has_regular_args(task)
|
|
549
|
+
):
|
|
492
550
|
import json
|
|
551
|
+
|
|
493
552
|
# Filter to only include regular (non-exported) args
|
|
494
553
|
regular_args = self._filter_regular_args(task, args_dict_for_execution)
|
|
495
554
|
if regular_args:
|
|
496
|
-
args_str = json.dumps(
|
|
555
|
+
args_str = json.dumps(
|
|
556
|
+
regular_args, sort_keys=True, separators=(",", ":")
|
|
557
|
+
)
|
|
497
558
|
status_key = f"{name}({args_str})"
|
|
498
559
|
else:
|
|
499
560
|
status_key = name
|
|
@@ -505,27 +566,29 @@ class Executor:
|
|
|
505
566
|
if status.will_run:
|
|
506
567
|
# Warn if re-running due to missing outputs
|
|
507
568
|
if status.reason == "outputs_missing":
|
|
508
|
-
|
|
509
|
-
|
|
569
|
+
self.logger.log(
|
|
570
|
+
LogLevel.WARN,
|
|
510
571
|
f"Warning: Re-running task '{name}' because declared outputs are missing",
|
|
511
|
-
file=sys.stderr,
|
|
512
572
|
)
|
|
513
573
|
|
|
514
|
-
self._run_task(task, args_dict_for_execution)
|
|
574
|
+
self._run_task(task, args_dict_for_execution, process_runner)
|
|
515
575
|
|
|
516
576
|
return statuses
|
|
517
577
|
|
|
518
|
-
def _run_task(
|
|
578
|
+
def _run_task(
|
|
579
|
+
self, task: Task, args_dict: dict[str, Any], process_runner: ProcessRunner
|
|
580
|
+
) -> None:
|
|
519
581
|
"""
|
|
520
582
|
Execute a single task.
|
|
521
583
|
|
|
522
584
|
Args:
|
|
523
585
|
task: Task to execute
|
|
524
586
|
args_dict: Arguments to substitute in command
|
|
587
|
+
process_runner: ProcessRunner instance for subprocess execution
|
|
525
588
|
|
|
526
589
|
Raises:
|
|
527
590
|
ExecutionError: If task execution fails
|
|
528
|
-
@athena:
|
|
591
|
+
@athena: b5abffeef10a
|
|
529
592
|
"""
|
|
530
593
|
# Capture timestamp at task start for consistency (in UTC)
|
|
531
594
|
task_start_time = datetime.now(timezone.utc)
|
|
@@ -533,6 +596,7 @@ class Executor:
|
|
|
533
596
|
# Parse task arguments to identify exported args
|
|
534
597
|
# Note: args_dict already has defaults applied by CLI (cli.py:413-424)
|
|
535
598
|
from tasktree.parser import parse_arg_spec
|
|
599
|
+
|
|
536
600
|
exported_args = set()
|
|
537
601
|
regular_args = {}
|
|
538
602
|
exported_env_vars = {}
|
|
@@ -551,18 +615,24 @@ class Executor:
|
|
|
551
615
|
|
|
552
616
|
# Collect early built-in variables (those that don't depend on working_dir)
|
|
553
617
|
# These can be used in the working_dir field itself
|
|
554
|
-
early_builtin_vars = self._collect_early_builtin_variables(
|
|
618
|
+
early_builtin_vars = self._collect_early_builtin_variables(
|
|
619
|
+
task, task_start_time
|
|
620
|
+
)
|
|
555
621
|
|
|
556
622
|
# Resolve working directory
|
|
557
623
|
# Validate that working_dir doesn't contain {{ tt.working_dir }} (circular dependency)
|
|
558
624
|
self._validate_no_working_dir_circular_ref(task.working_dir)
|
|
559
625
|
working_dir_str = self._substitute_builtin(task.working_dir, early_builtin_vars)
|
|
560
|
-
working_dir_str = self._substitute_args(
|
|
626
|
+
working_dir_str = self._substitute_args(
|
|
627
|
+
working_dir_str, regular_args, exported_args
|
|
628
|
+
)
|
|
561
629
|
working_dir_str = self._substitute_env(working_dir_str)
|
|
562
630
|
working_dir = self.recipe.project_root / working_dir_str
|
|
563
631
|
|
|
564
632
|
# Collect all built-in variables (including tt.working_dir now that it's resolved)
|
|
565
|
-
builtin_vars = self._collect_builtin_variables(
|
|
633
|
+
builtin_vars = self._collect_builtin_variables(
|
|
634
|
+
task, working_dir, task_start_time
|
|
635
|
+
)
|
|
566
636
|
|
|
567
637
|
# Substitute built-in variables, arguments, and environment variables in command
|
|
568
638
|
cmd = self._substitute_builtin(task.cmd, builtin_vars)
|
|
@@ -576,80 +646,65 @@ class Executor:
|
|
|
576
646
|
env = self.recipe.get_environment(env_name)
|
|
577
647
|
|
|
578
648
|
# Execute command
|
|
579
|
-
|
|
649
|
+
self.logger.log(LogLevel.INFO, f"Running: {task.name}")
|
|
580
650
|
|
|
581
651
|
# Route to Docker execution or regular execution
|
|
582
652
|
if env and env.dockerfile:
|
|
583
653
|
# Docker execution path
|
|
584
|
-
self._run_task_in_docker(
|
|
654
|
+
self._run_task_in_docker(
|
|
655
|
+
task,
|
|
656
|
+
env,
|
|
657
|
+
cmd,
|
|
658
|
+
working_dir,
|
|
659
|
+
process_runner,
|
|
660
|
+
exported_env_vars,
|
|
661
|
+
)
|
|
585
662
|
else:
|
|
586
|
-
# Regular execution path
|
|
587
|
-
shell,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
663
|
+
# Regular execution path - use unified script-based execution
|
|
664
|
+
shell, preamble = self._resolve_environment(task)
|
|
665
|
+
self._run_command_as_script(
|
|
666
|
+
cmd,
|
|
667
|
+
working_dir,
|
|
668
|
+
task.name,
|
|
669
|
+
shell,
|
|
670
|
+
preamble,
|
|
671
|
+
process_runner,
|
|
672
|
+
exported_env_vars,
|
|
673
|
+
)
|
|
594
674
|
|
|
595
675
|
# Update state
|
|
596
676
|
self._update_state(task, args_dict)
|
|
597
677
|
|
|
598
|
-
def
|
|
599
|
-
self,
|
|
600
|
-
|
|
678
|
+
def _run_command_as_script(
|
|
679
|
+
self,
|
|
680
|
+
cmd: str,
|
|
681
|
+
working_dir: Path,
|
|
682
|
+
task_name: str,
|
|
683
|
+
shell: str,
|
|
684
|
+
preamble: str,
|
|
685
|
+
process_runner: ProcessRunner,
|
|
686
|
+
exported_env_vars: dict[str, str] | None = None,
|
|
601
687
|
) -> None:
|
|
602
688
|
"""
|
|
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)
|
|
619
|
-
|
|
620
|
-
try:
|
|
621
|
-
# Build command: shell + args + cmd
|
|
622
|
-
full_cmd = [shell] + shell_args + [cmd]
|
|
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
|
-
)
|
|
689
|
+
Execute a command via temporary script file (unified execution path).
|
|
634
690
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
) -> None:
|
|
639
|
-
"""
|
|
640
|
-
Execute a multi-line command via temporary script file.
|
|
691
|
+
This method handles both single-line and multi-line commands by writing
|
|
692
|
+
them to a temporary script file and executing the script. This provides
|
|
693
|
+
consistent behavior and allows preamble to work with all commands.
|
|
641
694
|
|
|
642
695
|
Args:
|
|
643
|
-
cmd:
|
|
696
|
+
cmd: Command string (single-line or multi-line)
|
|
644
697
|
working_dir: Working directory
|
|
645
698
|
task_name: Task name (for error messages)
|
|
646
699
|
shell: Shell to use for script execution
|
|
647
700
|
preamble: Preamble text to prepend to script
|
|
701
|
+
process_runner: ProcessRunner instance to use for subprocess execution
|
|
648
702
|
exported_env_vars: Exported arguments to set as environment variables
|
|
649
703
|
|
|
650
704
|
Raises:
|
|
651
705
|
ExecutionError: If command execution fails
|
|
652
|
-
@athena:
|
|
706
|
+
@athena: TBD
|
|
707
|
+
@athena: 228cc00e7665
|
|
653
708
|
"""
|
|
654
709
|
# Prepare environment with exported args
|
|
655
710
|
env = self._prepare_env_with_exports(exported_env_vars)
|
|
@@ -689,13 +744,56 @@ class Executor:
|
|
|
689
744
|
|
|
690
745
|
# Execute script file
|
|
691
746
|
try:
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
747
|
+
# Check if stdout/stderr support fileno() (real file descriptors)
|
|
748
|
+
# CliRunner uses StringIO which has fileno() method but raises when called
|
|
749
|
+
def supports_fileno(stream):
|
|
750
|
+
"""Check if a stream has a working fileno() method."""
|
|
751
|
+
try:
|
|
752
|
+
stream.fileno()
|
|
753
|
+
return True
|
|
754
|
+
except (AttributeError, OSError, io.UnsupportedOperation):
|
|
755
|
+
return False
|
|
756
|
+
|
|
757
|
+
# Determine output targets based on task_output mode
|
|
758
|
+
# For "all" mode: show everything
|
|
759
|
+
# Future modes: use subprocess.DEVNULL for suppression
|
|
760
|
+
should_suppress = False # Will be: self.task_output == "none", etc.
|
|
761
|
+
|
|
762
|
+
if should_suppress:
|
|
763
|
+
stdout_target = subprocess.DEVNULL
|
|
764
|
+
stderr_target = subprocess.DEVNULL
|
|
765
|
+
else:
|
|
766
|
+
stdout_target = sys.stdout
|
|
767
|
+
stderr_target = sys.stderr
|
|
768
|
+
|
|
769
|
+
# If streams support fileno, pass target streams directly (most efficient)
|
|
770
|
+
# Otherwise capture and manually write (CliRunner compatibility)
|
|
771
|
+
if not should_suppress and not (
|
|
772
|
+
supports_fileno(sys.stdout) and supports_fileno(sys.stderr)
|
|
773
|
+
):
|
|
774
|
+
# CliRunner path: capture and write manually
|
|
775
|
+
result = process_runner.run(
|
|
776
|
+
[script_path],
|
|
777
|
+
cwd=working_dir,
|
|
778
|
+
check=True,
|
|
779
|
+
capture_output=True,
|
|
780
|
+
text=True,
|
|
781
|
+
env=env,
|
|
782
|
+
)
|
|
783
|
+
if result.stdout:
|
|
784
|
+
sys.stdout.write(result.stdout)
|
|
785
|
+
if result.stderr:
|
|
786
|
+
sys.stderr.write(result.stderr)
|
|
787
|
+
else:
|
|
788
|
+
# Normal execution path: use target streams (including DEVNULL when suppressing)
|
|
789
|
+
process_runner.run(
|
|
790
|
+
[script_path],
|
|
791
|
+
cwd=working_dir,
|
|
792
|
+
check=True,
|
|
793
|
+
stdout=stdout_target,
|
|
794
|
+
stderr=stderr_target,
|
|
795
|
+
env=env,
|
|
796
|
+
)
|
|
699
797
|
except subprocess.CalledProcessError as e:
|
|
700
798
|
raise ExecutionError(
|
|
701
799
|
f"Task '{task_name}' failed with exit code {e.returncode}"
|
|
@@ -707,7 +805,9 @@ class Executor:
|
|
|
707
805
|
except OSError:
|
|
708
806
|
pass # Ignore cleanup errors
|
|
709
807
|
|
|
710
|
-
def _substitute_builtin_in_environment(
|
|
808
|
+
def _substitute_builtin_in_environment(
|
|
809
|
+
self, env: Environment, builtin_vars: dict[str, str]
|
|
810
|
+
) -> Environment:
|
|
711
811
|
"""
|
|
712
812
|
Substitute builtin and environment variables in environment fields.
|
|
713
813
|
|
|
@@ -720,34 +820,56 @@ class Executor:
|
|
|
720
820
|
|
|
721
821
|
Raises:
|
|
722
822
|
ValueError: If builtin variable or environment variable is not defined
|
|
723
|
-
@athena:
|
|
823
|
+
@athena: eba6e3d62062
|
|
724
824
|
"""
|
|
725
825
|
from dataclasses import replace
|
|
726
826
|
|
|
727
827
|
# Substitute in volumes (builtin vars first, then env vars)
|
|
728
|
-
substituted_volumes =
|
|
729
|
-
|
|
730
|
-
|
|
828
|
+
substituted_volumes = (
|
|
829
|
+
[
|
|
830
|
+
self._substitute_env(self._substitute_builtin(vol, builtin_vars))
|
|
831
|
+
for vol in env.volumes
|
|
832
|
+
]
|
|
833
|
+
if env.volumes
|
|
834
|
+
else []
|
|
835
|
+
)
|
|
731
836
|
|
|
732
837
|
# Substitute in env_vars values (builtin vars first, then env vars)
|
|
733
|
-
substituted_env_vars =
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
838
|
+
substituted_env_vars = (
|
|
839
|
+
{
|
|
840
|
+
key: self._substitute_env(self._substitute_builtin(value, builtin_vars))
|
|
841
|
+
for key, value in env.env_vars.items()
|
|
842
|
+
}
|
|
843
|
+
if env.env_vars
|
|
844
|
+
else {}
|
|
845
|
+
)
|
|
737
846
|
|
|
738
847
|
# Substitute in ports (builtin vars first, then env vars)
|
|
739
|
-
substituted_ports =
|
|
740
|
-
|
|
741
|
-
|
|
848
|
+
substituted_ports = (
|
|
849
|
+
[
|
|
850
|
+
self._substitute_env(self._substitute_builtin(port, builtin_vars))
|
|
851
|
+
for port in env.ports
|
|
852
|
+
]
|
|
853
|
+
if env.ports
|
|
854
|
+
else []
|
|
855
|
+
)
|
|
742
856
|
|
|
743
857
|
# Substitute in working_dir (builtin vars first, then env vars)
|
|
744
|
-
substituted_working_dir =
|
|
858
|
+
substituted_working_dir = (
|
|
859
|
+
self._substitute_env(
|
|
860
|
+
self._substitute_builtin(env.working_dir, builtin_vars)
|
|
861
|
+
)
|
|
862
|
+
if env.working_dir
|
|
863
|
+
else ""
|
|
864
|
+
)
|
|
745
865
|
|
|
746
866
|
# Substitute in build args (for Docker environments, args is a dict)
|
|
747
867
|
# Apply builtin vars first, then env vars
|
|
748
868
|
if isinstance(env.args, dict):
|
|
749
869
|
substituted_args = {
|
|
750
|
-
key: self._substitute_env(
|
|
870
|
+
key: self._substitute_env(
|
|
871
|
+
self._substitute_builtin(str(value), builtin_vars)
|
|
872
|
+
)
|
|
751
873
|
for key, value in env.args.items()
|
|
752
874
|
}
|
|
753
875
|
else:
|
|
@@ -760,12 +882,17 @@ class Executor:
|
|
|
760
882
|
env_vars=substituted_env_vars,
|
|
761
883
|
ports=substituted_ports,
|
|
762
884
|
working_dir=substituted_working_dir,
|
|
763
|
-
args=substituted_args
|
|
885
|
+
args=substituted_args,
|
|
764
886
|
)
|
|
765
887
|
|
|
766
888
|
def _run_task_in_docker(
|
|
767
|
-
self,
|
|
768
|
-
|
|
889
|
+
self,
|
|
890
|
+
task: Task,
|
|
891
|
+
env: Any,
|
|
892
|
+
cmd: str,
|
|
893
|
+
working_dir: Path,
|
|
894
|
+
process_runner: ProcessRunner,
|
|
895
|
+
exported_env_vars: dict[str, str] | None = None,
|
|
769
896
|
) -> None:
|
|
770
897
|
"""
|
|
771
898
|
Execute task inside Docker container.
|
|
@@ -775,15 +902,19 @@ class Executor:
|
|
|
775
902
|
env: Docker environment configuration
|
|
776
903
|
cmd: Command to execute
|
|
777
904
|
working_dir: Host working directory
|
|
905
|
+
process_runner: ProcessRunner instance to use for subprocess execution
|
|
778
906
|
exported_env_vars: Exported arguments to set as environment variables
|
|
907
|
+
task_output: Control task subprocess output (all, out, err, on-err, none)
|
|
779
908
|
|
|
780
909
|
Raises:
|
|
781
910
|
ExecutionError: If Docker execution fails
|
|
782
|
-
@athena:
|
|
911
|
+
@athena: 61725a57e304
|
|
783
912
|
"""
|
|
784
913
|
# Get builtin variables for substitution in environment fields
|
|
785
914
|
task_start_time = datetime.now(timezone.utc)
|
|
786
|
-
builtin_vars = self._collect_builtin_variables(
|
|
915
|
+
builtin_vars = self._collect_builtin_variables(
|
|
916
|
+
task, working_dir, task_start_time
|
|
917
|
+
)
|
|
787
918
|
|
|
788
919
|
# Substitute builtin variables in environment fields (volumes, env_vars, etc.)
|
|
789
920
|
env = self._substitute_builtin_in_environment(env, builtin_vars)
|
|
@@ -807,6 +938,7 @@ class Executor:
|
|
|
807
938
|
|
|
808
939
|
# Create modified environment with merged env vars using dataclass replace
|
|
809
940
|
from dataclasses import replace
|
|
941
|
+
|
|
810
942
|
modified_env = replace(env, env_vars=docker_env_vars)
|
|
811
943
|
|
|
812
944
|
# Execute in container
|
|
@@ -816,11 +948,13 @@ class Executor:
|
|
|
816
948
|
cmd=cmd,
|
|
817
949
|
working_dir=working_dir,
|
|
818
950
|
container_working_dir=container_working_dir,
|
|
951
|
+
process_runner=process_runner,
|
|
819
952
|
)
|
|
820
953
|
except docker_module.DockerError as e:
|
|
821
954
|
raise ExecutionError(str(e)) from e
|
|
822
955
|
|
|
823
|
-
|
|
956
|
+
@staticmethod
|
|
957
|
+
def _validate_no_working_dir_circular_ref(text: str) -> None:
|
|
824
958
|
"""
|
|
825
959
|
Validate that working_dir field does not contain {{ tt.working_dir }}.
|
|
826
960
|
|
|
@@ -831,20 +965,22 @@ class Executor:
|
|
|
831
965
|
|
|
832
966
|
Raises:
|
|
833
967
|
ExecutionError: If {{ tt.working_dir }} placeholder is found
|
|
834
|
-
@athena:
|
|
968
|
+
@athena: 82822f02716a
|
|
835
969
|
"""
|
|
836
970
|
import re
|
|
971
|
+
|
|
837
972
|
# Pattern to match {{ tt.working_dir }} specifically
|
|
838
|
-
pattern = re.compile(r
|
|
973
|
+
pattern = re.compile(r"\{\{\s*tt\s*\.\s*working_dir\s*}}")
|
|
839
974
|
|
|
840
975
|
if pattern.search(text):
|
|
841
976
|
raise ExecutionError(
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
977
|
+
"Cannot use {{ tt.working_dir }} in the 'working_dir' field.\n\n"
|
|
978
|
+
"This creates a circular dependency (working_dir cannot reference itself).\n"
|
|
979
|
+
"Other built-in variables like {{ tt.task_name }} or {{ tt.timestamp }} are allowed."
|
|
845
980
|
)
|
|
846
981
|
|
|
847
|
-
|
|
982
|
+
@staticmethod
|
|
983
|
+
def _substitute_builtin(text: str, builtin_vars: dict[str, str]) -> str:
|
|
848
984
|
"""
|
|
849
985
|
Substitute {{ tt.name }} placeholders in text.
|
|
850
986
|
|
|
@@ -859,12 +995,16 @@ class Executor:
|
|
|
859
995
|
|
|
860
996
|
Raises:
|
|
861
997
|
ValueError: If built-in variable is not defined
|
|
862
|
-
@athena:
|
|
998
|
+
@athena: fe47afe87b52
|
|
863
999
|
"""
|
|
864
1000
|
from tasktree.substitution import substitute_builtin_variables
|
|
1001
|
+
|
|
865
1002
|
return substitute_builtin_variables(text, builtin_vars)
|
|
866
1003
|
|
|
867
|
-
|
|
1004
|
+
@staticmethod
|
|
1005
|
+
def _substitute_args(
|
|
1006
|
+
cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None
|
|
1007
|
+
) -> str:
|
|
868
1008
|
"""
|
|
869
1009
|
Substitute {{ arg.name }} placeholders in command string.
|
|
870
1010
|
|
|
@@ -881,12 +1021,14 @@ class Executor:
|
|
|
881
1021
|
|
|
882
1022
|
Raises:
|
|
883
1023
|
ValueError: If an exported argument is used in template substitution
|
|
884
|
-
@athena:
|
|
1024
|
+
@athena: 9a931179f270
|
|
885
1025
|
"""
|
|
886
1026
|
from tasktree.substitution import substitute_arguments
|
|
1027
|
+
|
|
887
1028
|
return substitute_arguments(cmd, args_dict, exported_args)
|
|
888
1029
|
|
|
889
|
-
|
|
1030
|
+
@staticmethod
|
|
1031
|
+
def _substitute_env(text: str) -> str:
|
|
890
1032
|
"""
|
|
891
1033
|
Substitute {{ env.NAME }} placeholders in text.
|
|
892
1034
|
|
|
@@ -900,9 +1042,10 @@ class Executor:
|
|
|
900
1042
|
|
|
901
1043
|
Raises:
|
|
902
1044
|
ValueError: If environment variable is not set
|
|
903
|
-
@athena:
|
|
1045
|
+
@athena: 1bbe24759451
|
|
904
1046
|
"""
|
|
905
1047
|
from tasktree.substitution import substitute_environment
|
|
1048
|
+
|
|
906
1049
|
return substitute_environment(text)
|
|
907
1050
|
|
|
908
1051
|
def _get_all_inputs(self, task: Task) -> list[str]:
|
|
@@ -929,8 +1072,13 @@ class Executor:
|
|
|
929
1072
|
all_inputs.extend(implicit_inputs)
|
|
930
1073
|
return all_inputs
|
|
931
1074
|
|
|
1075
|
+
# TODO: Understand why task isn't used
|
|
932
1076
|
def _check_environment_changed(
|
|
933
|
-
self,
|
|
1077
|
+
self,
|
|
1078
|
+
task: Task,
|
|
1079
|
+
cached_state: TaskState,
|
|
1080
|
+
env_name: str,
|
|
1081
|
+
process_runner: ProcessRunner,
|
|
934
1082
|
) -> bool:
|
|
935
1083
|
"""
|
|
936
1084
|
Check if environment definition has changed since last run.
|
|
@@ -942,10 +1090,11 @@ class Executor:
|
|
|
942
1090
|
task: Task to check
|
|
943
1091
|
cached_state: Cached state from previous run
|
|
944
1092
|
env_name: Effective environment name (from _get_effective_env_name)
|
|
1093
|
+
process_runner: ProcessRunner instance for subprocess execution
|
|
945
1094
|
|
|
946
1095
|
Returns:
|
|
947
1096
|
True if environment definition changed, False otherwise
|
|
948
|
-
@athena:
|
|
1097
|
+
@athena: e206e104150a
|
|
949
1098
|
"""
|
|
950
1099
|
# If using platform default (no environment), no definition to track
|
|
951
1100
|
if not env_name:
|
|
@@ -976,13 +1125,19 @@ class Executor:
|
|
|
976
1125
|
|
|
977
1126
|
# For Docker environments, also check if image ID changed
|
|
978
1127
|
if env.dockerfile:
|
|
979
|
-
return self._check_docker_image_changed(
|
|
1128
|
+
return self._check_docker_image_changed(
|
|
1129
|
+
env, cached_state, env_name, process_runner
|
|
1130
|
+
)
|
|
980
1131
|
|
|
981
1132
|
# Shell environment with unchanged hash
|
|
982
1133
|
return False
|
|
983
1134
|
|
|
984
1135
|
def _check_docker_image_changed(
|
|
985
|
-
self,
|
|
1136
|
+
self,
|
|
1137
|
+
env: Environment,
|
|
1138
|
+
cached_state: TaskState,
|
|
1139
|
+
env_name: str,
|
|
1140
|
+
process_runner: ProcessRunner,
|
|
986
1141
|
) -> bool:
|
|
987
1142
|
"""
|
|
988
1143
|
Check if Docker image ID has changed.
|
|
@@ -994,15 +1149,18 @@ class Executor:
|
|
|
994
1149
|
env: Docker environment definition
|
|
995
1150
|
cached_state: Cached state from previous run
|
|
996
1151
|
env_name: Environment name
|
|
1152
|
+
process_runner: ProcessRunner instance for subprocess execution
|
|
997
1153
|
|
|
998
1154
|
Returns:
|
|
999
1155
|
True if image ID changed, False otherwise
|
|
1000
|
-
@athena:
|
|
1156
|
+
@athena: bc954288e4ad
|
|
1001
1157
|
"""
|
|
1002
1158
|
# Build/ensure image is built and get its ID
|
|
1003
1159
|
try:
|
|
1004
|
-
image_tag, current_image_id = self.docker_manager.ensure_image_built(
|
|
1005
|
-
|
|
1160
|
+
image_tag, current_image_id = self.docker_manager.ensure_image_built(
|
|
1161
|
+
env, process_runner
|
|
1162
|
+
)
|
|
1163
|
+
except Exception:
|
|
1006
1164
|
# If we can't build, treat as changed (will fail later with better error)
|
|
1007
1165
|
return True
|
|
1008
1166
|
|
|
@@ -1095,7 +1253,9 @@ class Executor:
|
|
|
1095
1253
|
|
|
1096
1254
|
# Check if digests changed
|
|
1097
1255
|
if current_digests != cached_digests:
|
|
1098
|
-
changed_files.append(
|
|
1256
|
+
changed_files.append(
|
|
1257
|
+
f"Docker base image digests in {dockerfile_name}"
|
|
1258
|
+
)
|
|
1099
1259
|
except (OSError, IOError):
|
|
1100
1260
|
# Can't read Dockerfile - consider changed
|
|
1101
1261
|
changed_files.append(f"Dockerfile: {dockerfile_name}")
|
|
@@ -1115,7 +1275,8 @@ class Executor:
|
|
|
1115
1275
|
|
|
1116
1276
|
return changed_files
|
|
1117
1277
|
|
|
1118
|
-
|
|
1278
|
+
@staticmethod
|
|
1279
|
+
def _expand_output_paths(task: Task) -> list[str]:
|
|
1119
1280
|
"""
|
|
1120
1281
|
Extract all output paths from task outputs (both named and anonymous).
|
|
1121
1282
|
|
|
@@ -1124,7 +1285,7 @@ class Executor:
|
|
|
1124
1285
|
|
|
1125
1286
|
Returns:
|
|
1126
1287
|
List of output path patterns (glob patterns as strings)
|
|
1127
|
-
@athena:
|
|
1288
|
+
@athena: 21da23ad5dcf
|
|
1128
1289
|
"""
|
|
1129
1290
|
paths = []
|
|
1130
1291
|
for output in task.outputs:
|
|
@@ -1193,25 +1354,48 @@ class Executor:
|
|
|
1193
1354
|
def _update_state(self, task: Task, args_dict: dict[str, Any]) -> None:
|
|
1194
1355
|
"""
|
|
1195
1356
|
Update state after task execution.
|
|
1357
|
+
@athena: f4d3efdaac7c
|
|
1358
|
+
"""
|
|
1359
|
+
cache_key = self._cache_key(task, args_dict)
|
|
1360
|
+
input_state = self._input_files_to_modified_times(task)
|
|
1196
1361
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1362
|
+
env_name = self._get_effective_env_name(task)
|
|
1363
|
+
if env_name:
|
|
1364
|
+
env = self.recipe.get_environment(env_name)
|
|
1365
|
+
if env:
|
|
1366
|
+
input_state[f"_env_hash_{env_name}"] = hash_environment_definition(env)
|
|
1367
|
+
if env.dockerfile:
|
|
1368
|
+
input_state |= self._docker_inputs_to_modified_times(env_name, env)
|
|
1369
|
+
|
|
1370
|
+
new_state = TaskState(last_run=time.time(), input_state=input_state)
|
|
1371
|
+
self.state.set(cache_key, new_state)
|
|
1372
|
+
self.state.save()
|
|
1373
|
+
|
|
1374
|
+
def _cache_key(self, task: Task, args_dict: dict[str, Any]) -> str:
|
|
1375
|
+
"""
|
|
1376
|
+
@athena: d20ce4090741
|
|
1201
1377
|
"""
|
|
1202
|
-
# Compute hashes (include effective environment and dependencies)
|
|
1203
1378
|
effective_env = self._get_effective_env_name(task)
|
|
1204
|
-
task_hash = hash_task(
|
|
1379
|
+
task_hash = hash_task(
|
|
1380
|
+
task.cmd,
|
|
1381
|
+
task.outputs,
|
|
1382
|
+
task.working_dir,
|
|
1383
|
+
task.args,
|
|
1384
|
+
effective_env,
|
|
1385
|
+
task.deps,
|
|
1386
|
+
)
|
|
1205
1387
|
args_hash = hash_args(args_dict) if args_dict else None
|
|
1206
|
-
|
|
1388
|
+
return make_cache_key(task_hash, args_hash)
|
|
1207
1389
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1390
|
+
def _input_files_to_modified_times(self, task: Task) -> dict[str, float]:
|
|
1391
|
+
"""
|
|
1392
|
+
@athena: 7e5ba779a41f
|
|
1393
|
+
"""
|
|
1394
|
+
input_files = self._expand_globs(self._get_all_inputs(task), task.working_dir)
|
|
1211
1395
|
|
|
1212
1396
|
input_state = {}
|
|
1213
1397
|
for file_path in input_files:
|
|
1214
|
-
# Skip Docker special markers (handled separately
|
|
1398
|
+
# Skip Docker special markers (handled separately)
|
|
1215
1399
|
if file_path.startswith("_docker_"):
|
|
1216
1400
|
continue
|
|
1217
1401
|
|
|
@@ -1219,59 +1403,47 @@ class Executor:
|
|
|
1219
1403
|
if file_path_obj.exists():
|
|
1220
1404
|
input_state[file_path] = file_path_obj.stat().st_mtime
|
|
1221
1405
|
|
|
1222
|
-
|
|
1223
|
-
env_name = self._get_effective_env_name(task)
|
|
1224
|
-
if env_name:
|
|
1225
|
-
env = self.recipe.get_environment(env_name)
|
|
1226
|
-
if env and env.dockerfile:
|
|
1227
|
-
# Record Dockerfile mtime
|
|
1228
|
-
dockerfile_path = self.recipe.project_root / env.dockerfile
|
|
1229
|
-
if dockerfile_path.exists():
|
|
1230
|
-
input_state[env.dockerfile] = dockerfile_path.stat().st_mtime
|
|
1231
|
-
|
|
1232
|
-
# Record .dockerignore mtime if exists
|
|
1233
|
-
context_path = self.recipe.project_root / env.context
|
|
1234
|
-
dockerignore_path = context_path / ".dockerignore"
|
|
1235
|
-
if dockerignore_path.exists():
|
|
1236
|
-
relative_dockerignore = str(
|
|
1237
|
-
dockerignore_path.relative_to(self.recipe.project_root)
|
|
1238
|
-
)
|
|
1239
|
-
input_state[relative_dockerignore] = dockerignore_path.stat().st_mtime
|
|
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
|
|
1406
|
+
return input_state
|
|
1258
1407
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1408
|
+
def _docker_inputs_to_modified_times(
|
|
1409
|
+
self, env_name: str, env: Environment
|
|
1410
|
+
) -> dict[str, float]:
|
|
1411
|
+
"""
|
|
1412
|
+
@athena: bfe53b0d56cd
|
|
1413
|
+
"""
|
|
1414
|
+
input_state = dict()
|
|
1415
|
+
# Record Dockerfile mtime
|
|
1416
|
+
dockerfile_path = self.recipe.project_root / env.dockerfile
|
|
1417
|
+
if dockerfile_path.exists():
|
|
1418
|
+
input_state[env.dockerfile] = dockerfile_path.stat().st_mtime
|
|
1419
|
+
|
|
1420
|
+
# Record .dockerignore mtime if exists
|
|
1421
|
+
context_path = self.recipe.project_root / env.context
|
|
1422
|
+
dockerignore_path = context_path / ".dockerignore"
|
|
1423
|
+
if dockerignore_path.exists():
|
|
1424
|
+
relative_dockerignore = str(
|
|
1425
|
+
dockerignore_path.relative_to(self.recipe.project_root)
|
|
1426
|
+
)
|
|
1427
|
+
input_state[relative_dockerignore] = dockerignore_path.stat().st_mtime
|
|
1261
1428
|
|
|
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
|
-
)
|
|
1429
|
+
# Record context check timestamp
|
|
1430
|
+
input_state[f"_context_{env.context}"] = time.time()
|
|
1274
1431
|
|
|
1275
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1432
|
+
# Parse and record base image digests from Dockerfile
|
|
1433
|
+
try:
|
|
1434
|
+
dockerfile_content = dockerfile_path.read_text()
|
|
1435
|
+
digests = docker_module.parse_base_image_digests(dockerfile_content)
|
|
1436
|
+
for digest in digests:
|
|
1437
|
+
# Store digest with Dockerfile's mtime
|
|
1438
|
+
input_state[f"_digest_{digest}"] = dockerfile_path.stat().st_mtime
|
|
1439
|
+
except (OSError, IOError):
|
|
1440
|
+
# If we can't read Dockerfile, skip digest tracking
|
|
1441
|
+
pass
|
|
1442
|
+
|
|
1443
|
+
# For Docker environments, also store the image ID
|
|
1444
|
+
# Image was already built during check phase or task execution
|
|
1445
|
+
if env_name in self.docker_manager._built_images:
|
|
1446
|
+
image_tag, image_id = self.docker_manager._built_images[env_name]
|
|
1447
|
+
input_state[f"_docker_image_id_{env_name}"] = image_id
|
|
1448
|
+
|
|
1449
|
+
return input_state
|