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/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 get_implicit_inputs, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
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
- """Status of a task for execution planning."""
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
- """Raised when task execution fails."""
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
- """Executes tasks with incremental execution logic."""
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
- 'PATH',
47
- 'LD_LIBRARY_PATH',
48
- 'LD_PRELOAD',
49
- 'PYTHONPATH',
50
- 'HOME',
51
- 'SHELL',
52
- 'USER',
53
- 'LOGNAME',
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
- """Initialize executor.
72
+ """
73
+ Initialize executor.
58
74
 
59
75
  Args:
60
- recipe: Parsed recipe containing all tasks
61
- state_manager: State manager for tracking task execution
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
- def _has_regular_args(self, task: Task) -> bool:
68
- """Check if a task has any regular (non-exported) arguments.
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
- task: Task to check
90
+ task: Task to check
72
91
 
73
92
  Returns:
74
- True if task has at least one regular (non-exported) argument, False otherwise
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('=')[0].split(':')[0].strip()
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
- def _filter_regular_args(self, task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
96
- """Filter task_args to only include regular (non-exported) arguments.
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
- task: Task definition
100
- task_args: Dictionary of all task arguments
121
+ task: Task definition
122
+ task_args: Dictionary of all task arguments
101
123
 
102
124
  Returns:
103
- Dictionary containing only regular (non-exported) arguments
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('=')[0].split(':')[0].strip()
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(self, task: Task, timestamp: datetime) -> dict[str, str]:
124
- """Collect built-in variables that don't depend on working_dir.
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
- task: Task being executed
130
- timestamp: Timestamp when task started execution
155
+ task: Task being executed
156
+ timestamp: Timestamp when task started execution
131
157
 
132
158
  Returns:
133
- Dictionary mapping built-in variable names to their string values
159
+ Dictionary mapping built-in variable names to their string values
134
160
 
135
161
  Raises:
136
- ExecutionError: If any built-in variable fails to resolve
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
- # {{ tt.project_root }} - Absolute path to project root
143
- builtin_vars['project_root'] = str(self.recipe.project_root.resolve())
144
-
145
- # {{ tt.recipe_dir }} - Absolute path to directory containing the recipe file
146
- builtin_vars['recipe_dir'] = str(self.recipe.recipe_path.parent.resolve())
147
-
148
- # {{ tt.task_name }} - Name of currently executing task
149
- builtin_vars['task_name'] = task.name
150
-
151
- # {{ tt.timestamp }} - ISO8601 timestamp when task started execution
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['user_home'] = str(user_home)
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 = os.environ.get('USER') or os.environ.get('USERNAME') or 'unknown'
172
- builtin_vars['user_name'] = user_name
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(self, task: Task, working_dir: Path, timestamp: datetime) -> dict[str, str]:
177
- """Collect built-in variables for task execution.
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
- task: Task being executed
181
- working_dir: Resolved working directory for the task
182
- timestamp: Timestamp when task started execution
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
- Dictionary mapping built-in variable names to their string values
213
+ Dictionary mapping built-in variable names to their string values
186
214
 
187
215
  Raises:
188
- ExecutionError: If any built-in variable fails to resolve
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['working_dir'] = str(working_dir.resolve())
224
+ builtin_vars["working_dir"] = str(working_dir.resolve())
196
225
 
197
226
  return builtin_vars
198
227
 
199
- def _prepare_env_with_exports(self, exported_env_vars: dict[str, str] | None = None) -> dict[str, str]:
200
- """Prepare environment with exported arguments.
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
- exported_env_vars: Exported arguments to set as environment variables
235
+ exported_env_vars: Exported arguments to set as environment variables
204
236
 
205
237
  Returns:
206
- Environment dict with exported args merged
238
+ Environment dict with exported args merged
207
239
 
208
240
  Raises:
209
- ValueError: If an exported arg attempts to override a protected environment variable
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
- def _get_platform_default_environment(self) -> tuple[str, list[str]]:
224
- """Get default shell and args for current platform.
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
- Tuple of (shell, args) for platform default
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 ("cmd", ["/c"])
267
+ return "cmd", ["/c"]
232
268
  else:
233
- return ("bash", ["-c"])
269
+ return "bash", ["-c"]
234
270
 
235
271
  def _get_effective_env_name(self, task: Task) -> str:
236
- """Get the effective environment name for a task.
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
- task: Task to get environment name for
282
+ task: Task to get environment name for
246
283
 
247
284
  Returns:
248
- Environment name (empty string if using platform default)
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, list[str], str]:
266
- """Resolve which environment to use for a task.
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
- task: Task to resolve environment for
314
+ task: Task to resolve environment for
276
315
 
277
316
  Returns:
