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.
@@ -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