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/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 get_implicit_inputs, resolve_execution_order, resolve_dependency_output_references, resolve_self_references
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: ac1e2fc7b82b
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
- 'PATH',
56
- 'LD_LIBRARY_PATH',
57
- 'LD_PRELOAD',
58
- 'PYTHONPATH',
59
- 'HOME',
60
- 'SHELL',
61
- 'USER',
62
- 'LOGNAME',
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__(self, recipe: Recipe, state_manager: StateManager):
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
- @athena: 21b65db48bca
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
- def _has_regular_args(self, task: Task) -> bool:
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: 0fc46146eed3
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('=')[0].split(':')[0].strip()
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
- def _filter_regular_args(self, task: Task, task_args: dict[str, Any]) -> dict[str, Any]:
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: 811abd3a56f9
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('=')[0].split(':')[0].strip()
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(self, task: Task, timestamp: datetime) -> dict[str, str]:
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: 0b348e67ce4c
177
+ @athena: a0c1316fd713
154
178
  """
155
179
  import os
156
180
 
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()))
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['user_home'] = str(user_home)
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 = os.environ.get('USER') or os.environ.get('USERNAME') or 'unknown'
189
- builtin_vars['user_name'] = user_name
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(self, task: Task, working_dir: Path, timestamp: datetime) -> dict[str, str]:
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: bb8c385cb0a5
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['working_dir'] = str(working_dir.resolve())
238
+ builtin_vars["working_dir"] = str(working_dir.resolve())
215
239
 
216
240
  return builtin_vars
217
241
 
218
- def _prepare_env_with_exports(self, exported_env_vars: dict[str, str] | None = None) -> dict[str, str]:
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
- def _get_platform_default_environment(self) -> tuple[str, list[str]]:
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: b67799671787
277
+ @athena: 8b7fa81073af
251
278
  """
252
279
  is_windows = platform.system() == "Windows"
253
280
  if is_windows:
254
- return ("cmd", ["/c"])
281
+ return "cmd", ["/c"]
255
282
  else:
256
- return ("bash", ["-c"])
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, list[str], 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, args, preamble)
305
- @athena: b919568f73fc
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 (env.shell, env.args, env.preamble)
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, args = self._get_platform_default_environment()
327
- return (shell, args, "")
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: 7252f5db8a4d
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(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
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
- # Check if environment definition has changed
390
- env_changed = self._check_environment_changed(task, cached_state, effective_env)
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: 1c293ee6a6fa
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(task, args_dict_for_execution, force=force)
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 = (name == task_name)
491
- if not is_root_task and args_dict_for_execution and self._has_regular_args(task):
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(regular_args, sort_keys=True, separators=(",", ":"))
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
- import sys
509
- print(
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(self, task: Task, args_dict: dict[str, Any]) -> None:
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: 885c66658550
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(task, task_start_time)
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(working_dir_str, regular_args, exported_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(task, working_dir, task_start_time)
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
- print(f"Running: {task.name}")
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(task, env, cmd, working_dir, exported_env_vars)
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, 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)
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 _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
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 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)
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
- 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.
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: Multi-line command string
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: 825892b6db05
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
- subprocess.run(
693
- [script_path],
694
- cwd=working_dir,
695
- check=True,
696
- capture_output=False,
697
- env=env,
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(self, env: Environment, builtin_vars: dict[str, str]) -> 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: 21e2ccd27dbb
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
- self._substitute_env(self._substitute_builtin(vol, builtin_vars)) for vol in env.volumes
730
- ] if env.volumes else []
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
- 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 {}
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
- self._substitute_env(self._substitute_builtin(port, builtin_vars)) for port in env.ports
741
- ] if env.ports else []
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 = self._substitute_env(self._substitute_builtin(env.working_dir, builtin_vars)) if env.working_dir else ""
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(self._substitute_builtin(str(value), builtin_vars))
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, task: Task, env: Any, cmd: str, working_dir: Path,
768
- exported_env_vars: dict[str, str] | None = None
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: fe972e4c97a3
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(task, working_dir, task_start_time)
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
- def _validate_no_working_dir_circular_ref(self, text: str) -> None:
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: 5dc6ee41d403
968
+ @athena: 82822f02716a
835
969
  """
836
970
  import re
971
+
837
972
  # Pattern to match {{ tt.working_dir }} specifically
838
- pattern = re.compile(r'\{\{\s*tt\s*\.\s*working_dir\s*\}\}')
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
- 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."
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
- def _substitute_builtin(self, text: str, builtin_vars: dict[str, str]) -> str:
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: 463600a203f4
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
- def _substitute_args(self, cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None) -> str:
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: 4261a91c6a98
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
- def _substitute_env(self, text: str) -> str:
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: 63becab531cd
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, task: Task, cached_state: TaskState, env_name: str
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: 052561b75455
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(env, cached_state, env_name)
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, env: Environment, cached_state: TaskState, env_name: str
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: 8af77cb1be44
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(env)
1005
- except Exception as e:
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(f"Docker base image digests in {dockerfile_name}")
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
- def _expand_output_paths(self, task: Task) -> list[str]:
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: 848a28564b14
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
- Args:
1198
- task: Task that was executed
1199
- args_dict: Arguments used for execution
1200
- @athena: 1fcfdfcb9be9
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(task.cmd, task.outputs, task.working_dir, task.args, effective_env, task.deps)
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
- cache_key = make_cache_key(task_hash, args_hash)
1388
+ return make_cache_key(task_hash, args_hash)
1207
1389
 
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)
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 below)
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
- # 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
1406
+ return input_state
1258
1407
 
1259
- env_hash = hash_environment_definition(env)
1260
- input_state[f"_env_hash_{env_name}"] = env_hash
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
- # 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
- )
1429
+ # Record context check timestamp
1430
+ input_state[f"_context_{env.context}"] = time.time()
1274
1431
 
1275
- # Save state
1276
- self.state.set(cache_key, state)
1277
- self.state.save()
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