taipanstack 0.1.0__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.
- taipanstack/__init__.py +53 -0
- taipanstack/config/__init__.py +25 -0
- taipanstack/config/generators.py +357 -0
- taipanstack/config/models.py +316 -0
- taipanstack/config/version_config.py +227 -0
- taipanstack/core/__init__.py +47 -0
- taipanstack/core/compat.py +329 -0
- taipanstack/core/optimizations.py +392 -0
- taipanstack/core/result.py +199 -0
- taipanstack/security/__init__.py +55 -0
- taipanstack/security/decorators.py +369 -0
- taipanstack/security/guards.py +362 -0
- taipanstack/security/sanitizers.py +321 -0
- taipanstack/security/validators.py +342 -0
- taipanstack/utils/__init__.py +24 -0
- taipanstack/utils/circuit_breaker.py +268 -0
- taipanstack/utils/filesystem.py +417 -0
- taipanstack/utils/logging.py +328 -0
- taipanstack/utils/metrics.py +272 -0
- taipanstack/utils/retry.py +300 -0
- taipanstack/utils/subprocess.py +344 -0
- taipanstack-0.1.0.dist-info/METADATA +350 -0
- taipanstack-0.1.0.dist-info/RECORD +25 -0
- taipanstack-0.1.0.dist-info/WHEEL +4 -0
- taipanstack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry logic with exponential backoff.
|
|
3
|
+
|
|
4
|
+
Provides decorators for automatic retry of failing operations
|
|
5
|
+
with configurable backoff strategies. Compatible with any
|
|
6
|
+
Python framework (sync and async).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, ParamSpec, TypeVar
|
|
18
|
+
|
|
19
|
+
P = ParamSpec("P")
|
|
20
|
+
R = TypeVar("R")
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("taipanstack.utils.retry")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RetryConfig:
|
|
27
|
+
"""Configuration for retry behavior.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
max_attempts: Maximum number of retry attempts.
|
|
31
|
+
initial_delay: Initial delay between retries in seconds.
|
|
32
|
+
max_delay: Maximum delay between retries.
|
|
33
|
+
exponential_base: Base for exponential backoff (2 = double each time).
|
|
34
|
+
jitter: Whether to add random jitter to delays.
|
|
35
|
+
jitter_factor: Maximum jitter as fraction of delay (0.1 = 10%).
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
max_attempts: int = 3
|
|
40
|
+
initial_delay: float = 1.0
|
|
41
|
+
max_delay: float = 60.0
|
|
42
|
+
exponential_base: float = 2.0
|
|
43
|
+
jitter: bool = True
|
|
44
|
+
jitter_factor: float = 0.1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RetryError(Exception):
|
|
48
|
+
"""Raised when all retry attempts have failed."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
attempts: int,
|
|
54
|
+
last_exception: Exception | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialize RetryError.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
message: Description of the retry failure.
|
|
60
|
+
attempts: Number of attempts made.
|
|
61
|
+
last_exception: The last exception that was raised.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
self.attempts = attempts
|
|
65
|
+
self.last_exception = last_exception
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def calculate_delay(
|
|
70
|
+
attempt: int,
|
|
71
|
+
config: RetryConfig,
|
|
72
|
+
) -> float:
|
|
73
|
+
"""Calculate delay before next retry.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
attempt: Current attempt number (1-indexed).
|
|
77
|
+
config: Retry configuration.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Delay in seconds before next retry.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
# Exponential backoff
|
|
84
|
+
delay = config.initial_delay * (config.exponential_base ** (attempt - 1))
|
|
85
|
+
|
|
86
|
+
# Cap at max delay
|
|
87
|
+
delay = min(delay, config.max_delay)
|
|
88
|
+
|
|
89
|
+
# Add jitter if enabled
|
|
90
|
+
if config.jitter:
|
|
91
|
+
jitter_amount = delay * config.jitter_factor
|
|
92
|
+
delay += random.uniform(-jitter_amount, jitter_amount)
|
|
93
|
+
|
|
94
|
+
return max(0, delay)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def retry(
|
|
98
|
+
*,
|
|
99
|
+
max_attempts: int = 3,
|
|
100
|
+
initial_delay: float = 1.0,
|
|
101
|
+
max_delay: float = 60.0,
|
|
102
|
+
exponential_base: float = 2.0,
|
|
103
|
+
jitter: bool = True,
|
|
104
|
+
on: tuple[type[Exception], ...] = (Exception,),
|
|
105
|
+
reraise: bool = True,
|
|
106
|
+
log_retries: bool = True,
|
|
107
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
108
|
+
"""Retry a function with exponential backoff.
|
|
109
|
+
|
|
110
|
+
Automatically retries the decorated function when specified
|
|
111
|
+
exceptions are raised, with configurable backoff strategy.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
max_attempts: Maximum number of retry attempts.
|
|
115
|
+
initial_delay: Initial delay between retries in seconds.
|
|
116
|
+
max_delay: Maximum delay between retries.
|
|
117
|
+
exponential_base: Base for exponential backoff.
|
|
118
|
+
jitter: Whether to add random jitter to delays.
|
|
119
|
+
on: Exception types to retry on.
|
|
120
|
+
reraise: Whether to reraise the last exception on failure.
|
|
121
|
+
log_retries: Whether to log retry attempts.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Decorated function with retry logic.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
>>> @retry(max_attempts=3, on=(ConnectionError, TimeoutError))
|
|
128
|
+
... def fetch_data(url: str) -> dict:
|
|
129
|
+
... return requests.get(url).json()
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
config = RetryConfig(
|
|
133
|
+
max_attempts=max_attempts,
|
|
134
|
+
initial_delay=initial_delay,
|
|
135
|
+
max_delay=max_delay,
|
|
136
|
+
exponential_base=exponential_base,
|
|
137
|
+
jitter=jitter,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
141
|
+
@functools.wraps(func)
|
|
142
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
143
|
+
last_exception: Exception | None = None
|
|
144
|
+
|
|
145
|
+
for attempt in range(1, max_attempts + 1):
|
|
146
|
+
try:
|
|
147
|
+
return func(*args, **kwargs)
|
|
148
|
+
except on as e:
|
|
149
|
+
last_exception = e
|
|
150
|
+
|
|
151
|
+
if attempt == max_attempts:
|
|
152
|
+
# Last attempt failed
|
|
153
|
+
if log_retries:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"All %d attempts failed for %s: %s",
|
|
156
|
+
max_attempts,
|
|
157
|
+
func.__name__,
|
|
158
|
+
str(e),
|
|
159
|
+
)
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# Calculate delay and wait
|
|
163
|
+
delay = calculate_delay(attempt, config)
|
|
164
|
+
|
|
165
|
+
if log_retries:
|
|
166
|
+
logger.info(
|
|
167
|
+
"Attempt %d/%d failed for %s: %s. "
|
|
168
|
+
"Retrying in %.2f seconds...",
|
|
169
|
+
attempt,
|
|
170
|
+
max_attempts,
|
|
171
|
+
func.__name__,
|
|
172
|
+
str(e),
|
|
173
|
+
delay,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
time.sleep(delay)
|
|
177
|
+
|
|
178
|
+
# All attempts failed
|
|
179
|
+
if reraise and last_exception is not None:
|
|
180
|
+
raise RetryError(
|
|
181
|
+
f"All {max_attempts} attempts failed for {func.__name__}",
|
|
182
|
+
attempts=max_attempts,
|
|
183
|
+
last_exception=last_exception,
|
|
184
|
+
) from last_exception
|
|
185
|
+
|
|
186
|
+
# Should never reach here if reraise=True
|
|
187
|
+
raise RetryError(
|
|
188
|
+
f"All {max_attempts} attempts failed for {func.__name__}",
|
|
189
|
+
attempts=max_attempts,
|
|
190
|
+
last_exception=last_exception,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return wrapper
|
|
194
|
+
|
|
195
|
+
return decorator
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def retry_on_exception(
|
|
199
|
+
exception_types: tuple[type[Exception], ...],
|
|
200
|
+
max_attempts: int = 3,
|
|
201
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
202
|
+
"""Retry on specific exceptions.
|
|
203
|
+
|
|
204
|
+
A simpler alternative to the full retry decorator when you
|
|
205
|
+
just need basic retry functionality.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
exception_types: Exception types to retry on.
|
|
209
|
+
max_attempts: Maximum number of attempts.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Decorated function with retry logic.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> @retry_on_exception((ValueError,), max_attempts=2)
|
|
216
|
+
... def parse_data(data: str) -> dict:
|
|
217
|
+
... return json.loads(data)
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
return retry(
|
|
221
|
+
max_attempts=max_attempts,
|
|
222
|
+
on=exception_types,
|
|
223
|
+
jitter=False,
|
|
224
|
+
log_retries=False,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class Retrier:
|
|
229
|
+
"""Context manager for retry logic.
|
|
230
|
+
|
|
231
|
+
Provides a context manager interface for retry logic when
|
|
232
|
+
decorators are not suitable.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> retrier = Retrier(max_attempts=3, on=(ConnectionError,))
|
|
236
|
+
>>> with retrier:
|
|
237
|
+
... result = some_operation()
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
max_attempts: int = 3,
|
|
245
|
+
initial_delay: float = 1.0,
|
|
246
|
+
max_delay: float = 60.0,
|
|
247
|
+
on: tuple[type[Exception], ...] = (Exception,),
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Initialize Retrier.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
max_attempts: Maximum retry attempts.
|
|
253
|
+
initial_delay: Initial delay between retries.
|
|
254
|
+
max_delay: Maximum delay between retries.
|
|
255
|
+
on: Exception types to retry on.
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
self.config = RetryConfig(
|
|
259
|
+
max_attempts=max_attempts,
|
|
260
|
+
initial_delay=initial_delay,
|
|
261
|
+
max_delay=max_delay,
|
|
262
|
+
)
|
|
263
|
+
self.exception_types = on
|
|
264
|
+
self.attempt = 0
|
|
265
|
+
self.last_exception: Exception | None = None
|
|
266
|
+
|
|
267
|
+
def __enter__(self) -> Retrier:
|
|
268
|
+
"""Enter the retry context."""
|
|
269
|
+
self.attempt = 0
|
|
270
|
+
self.last_exception = None
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def __exit__(
|
|
274
|
+
self,
|
|
275
|
+
exc_type: type[Exception] | None,
|
|
276
|
+
exc_val: Exception | None,
|
|
277
|
+
exc_tb: Any,
|
|
278
|
+
) -> bool:
|
|
279
|
+
"""Exit the retry context.
|
|
280
|
+
|
|
281
|
+
Returns True to suppress the exception if we should retry,
|
|
282
|
+
False to let it propagate.
|
|
283
|
+
"""
|
|
284
|
+
if exc_type is None:
|
|
285
|
+
return False # No exception, exit normally
|
|
286
|
+
|
|
287
|
+
if not issubclass(exc_type, self.exception_types):
|
|
288
|
+
return False # Exception type not in retry list
|
|
289
|
+
|
|
290
|
+
self.last_exception = exc_val
|
|
291
|
+
self.attempt += 1
|
|
292
|
+
|
|
293
|
+
if self.attempt >= self.config.max_attempts:
|
|
294
|
+
return False # Max attempts reached, propagate exception
|
|
295
|
+
|
|
296
|
+
# Calculate delay and wait
|
|
297
|
+
delay = calculate_delay(self.attempt, self.config)
|
|
298
|
+
time.sleep(delay)
|
|
299
|
+
|
|
300
|
+
return True # Suppress exception and retry
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe subprocess execution with security guards.
|
|
3
|
+
|
|
4
|
+
Provides secure wrappers around subprocess execution with
|
|
5
|
+
command validation, timeout handling, and retry logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from taipanstack.security.guards import SecurityError, guard_command_injection
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SafeCommandResult:
|
|
22
|
+
"""Result of a safe command execution.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
command: The executed command.
|
|
26
|
+
returncode: Exit code of the command.
|
|
27
|
+
stdout: Standard output.
|
|
28
|
+
stderr: Standard error.
|
|
29
|
+
success: Whether the command succeeded (returncode == 0).
|
|
30
|
+
duration_seconds: How long the command took.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
command: list[str]
|
|
35
|
+
returncode: int
|
|
36
|
+
stdout: str = ""
|
|
37
|
+
stderr: str = ""
|
|
38
|
+
duration_seconds: float = 0.0
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def success(self) -> bool:
|
|
42
|
+
"""Check if command succeeded."""
|
|
43
|
+
return self.returncode == 0
|
|
44
|
+
|
|
45
|
+
def raise_on_error(self) -> SafeCommandResult:
|
|
46
|
+
"""Raise an exception if command failed.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Self if successful.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
subprocess.CalledProcessError: If command failed.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
if not self.success:
|
|
56
|
+
raise subprocess.CalledProcessError(
|
|
57
|
+
self.returncode,
|
|
58
|
+
self.command,
|
|
59
|
+
self.stdout,
|
|
60
|
+
self.stderr,
|
|
61
|
+
)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Default allowed commands whitelist
|
|
66
|
+
DEFAULT_ALLOWED_COMMANDS: frozenset[str] = frozenset(
|
|
67
|
+
{
|
|
68
|
+
# Python/Poetry
|
|
69
|
+
"python",
|
|
70
|
+
"python3",
|
|
71
|
+
"pip",
|
|
72
|
+
"pip3",
|
|
73
|
+
"poetry",
|
|
74
|
+
"pipx",
|
|
75
|
+
# Version control
|
|
76
|
+
"git",
|
|
77
|
+
# Build tools
|
|
78
|
+
"make",
|
|
79
|
+
# Testing
|
|
80
|
+
"pytest",
|
|
81
|
+
"mypy",
|
|
82
|
+
"ruff",
|
|
83
|
+
"bandit",
|
|
84
|
+
"safety",
|
|
85
|
+
"semgrep",
|
|
86
|
+
"pre-commit",
|
|
87
|
+
# System
|
|
88
|
+
"echo",
|
|
89
|
+
"cat",
|
|
90
|
+
"ls",
|
|
91
|
+
"pwd",
|
|
92
|
+
"mkdir",
|
|
93
|
+
"rm",
|
|
94
|
+
"cp",
|
|
95
|
+
"mv",
|
|
96
|
+
"touch",
|
|
97
|
+
"chmod",
|
|
98
|
+
"which",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_safe_command(
|
|
104
|
+
command: Sequence[str],
|
|
105
|
+
*,
|
|
106
|
+
cwd: Path | str | None = None,
|
|
107
|
+
timeout: float = 300.0,
|
|
108
|
+
capture_output: bool = True,
|
|
109
|
+
check: bool = False,
|
|
110
|
+
allowed_commands: Sequence[str] | None = None,
|
|
111
|
+
env: dict[str, str] | None = None,
|
|
112
|
+
dry_run: bool = False,
|
|
113
|
+
) -> SafeCommandResult:
|
|
114
|
+
"""Execute a command safely with security guards.
|
|
115
|
+
|
|
116
|
+
This function provides a secure wrapper around subprocess.run
|
|
117
|
+
with command injection protection, timeout handling, and
|
|
118
|
+
optional command whitelisting.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
command: Command and arguments as a sequence.
|
|
122
|
+
cwd: Working directory for the command.
|
|
123
|
+
timeout: Maximum execution time in seconds.
|
|
124
|
+
capture_output: Whether to capture stdout/stderr.
|
|
125
|
+
check: Whether to raise on non-zero exit.
|
|
126
|
+
allowed_commands: Whitelist of allowed commands.
|
|
127
|
+
env: Environment variables to set.
|
|
128
|
+
dry_run: If True, don't actually execute the command.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
SafeCommandResult with execution details.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
SecurityError: If command validation fails.
|
|
135
|
+
subprocess.TimeoutExpired: If command times out.
|
|
136
|
+
subprocess.CalledProcessError: If check=True and command fails.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
>>> result = run_safe_command(["poetry", "install"])
|
|
140
|
+
>>> if result.success:
|
|
141
|
+
... print("Installation complete!")
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
# Convert to list
|
|
145
|
+
cmd_list = list(command)
|
|
146
|
+
|
|
147
|
+
if not cmd_list:
|
|
148
|
+
raise SecurityError(
|
|
149
|
+
"Empty command is not allowed",
|
|
150
|
+
guard_name="safe_command",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Get command whitelist
|
|
154
|
+
if allowed_commands is not None:
|
|
155
|
+
whitelist = list(allowed_commands)
|
|
156
|
+
else:
|
|
157
|
+
whitelist = list(DEFAULT_ALLOWED_COMMANDS)
|
|
158
|
+
|
|
159
|
+
# Validate command against guards
|
|
160
|
+
validated_cmd = guard_command_injection(cmd_list, allowed_commands=whitelist)
|
|
161
|
+
|
|
162
|
+
# Verify command exists
|
|
163
|
+
base_command = validated_cmd[0]
|
|
164
|
+
if not shutil.which(base_command):
|
|
165
|
+
raise SecurityError(
|
|
166
|
+
f"Command not found: {base_command}",
|
|
167
|
+
guard_name="safe_command",
|
|
168
|
+
value=base_command,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Handle dry run
|
|
172
|
+
if dry_run:
|
|
173
|
+
return SafeCommandResult(
|
|
174
|
+
command=validated_cmd,
|
|
175
|
+
returncode=0,
|
|
176
|
+
stdout=f"[DRY-RUN] Would execute: {' '.join(validated_cmd)}",
|
|
177
|
+
stderr="",
|
|
178
|
+
duration_seconds=0.0,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Resolve working directory
|
|
182
|
+
if cwd is not None:
|
|
183
|
+
cwd = Path(cwd).resolve()
|
|
184
|
+
if not cwd.exists():
|
|
185
|
+
raise SecurityError(
|
|
186
|
+
f"Working directory does not exist: {cwd}",
|
|
187
|
+
guard_name="safe_command",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Execute command
|
|
191
|
+
start_time = time.time()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
validated_cmd,
|
|
196
|
+
cwd=cwd,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
capture_output=capture_output,
|
|
199
|
+
text=True,
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
env=env,
|
|
202
|
+
check=False, # We handle check ourselves
|
|
203
|
+
)
|
|
204
|
+
except subprocess.TimeoutExpired as e:
|
|
205
|
+
duration = time.time() - start_time
|
|
206
|
+
stdout_str = ""
|
|
207
|
+
if hasattr(e, "stdout") and e.stdout is not None:
|
|
208
|
+
if isinstance(e.stdout, str):
|
|
209
|
+
stdout_str = e.stdout
|
|
210
|
+
else:
|
|
211
|
+
stdout_str = e.stdout.decode("utf-8", errors="replace")
|
|
212
|
+
return SafeCommandResult(
|
|
213
|
+
command=validated_cmd,
|
|
214
|
+
returncode=-1,
|
|
215
|
+
stdout=stdout_str,
|
|
216
|
+
stderr=f"Command timed out after {timeout}s",
|
|
217
|
+
duration_seconds=duration,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
duration = time.time() - start_time
|
|
221
|
+
|
|
222
|
+
safe_result = SafeCommandResult(
|
|
223
|
+
command=validated_cmd,
|
|
224
|
+
returncode=result.returncode,
|
|
225
|
+
stdout=result.stdout or "",
|
|
226
|
+
stderr=result.stderr or "",
|
|
227
|
+
duration_seconds=duration,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if check:
|
|
231
|
+
safe_result.raise_on_error()
|
|
232
|
+
|
|
233
|
+
return safe_result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_poetry_command(
|
|
237
|
+
args: Sequence[str],
|
|
238
|
+
*,
|
|
239
|
+
cwd: Path | str | None = None,
|
|
240
|
+
timeout: float = 600.0,
|
|
241
|
+
dry_run: bool = False,
|
|
242
|
+
) -> SafeCommandResult:
|
|
243
|
+
"""Execute a Poetry command safely.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
args: Arguments to pass to poetry.
|
|
247
|
+
cwd: Working directory.
|
|
248
|
+
timeout: Maximum execution time.
|
|
249
|
+
dry_run: Don't actually execute.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
SafeCommandResult with execution details.
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
>>> result = run_poetry_command(["install"])
|
|
256
|
+
>>> result = run_poetry_command(["add", "pytest", "--group", "dev"])
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
return run_safe_command(
|
|
260
|
+
["poetry", *args],
|
|
261
|
+
cwd=cwd,
|
|
262
|
+
timeout=timeout,
|
|
263
|
+
dry_run=dry_run,
|
|
264
|
+
allowed_commands=["poetry"],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def run_git_command(
|
|
269
|
+
args: Sequence[str],
|
|
270
|
+
*,
|
|
271
|
+
cwd: Path | str | None = None,
|
|
272
|
+
timeout: float = 60.0,
|
|
273
|
+
dry_run: bool = False,
|
|
274
|
+
) -> SafeCommandResult:
|
|
275
|
+
"""Execute a Git command safely.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
args: Arguments to pass to git.
|
|
279
|
+
cwd: Working directory.
|
|
280
|
+
timeout: Maximum execution time.
|
|
281
|
+
dry_run: Don't actually execute.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
SafeCommandResult with execution details.
|
|
285
|
+
|
|
286
|
+
Example:
|
|
287
|
+
>>> result = run_git_command(["init"])
|
|
288
|
+
>>> result = run_git_command(["status", "--porcelain"])
|
|
289
|
+
|
|
290
|
+
"""
|
|
291
|
+
return run_safe_command(
|
|
292
|
+
["git", *args],
|
|
293
|
+
cwd=cwd,
|
|
294
|
+
timeout=timeout,
|
|
295
|
+
dry_run=dry_run,
|
|
296
|
+
allowed_commands=["git"],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_command_exists(command: str) -> bool:
|
|
301
|
+
"""Check if a command exists in PATH.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
command: The command to check.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
True if command exists, False otherwise.
|
|
308
|
+
|
|
309
|
+
"""
|
|
310
|
+
return shutil.which(command) is not None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_command_version(
|
|
314
|
+
command: str,
|
|
315
|
+
version_arg: str = "--version",
|
|
316
|
+
) -> str | None:
|
|
317
|
+
"""Get the version of a command.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
command: The command to check.
|
|
321
|
+
version_arg: Argument to get version (default: --version).
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Version string or None if command not found.
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
if not check_command_exists(command):
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
result = run_safe_command(
|
|
332
|
+
[command, version_arg],
|
|
333
|
+
timeout=10.0,
|
|
334
|
+
capture_output=True,
|
|
335
|
+
)
|
|
336
|
+
if result.success:
|
|
337
|
+
# Return first non-empty line
|
|
338
|
+
for raw_line in result.stdout.split("\n"):
|
|
339
|
+
stripped_line = raw_line.strip()
|
|
340
|
+
if stripped_line:
|
|
341
|
+
return stripped_line
|
|
342
|
+
return None
|
|
343
|
+
except (SecurityError, subprocess.SubprocessError):
|
|
344
|
+
return None
|