278
- Tuple of (shell, args, preamble)
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 (env.shell, env.args, env.preamble)
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, args = self._get_platform_default_environment()
300
- return (shell, args, "")
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
- """Check if a task needs to run.
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
- task: Task to check
322
- args_dict: Arguments for this task execution
323
- force: If True, ignore freshness and force execution
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
- TaskStatus indicating whether task will run and why
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(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
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
- """Execute a task and its dependencies.
456
+ """
457
+ Execute a task and its dependencies.
408
458
 
409
459
  Args:
410
- task_name: Name of task to execute
411
- args_dict: Arguments to pass to the task
412
- force: If True, ignore freshness and re-run all tasks
413
- only: If True, run only the specified task without dependencies (implies force=True)
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
- Dictionary of task names to their execution status
466
+ Dictionary of task names to their execution status
417
467
 
418
468
  Raises:
419
- ExecutionError: If task execution fails
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 = (name == task_name)
460
- if not is_root_task and args_dict_for_execution and self._has_regular_args(task):
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(regular_args, sort_keys=True, separators=(",", ":"))
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
- """Execute a single task.
547
+ """
548
+ Execute a single task.
489
549
 
490
550
  Args:
491
- task: Task to execute
492
- args_dict: Arguments to substitute in command
551
+ task: Task to execute
552
+ args_dict: Arguments to substitute in command
493
553
 
494
554
  Raises:
495
- ExecutionError: If task execution fails
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(task, task_start_time)
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(working_dir_str, regular_args, exported_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(task, working_dir, task_start_time)
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, shell_args, preamble = self._resolve_environment(task)
555
-
556
- # Detect multi-line commands (ignore trailing newlines from YAML folded blocks)
557
- if "\n" in cmd.rstrip():
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 _run_single_line_command(
566
- self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str],
567
- exported_env_vars: dict[str, str] | None = None
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
- # Prepare environment with exported args
583
- env = self._prepare_env_with_exports(exported_env_vars)
640
+ Execute a command via temporary script file (unified execution path).
584
641
 
585
- try:
586
- # Build command: shell + args + cmd
587
- full_cmd = [shell] + shell_args + [cmd]
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
- cmd: Multi-line command string
608
- working_dir: Working directory
609
- task_name: Task name (for error messages)
610
- shell: Shell to use for script execution
611
- preamble: Preamble text to prepend to script
612
- exported_env_vars: Exported arguments to set as environment variables
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
- ExecutionError: If command execution fails
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(self, env: Environment, builtin_vars: dict[str, str]) -> Environment:
674
- """Substitute builtin and environment variables in environment fields.
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
- env: Environment to process
678
- builtin_vars: Built-in variable values
722
+ env: Environment to process
723
+ builtin_vars: Built-in variable values
679
724
 
680
725
  Returns:
681
- New Environment with builtin and environment variables substituted
726
+ New Environment with builtin and environment variables substituted
682
727
 
683
728
  Raises:
684
- ValueError: If builtin variable or environment variable is not defined
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
- self._substitute_env(self._substitute_builtin(vol, builtin_vars)) for vol in env.volumes
691
- ] if env.volumes else []
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
- key: self._substitute_env(self._substitute_builtin(value, builtin_vars))
696
- for key, value in env.env_vars.items()
697
- } if env.env_vars else {}
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
- self._substitute_env(self._substitute_builtin(port, builtin_vars)) for port in env.ports
702
- ] if env.ports else []
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 = self._substitute_env(self._substitute_builtin(env.working_dir, builtin_vars)) if env.working_dir else ""
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(self._substitute_builtin(str(value), builtin_vars))
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, task: Task, env: Any, cmd: str, working_dir: Path,
729
- exported_env_vars: dict[str, str] | None = None
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
- """Execute task inside Docker container.
803
+ """
804
+ Execute task inside Docker container.
732
805
 
733
806
  Args:
734
- task: Task to execute
735
- env: Docker environment configuration
736
- cmd: Command to execute
737
- working_dir: Host working directory
738
- exported_env_vars: Exported arguments to set as environment variables
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
- ExecutionError: If Docker execution fails
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(task, working_dir, task_start_time)
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
- def _validate_no_working_dir_circular_ref(self, text: str) -> None:
783
- """Validate that working_dir field does not contain {{ tt.working_dir }}.
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
- text: The working_dir field value to validate
867
+ text: The working_dir field value to validate
789
868
 
790
869
  Raises:
791
- ExecutionError: If {{ tt.working_dir }} placeholder is found
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'\{\{\s*tt\s*\.\s*working_dir\s*\}\}')
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
- f"Cannot use {{{{ tt.working_dir }}}} in the 'working_dir' field.\n\n"
800
- f"This creates a circular dependency (working_dir cannot reference itself).\n"
801
- f"Other built-in variables like {{{{ tt.task_name }}}} or {{{{ tt.timestamp }}}} are allowed."
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
- def _substitute_builtin(self, text: str, builtin_vars: dict[str, str]) -> str:
805
- """Substitute {{ tt.name }} placeholders in text.
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
- text: Text with {{ tt.name }} placeholders
811
- builtin_vars: Built-in variable values
893
+ text: Text with {{ tt.name }} placeholders
894
+ builtin_vars: Built-in variable values
812
895
 
