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/__init__.py +1 -1
- tasktree/cli.py +145 -112
- tasktree/console_logger.py +66 -0
- tasktree/docker.py +14 -8
- tasktree/executor.py +161 -41
- tasktree/graph.py +3 -3
- tasktree/hasher.py +5 -5
- tasktree/logging.py +112 -0
- tasktree/parser.py +20 -17
- tasktree/process_runner.py +411 -0
- tasktree/substitution.py +2 -2
- tasktree/types.py +3 -3
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/METADATA +201 -4
- tasktree-0.0.23.dist-info/RECORD +17 -0
- tasktree-0.0.22.dist-info/RECORD +0 -14
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/WHEEL +0 -0
- {tasktree-0.0.22.dist-info → tasktree-0.0.23.dist-info}/entry_points.txt +0 -0
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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}")
|