tasktree 0.0.22__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/logging.py ADDED
@@ -0,0 +1,112 @@
1
+ """Logging infrastructure for Task Tree.
2
+
3
+ Provides a Logger interface for dependency injection of logging functionality.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ from abc import abstractmethod
10
+
11
+
12
+ class LogLevel(enum.Enum):
13
+ """
14
+ Log verbosity levels for tasktree diagnostic messages.
15
+
16
+ Lower numeric values represent higher severity / less verbosity.
17
+ @athena: 3b2d8a378440
18
+ """
19
+
20
+ FATAL = 0 # Only unrecoverable errors (malformed task files, missing dependencies)
21
+ ERROR = 1 # Fatal errors plus task execution failures
22
+ WARN = 2 # Errors plus warnings about deprecated features, configuration issues
23
+ INFO = 3 # Warnings plus normal execution progress (default)
24
+ DEBUG = 4 # Info plus variable values, resolved paths, environment details
25
+ TRACE = 5 # Debug plus fine-grained execution tracing
26
+
27
+
28
+ class Logger:
29
+ """
30
+ Abstract base class for logging implementations.
31
+
32
+ Provides a level-based logging interface with stack-based level management.
33
+ Concrete implementations must define how messages are output (e.g., to console, file, etc.).
34
+ @athena: fdcd08796011
35
+ """
36
+
37
+ @abstractmethod
38
+ def log(self, level: LogLevel, *args, **kwargs) -> None:
39
+ """
40
+ Log a message at the specified level.
41
+
42
+ Args:
43
+ level: The severity level of the message
44
+ *args: Positional arguments to log (strings, Rich objects, etc.)
45
+ **kwargs: Keyword arguments for formatting (e.g., style, justify)
46
+ @athena: 4563ae920ff4
47
+ """
48
+ pass
49
+
50
+ @abstractmethod
51
+ def push_level(self, level: LogLevel) -> None:
52
+ """
53
+ Push a new log level onto the level stack.
54
+
55
+ Messages below this level will be filtered out until pop_level() is called.
56
+ Useful for temporarily increasing verbosity in specific code sections.
57
+
58
+ Args:
59
+ level: The new log level to use
60
+ @athena: c73ac375d30a
61
+ """
62
+ pass
63
+
64
+ @abstractmethod
65
+ def pop_level(self) -> LogLevel:
66
+ """
67
+ Pop the current log level from the stack and return to the previous level.
68
+
69
+ Returns:
70
+ The log level that was popped
71
+
72
+ Raises:
73
+ RuntimeError: If attempting to pop the base (initial) log level
74
+ @athena: d4721a34516f
75
+ """
76
+ pass
77
+
78
+ def fatal(self, *args, **kwargs):
79
+ """
80
+ @athena: 951c9c5d4f3b
81
+ """
82
+ self.log(LogLevel.FATAL, *args, **kwargs)
83
+
84
+ def error(self, *args, **kwargs):
85
+ """
86
+ @athena: 7125264a8ce1
87
+ """
88
+ self.log(LogLevel.ERROR, *args, **kwargs)
89
+
90
+ def warn(self, *args, **kwargs):
91
+ """
92
+ @athena: 5a70af1bb5b4
93
+ """
94
+ self.log(LogLevel.WARN, *args, **kwargs)
95
+
96
+ def info(self, *args, **kwargs):
97
+ """
98
+ @athena: cdb5381023a6
99
+ """
100
+ self.log(LogLevel.INFO, *args, **kwargs)
101
+
102
+ def debug(self, *args, **kwargs):
103
+ """
104
+ @athena: ddfd0e359f09
105
+ """
106
+ self.log(LogLevel.DEBUG, *args, **kwargs)
107
+
108
+ def trace(self, *args, **kwargs):
109
+ """
110
+ @athena: 4f615a15562c
111
+ """
112
+ self.log(LogLevel.TRACE, *args, **kwargs)
tasktree/parser.py CHANGED
@@ -16,6 +16,7 @@ from typing import Any, List
16
16
  import yaml
17
17
 
18
18
  from tasktree.types import get_click_type
19
+ from tasktree.process_runner import TaskOutputTypes
19
20
 
20
21
 
21
22
  class CircularImportError(Exception):
@@ -69,7 +70,7 @@ class Environment:
69
70
  class Task:
70
71
  """