813
896
  Returns:
814
- Text with built-in variables substituted
897
+ Text with built-in variables substituted
815
898
 
816
899
  Raises:
817
- ValueError: If built-in variable is not defined
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
- def _substitute_args(self, cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None) -> str:
823
- """Substitute {{ arg.name }} placeholders in command string.
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
- cmd: Command with {{ arg.name }} placeholders
830
- args_dict: Argument values to substitute (only regular args)
831
- exported_args: Set of argument names that are exported (not available for substitution)
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
- Command with arguments substituted
923
+ Command with arguments substituted
835
924
 
836
925
  Raises:
837
- ValueError: If an exported argument is used in template substitution
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
- def _substitute_env(self, text: str) -> str:
843
- """Substitute {{ env.NAME }} placeholders in text.
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
- text: Text with {{ env.NAME }} placeholders
941
+ text: Text with {{ env.NAME }} placeholders
849
942
 
850
943
  Returns:
851
- Text with environment variables substituted
944
+ Text with environment variables substituted
852
945
 
853
946
  Raises:
854
- ValueError: If environment variable is not set
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
- """Get all inputs for a task (explicit + implicit from dependencies).
955
+ """
956
+ Get all inputs for a task (explicit + implicit from dependencies).
861
957
 
862
958
  Args:
863
- task: Task to get inputs for
959
+ task: Task to get inputs for
864
960
 
865
961
  Returns:
866
- List of input glob patterns
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
- """Check if environment definition has changed since last run.
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
- task: Task to check
891
- cached_state: Cached state from previous run
892
- env_name: Effective environment name (from _get_effective_env_name)
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
- True if environment definition changed, False otherwise
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
- """Check if Docker image ID has changed.
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
- env: Docker environment definition
941
- cached_state: Cached state from previous run
942
- env_name: Environment name
1041
+ env: Docker environment definition
1042
+ cached_state: Cached state from previous run
1043
+ env_name: Environment name
943
1044
 
944
1045
  Returns:
945
- True if image ID changed, False otherwise
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 as e:
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
- """Check if any input files have changed since last run.
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
- task: Task to check
977
- cached_state: Cached state from previous run
978
- all_inputs: All input glob patterns
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
- List of changed file paths
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(f"Docker base image digests in {dockerfile_name}")
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
- def _expand_output_paths(self, task: Task) -> list[str]:
1062
- """Extract all output paths from task outputs (both named and anonymous).
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
- task: Task with outputs to extract
1173
+ task: Task with outputs to extract
1066
1174
 
1067
1175
  Returns:
1068
- List of output path patterns (glob patterns as strings)
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
- """Check if any declared outputs are missing.
1190
+ """
1191
+ Check if any declared outputs are missing.
1082
1192
 
1083
1193
  Args:
1084
- task: Task to check
1194
+ task: Task to check
1085
1195
 
1086
1196
  Returns:
1087
- List of output patterns that have no matching files
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
- """Expand glob patterns to actual file paths.
1218
+ """
1219
+ Expand glob patterns to actual file paths.
1108
1220
 
1109
1221
  Args:
1110
- patterns: List of glob patterns
1111
- working_dir: Working directory to resolve patterns from
1222
+ patterns: List of glob patterns
1223
+ working_dir: Working directory to resolve patterns from
1112
1224
 
1113
1225
  Returns:
1114
- List of file paths (relative to working_dir)
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
- # Compute hashes (include effective environment and dependencies)
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(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
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
- cache_key = make_cache_key(task_hash, args_hash)
1274
+ return make_cache_key(task_hash, args_hash)
1142
1275
 
1143
- # Get all inputs and their current mtimes
1144
- all_inputs = self._get_all_inputs(task)
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 below)
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
- # Record Docker-specific inputs if task uses Docker environment
1158
- env_name = self._get_effective_env_name(task)
1159
- if env_name:
1160
- env = self.recipe.get_environment(env_name)
1161
- if env and env.dockerfile:
1162
- # Record Dockerfile mtime
1163
- dockerfile_path = self.recipe.project_root / env.dockerfile
1164
- if dockerfile_path.exists():
1165
- input_state[env.dockerfile] = dockerfile_path.stat().st_mtime
1166
-
1167
- # Record .dockerignore mtime if exists
1168
- context_path = self.recipe.project_root / env.context
1169
- dockerignore_path = context_path / ".dockerignore"
1170
- if dockerignore_path.exists():
1171
- relative_dockerignore = str(
1172
- dockerignore_path.relative_to(self.recipe.project_root)
1173
- )
1174
- input_state[relative_dockerignore] = dockerignore_path.stat().st_mtime
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
- # For Docker environments, also store the image ID
1198
- if env.dockerfile:
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
- # Save state
1211
- self.state.set(cache_key, state)
1212
- self.state.save()
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