tasktree 0.0.21__py3-none-any.whl → 0.0.22__py3-none-any.whl

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