71
72
  Represents a task definition.
72
- @athena: f516b5ae61c5
73
+ @athena: e2ea62ad15ba
73
74
  """
74
75
 
75
76
  name: str
@@ -91,6 +92,7 @@ class Task:
91
92
  source_file: str = "" # Track which file defined this task
92
93
  env: str = "" # Environment name to use for execution
93
94
  private: bool = False # If True, task is hidden from --list output
95
+ task_output: TaskOutputTypes | None = None
94
96
 
95
97
  # Internal fields for efficient output lookup (built in __post_init__)
96
98
  _output_map: dict[str, str] = field(
@@ -119,7 +121,7 @@ class Task:
119
121
  def __post_init__(self):
120
122
  """
121
123
  Ensure lists are always lists and build input/output maps and indexed lists.
122
- @athena: a48b1eba81cd
124
+ @athena: 5c750d8b1ef7
123
125
  """
124
126
  if isinstance(self.deps, str):
125
127
  self.deps = [self.deps]
@@ -313,7 +315,7 @@ class ArgSpec:
313
315
  class Recipe:
314
316
  """
315
317
  Represents a parsed recipe file with all tasks.
316
- @athena: 47f568c77013
318
+ @athena: 5d1881f292cc
317
319
  """
318
320
 
319
321
  tasks: dict[str, Task]
@@ -394,7 +396,7 @@ class Recipe:
394
396
  >>> recipe = parse_recipe(path) # Variables not yet evaluated
395
397
  >>> recipe.evaluate_variables("build") # Evaluate only reachable variables
396
398
  >>> # Now recipe.evaluated_variables contains only vars used by "build" task
397
- @athena: d8de7b5f42b6
399
+ @athena: 108eb8ae4de1
398
400
  """
399
401
  if self._variables_evaluated:
400
402
  return # Already evaluated, skip (idempotent)
@@ -665,7 +667,7 @@ def _validate_variable_name(name: str) -> None:
665
667
 
666
668
  Raises:
667
669
  ValueError: If name is not a valid identifier
668
- @athena: 61f92f7ad278
670
+ @athena: b768b37686da
669
671
  """
670
672
  if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
671
673
  raise ValueError(
@@ -727,7 +729,7 @@ def _validate_env_variable_reference(
727
729
 
728
730
  Raises:
729
731
  ValueError: If reference is invalid
730
- @athena: 9fc8b2333b54
732
+ @athena: 9738cec4b0b4
731
733
  """
732
734
  # Validate dict structure - allow 'env' and optionally 'default'
733
735
  valid_keys = {"env", "default"}
@@ -911,7 +913,7 @@ def _resolve_file_variable(var_name: str, filepath: str, resolved_path: Path) ->
911
913
 
912
914
  Raises:
913
915
  ValueError: If file doesn't exist, can't be read, or contains invalid UTF-8
914
- @athena: cab84337f145
916
+ @athena: 211ae0e2493d
915
917
  """
916
918
  # Check file exists
917
919
  if not resolved_path.exists():
@@ -1036,7 +1038,7 @@ def _resolve_eval_variable(
1036
1038
 
1037
1039
  Raises:
1038
1040
  ValueError: If command fails or cannot be executed
1039
- @athena: 647d3a310c77
1041
+ @athena: 0f912a7346fd
1040
1042
  """
1041
1043
  # Determine shell to use
1042
1044
  shell = None
@@ -1131,7 +1133,7 @@ def _resolve_variable_value(
1131
1133
 
1132
1134
  Raises:
1133
1135
  ValueError: If circular reference detected or validation fails
1134
- @athena: da94de106756
1136
+ @athena: 2d87857c4e95
1135
1137
  """
1136
1138
  # Check for circular reference
1137
1139
  if name in resolution_stack:
@@ -1336,7 +1338,7 @@ def _expand_variable_dependencies(
1336
1338
  ... }
1337
1339
  >>> _expand_variable_dependencies({"a"}, raw_vars)
1338
1340
  {"a", "b", "c"}
1339
- @athena: 98e583b402aa
1341
+ @athena: c7d55d26a3c2
1340
1342
  """
1341
1343
  expanded = set(variable_names)
1342
1344
  to_process = list(variable_names)
@@ -1470,7 +1472,7 @@ def _parse_file_with_env(
1470
1472
  Returns:
1471
1473
  Tuple of (tasks, environments, default_env_name, raw_variables, YAML_data)
1472
1474
  Note: Variables are NOT evaluated here - they're stored as raw specs for lazy evaluation
1473
- @athena: b2dced506787
1475
+ @athena: 8b00183e612d
1474
1476
  """
1475
1477
  # Parse tasks normally
1476
1478
  tasks = _parse_file(file_path, namespace, project_root, import_stack)
@@ -1675,7 +1677,7 @@ def collect_reachable_variables(
1675
1677
  >>> task = Task("build", cmd="echo {{ var.version }}")
1676
1678
  >>> collect_reachable_variables({"build": task}, {"build"})
1677
1679
  {"version"}
1678
- @athena: e22e54537f8d
1680
+ @athena: 84edaecf913a
1679
1681
  """
1680
1682
  import re
1681
1683
 
@@ -1824,7 +1826,7 @@ def parse_recipe(
1824
1826
  CircularImportError: If circular imports are detected
1825
1827
  yaml.YAMLError: If YAML is invalid
1826
1828
  ValueError: If recipe structure is invalid
1827
- @athena: 27326e37d5f3
1829
+ @athena: c79c0f326180
1828
1830
  """
1829
1831
  if not recipe_path.exists():
1830
1832
  raise FileNotFoundError(f"Recipe file not found: {recipe_path}")
@@ -1883,7 +1885,7 @@ def _parse_file(
1883
1885
  CircularImportError: If a circular import is detected
1884
1886
  FileNotFoundError: If an imported file doesn't exist
1885
1887
  ValueError: If task structure is invalid
1886
- @athena: 8a903d791c2f
1888
+ @athena: 225864160e55
1887
1889
  """
1888
1890
  # Initialize import stack if not provided
1889
1891
  if import_stack is None:
@@ -2065,6 +2067,7 @@ def _parse_file(
2065
2067
  source_file=str(file_path),
2066
2068
  env=task_data.get("env", ""),
2067
2069
  private=task_data.get("private", False),
2070
+ task_output=task_data.get("task_output", None)
2068
2071
  )
2069
2072
 
2070
2073
  # Check for case-sensitive argument collisions
@@ -2090,7 +2093,7 @@ def _check_case_sensitive_arg_collisions(args: list[str], task_name: str) -> Non
2090
2093
  Args:
2091
2094
  args: List of argument specifications
2092
2095
  task_name: Name of the task (for warning message)
2093
- @athena: a3f0f3b184a8
2096
+ @athena: 11ec810aa07b
2094
2097
  """
2095
2098
  import sys
2096
2099
 
@@ -2156,7 +2159,7 @@ def parse_arg_spec(arg_spec: str | dict) -> ArgSpec:
2156
2159
 
2157
2160
  Raises:
2158
2161
  ValueError: If argument specification is invalid
2159
- @athena: 2a4c7e804622
2162
+ @athena: ef9805c194d7
2160
2163
  """
2161
2164
  # Handle dictionary format: { argname: { type: ..., default: ... } }
2162
2165
  if isinstance(arg_spec, dict):
@@ -2232,7 +2235,7 @@ def _parse_arg_dict(arg_name: str, config: dict, is_exported: bool) -> ArgSpec:
2232
2235
 
2233
2236
  Raises:
2234
2237
  ValueError: If dictionary format is invalid
2235
- @athena: 5b6b93a3612a
2238
+ @athena: a6020b5b771c
2236
2239
  """
2237
2240
  # Validate dictionary keys
2238
2241
  valid_keys = {"type", "default", "min", "max", "choices"}
@@ -0,0 +1,411 @@
1
+ """Process execution abstraction layer.
2
+
3
+ This module provides an interface for running subprocesses, allowing for
4
+ better testability and dependency injection.
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+ from abc import ABC, abstractmethod
10
+ from enum import Enum
11
+ from subprocess import Popen
12
+ from threading import Thread
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "ProcessRunner",
17
+ "PassthroughProcessRunner",
18
+ "SilentProcessRunner",
19
+ "StdoutOnlyProcessRunner",
20
+ "StderrOnlyProcessRunner",
21
+ "StderrOnlyOnFailureProcessRunner",
22
+ "TaskOutputTypes",
23
+ "make_process_runner",
24
+ "stream_output"
25
+ ]
26
+
27
+ from tasktree.logging import Logger
28
+
29
+
30
+ class TaskOutputTypes(Enum):
31
+ """
32
+ Enum defining task output control modes.
33
+ @athena: TBD
34
+ """
35
+
36
+ ALL = "all"
37
+ NONE = "none"
38
+ OUT = "out"
39
+ ERR = "err"
40
+ ON_ERR = "on-err"
41
+
42
+
43
+ class ProcessRunner(ABC):
44
+ """
45
+ Abstract interface for running subprocess commands.
46
+ @athena: 78720f594104
47
+ """
48
+
49
+ @abstractmethod
50
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
51
+ """
52
+ Run a subprocess command.
53
+
54
+ This method signature matches subprocess.run() to allow for direct
55
+ substitution in existing code.
56
+
57
+ Args:
58
+ *args: Positional arguments passed to subprocess.run
59
+ **kwargs: Keyword arguments passed to subprocess.run
60
+
61
+ Returns:
62
+ subprocess.CompletedProcess: The completed process result
63
+
64
+ Raises:
65
+ subprocess.CalledProcessError: If check=True and process exits non-zero
66
+ subprocess.TimeoutExpired: If timeout is exceeded
67
+ @athena: c056d217be2e
68
+ """
69
+ ...
70
+
71
+
72
+ class PassthroughProcessRunner(ProcessRunner):
73
+ """
74
+ Process runner that directly delegates to subprocess.run.
75
+ @athena: 470e2ca46355
76
+ """
77
+
78
+ def __init__(self, logger: Logger) -> None:
79
+ self._logger = logger
80
+
81
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
82
+ """
83
+ Run a subprocess command via subprocess.run.
84
+
85
+ Args:
86
+ *args: Positional arguments passed to subprocess.run
87
+ **kwargs: Keyword arguments passed to subprocess.run
88
+
89
+ Returns:
90
+ subprocess.CompletedProcess: The completed process result
91
+
92
+ Raises:
93
+ subprocess.CalledProcessError: If check=True and process exits non-zero
94
+ subprocess.TimeoutExpired: If timeout is exceeded
95
+ @athena: 9f6363a621f2
96
+ """
97
+ return subprocess.run(*args, **kwargs)
98
+
99
+
100
+ class SilentProcessRunner(ProcessRunner):
101
+ """
102
+ Process runner that suppresses all subprocess output by redirecting to DEVNULL.
103
+ @athena: TBD
104
+ """
105
+
106
+ def __init__(self, logger: Logger) -> None:
107
+ self._logger = logger
108
+
109
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
110
+ """
111
+ Run a subprocess command with stdout and stderr suppressed.
112
+
113
+ This implementation forces stdout=DEVNULL and stderr=DEVNULL to discard
114
+ all subprocess output, regardless of what the caller requests.
115
+
116
+ Args:
117
+ *args: Positional arguments passed to subprocess.run
118
+ **kwargs: Keyword arguments passed to subprocess.run
119
+
120
+ Returns:
121
+ subprocess.CompletedProcess: The completed process result
122
+
123
+ Raises:
124
+ subprocess.CalledProcessError: If check=True and process exits non-zero
125
+ subprocess.TimeoutExpired: If timeout is exceeded
126
+ @athena: TBD
127
+ """
128
+ kwargs["stdout"] = subprocess.DEVNULL
129
+ kwargs["stderr"] = subprocess.DEVNULL
130
+ return subprocess.run(*args, **kwargs)
131
+
132
+
133
+ def stream_output(pipe: Any, target: Any) -> None:
134
+ """
135
+ Stream output from a pipe to a target stream.
136
+
137
+ Handles exceptions gracefully to avoid silent thread failures.
138
+ If the pipe is closed or an error occurs during reading/writing,
139
+ the function returns without raising an exception.
140
+
141
+ Args:
142
+ pipe: Input pipe to read from
143
+ target: Output stream to write to
144
+ @athena: TBD
145
+ """
146
+ if pipe:
147
+ try:
148
+ for line in pipe:
149
+ target.write(line)
150
+ target.flush()
151
+ except (OSError, ValueError):
152
+ # Pipe closed or other I/O error - this is expected when
153
+ # process is killed or stdout is closed
154
+ pass
155
+
156
+
157
+ def _start_thread_and_wait_to_complete(process: Popen[str], stream: Any, thread: Thread, process_allowed_runtime: float | None, logger: Logger) -> int:
158
+ join_timeout_secs = 1.0
159
+
160
+ thread.start()
161
+
162
+ try:
163
+ process_return_code = process.wait(timeout=process_allowed_runtime)
164
+ except subprocess.TimeoutExpired:
165
+ process.kill()
166
+ process.wait()
167
+ if stream:
168
+ stream.close()
169
+ stream = None
170
+ thread.join(timeout=join_timeout_secs)
171
+ raise
172
+ finally:
173
+ if stream:
174
+ stream.close()
175
+ stream = None
176
+
177
+ thread.join(timeout=join_timeout_secs)
178
+ if thread.is_alive():
179
+ logger.warn(f"Stream thread did not complete within timeout of {join_timeout_secs} seconds")
180
+
181
+ return process_return_code
182
+
183
+
184
+ def _check_result_if_necessary(raise_on_failure: bool, proc_ret_code: int, *args, **kwargs) -> subprocess.CompletedProcess[Any]:
185
+ if raise_on_failure and proc_ret_code != 0:
186
+ raise subprocess.CalledProcessError(
187
+ proc_ret_code, args[0] if args else kwargs.get("args", [])
188
+ )
189
+
190
+ # Return a CompletedProcess object for interface compatibility
191
+ return subprocess.CompletedProcess(
192
+ args=args[0] if args else kwargs.get("args", []),
193
+ returncode=proc_ret_code,
194
+ stdout=None,
195
+ stderr=None, # We streamed it, so don't capture it
196
+ )
197
+
198
+
199
+ class StdoutOnlyProcessRunner(ProcessRunner):
200
+ """
201
+ Process runner that streams stdout while suppressing stderr.
202
+
203
+ This implementation uses threading to asynchronously stream stdout from the
204
+ subprocess while discarding stderr output.
205
+ @athena: TBD
206
+ """
207
+
208
+ def __init__(self, logger: Logger) -> None:
209
+ self._logger = logger
210
+
211
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
212
+ """
213
+ Run a subprocess command with stdout streamed and stderr suppressed.
214
+
215
+ This implementation uses subprocess.Popen with threading to stream stdout
216
+ in real-time while discarding stderr. The interface remains synchronous
217
+ from the caller's perspective.
218
+
219
+ Buffering strategy: Uses line buffering (bufsize=1) to ensure output
220
+ appears promptly while maintaining reasonable performance.
221
+
222
+ Args:
223
+ *args: Positional arguments passed to subprocess.Popen
224
+ **kwargs: Keyword arguments passed to subprocess.Popen
225
+
226
+ Returns:
227
+ subprocess.CompletedProcess: The completed process result
228
+
229
+ Raises:
230
+ subprocess.CalledProcessError: If check=True and process exits non-zero
231
+ subprocess.TimeoutExpired: If timeout is exceeded
232
+ @athena: TBD
233
+ """
234
+ # Extract parameters that need special handling
235
+ check = kwargs.pop("check", False)
236
+ timeout = kwargs.pop("timeout", None)
237
+ # Remove capture_output if present - not supported by Popen
238
+ kwargs.pop("capture_output", None)
239
+
240
+ # Force stdout/stderr handling
241
+ kwargs["stdout"] = subprocess.PIPE
242
+ kwargs["stderr"] = subprocess.DEVNULL
243
+ kwargs["text"] = True
244
+ kwargs["bufsize"] = 1
245
+
246
+ # Start the process
247
+ process = subprocess.Popen(*args, **kwargs)
248
+
249
+ # Start thread to stream stdout with a descriptive name for debugging
250
+ thread = Thread(
251
+ target=stream_output,
252
+ args=(process.stdout, sys.stdout),
253
+ name="stdout-streamer",
254
+ daemon=True,
255
+ )
256
+
257
+ process_return_code = _start_thread_and_wait_to_complete(process, process.stdout, thread, timeout, self._logger)
258
+ return _check_result_if_necessary(check, process_return_code, *args, **kwargs)
259
+
260
+
261
+ class StderrOnlyProcessRunner(ProcessRunner):
262
+ """
263
+ Process runner that streams stderr while suppressing stdout.
264
+
265
+ This implementation uses threading to asynchronously stream stderr from the
266
+ subprocess while discarding stdout output.
267
+ @athena: TBD
268
+ """
269
+
270
+ def __init__(self, logger: Logger) -> None:
271
+ self._logger = logger
272
+
273
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
274
+ """
275
+ Run a subprocess command with stderr streamed and stdout suppressed.
276
+
277
+ This implementation uses subprocess.Popen with threading to stream stderr
278
+ in real-time while discarding stdout. The interface remains synchronous
279
+ from the caller's perspective.
280
+
281
+ Buffering strategy: Uses line buffering (bufsize=1) to ensure output
282
+ appears promptly while maintaining reasonable performance.
283
+
284
+ Args:
285
+ *args: Positional arguments passed to subprocess.Popen
286
+ **kwargs: Keyword arguments passed to subprocess.Popen
287
+
288
+ Returns:
289
+ subprocess.CompletedProcess: The completed process result
290
+
291
+ Raises:
292
+ subprocess.CalledProcessError: If check=True and process exits non-zero
293
+ subprocess.TimeoutExpired: If timeout is exceeded
294
+ @athena: TBD
295
+ """
296
+ # Extract parameters that need special handling
297
+ check = kwargs.pop("check", False)
298
+ timeout = kwargs.pop("timeout", None)
299
+ # Remove capture_output if present - not supported by Popen
300
+ kwargs.pop("capture_output", None)
301
+
302
+ # Force stdout/stderr handling
303
+ kwargs["stdout"] = subprocess.DEVNULL
304
+ kwargs["stderr"] = subprocess.PIPE
305
+ kwargs["text"] = True
306
+ kwargs["bufsize"] = 1
307
+
308
+ # Start the process
309
+ process = subprocess.Popen(*args, **kwargs)
310
+
311
+ # Start thread to stream stderr with a descriptive name for debugging
312
+ thread = Thread(
313
+ target=stream_output,
314
+ args=(process.stderr, sys.stderr),
315
+ name="stderr-streamer",
316
+ daemon=True,
317
+ )
318
+
319
+ process_return_code = _start_thread_and_wait_to_complete(process, process.stderr, thread, timeout, self._logger)
320
+ return _check_result_if_necessary(check, process_return_code, *args, **kwargs)
321
+
322
+
323
+ class StderrOnlyOnFailureProcessRunner(ProcessRunner):
324
+ """
325
+ Process runner that buffers stderr and only outputs it on failure.
326
+
327
+ This implementation ignores stdout completely (sends to DEVNULL) and captures
328
+ stderr. The buffered stderr is only output if the process exits with a non-zero
329
+ code.
330
+ """
331
+
332
+ def __init__(self, logger: Logger) -> None:
333
+ self._logger = logger
334
+
335
+ def run(self, *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
336
+ """
337
+ Run a subprocess command, buffering stderr and outputting only on failure.
338
+
339
+ Stdout is completely ignored (sent to DEVNULL). Stderr is collected in a
340
+ buffer during execution. If the process exits with non-zero code, the
341
+ buffered stderr is written to sys.stderr before returning/raising.
342
+
343
+ Args:
344
+ *args: Positional arguments passed to subprocess.run
345
+ **kwargs: Keyword arguments passed to subprocess.run
346
+
347
+ Returns:
348
+ subprocess.CompletedProcess: The completed process result
349
+
350
+ Raises:
351
+ subprocess.CalledProcessError: If check=True and process exits non-zero
352
+ subprocess.TimeoutExpired: If timeout is exceeded
353
+ """
354
+ check = kwargs.pop("check", False)
355
+ timeout = kwargs.pop("timeout", None)
356
+ kwargs.pop("capture_output", None) # Remove if present
357
+ kwargs.pop("stdout", None) # Remove if present
358
+ kwargs.pop("stderr", None) # Remove if present
359
+
360
+ result = subprocess.run(
361
+ *args,
362
+ **kwargs,
363
+ stdout=subprocess.DEVNULL,
364
+ stderr=subprocess.PIPE,
365
+ text=True,
366
+ timeout=timeout,
367
+ check=False,
368
+ )
369
+
370
+ if result.returncode != 0 and result.stderr:
371
+ sys.stderr.write(result.stderr)
372
+ sys.stderr.flush()
373
+
374
+ if check and result.returncode != 0:
375
+ raise subprocess.CalledProcessError(
376
+ result.returncode,
377
+ result.args,
378
+ output=result.stdout,
379
+ stderr=result.stderr,
380
+ )
381
+
382
+ return result
383
+
384
+
385
+ def make_process_runner(output_type: TaskOutputTypes, logger: Logger) -> ProcessRunner:
386
+ """
387
+ Factory function for creating ProcessRunner instances.
388
+
389
+ Args:
390
+ output_type: The type of output control to use
391
+
392
+ Returns:
393
+ ProcessRunner: A new ProcessRunner instance
394
+
395
+ Raises:
396
+ ValueError: If an invalid TaskOutputTypes value is provided
397
+ @athena: ba1d2e048716
398
+ """
399
+ match output_type:
400
+ case TaskOutputTypes.ALL:
401
+ return PassthroughProcessRunner(logger)
402
+ case TaskOutputTypes.NONE:
403
+ return SilentProcessRunner(logger)
404
+ case TaskOutputTypes.OUT:
405
+ return StdoutOnlyProcessRunner(logger)
406
+ case TaskOutputTypes.ERR:
407
+ return StderrOnlyProcessRunner(logger)
408
+ case TaskOutputTypes.ON_ERR:
409
+ return StderrOnlyOnFailureProcessRunner(logger)
410
+ case _:
411
+ raise ValueError(f"Invalid TaskOutputTypes: {output_type}")