Skip to content

Commit 247dffd

Browse files
authored
Merge pull request #6 from JustAGhosT/tembo/migrate-orchestration-utils-repos
Migrate Orchestration Utils
2 parents d143214 + cef4ade commit 247dffd

15 files changed

Lines changed: 922 additions & 1 deletion

File tree

tools/packages/codeflow-utils-python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "Shared utility functions for CodeFlow Python applications"
99
readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.12"
12-
keywords = ["codeflow", "utils", "utilities", "resilience", "logging"]
12+
keywords = ["codeflow", "utils", "utilities", "resilience", "logging", "validation", "formatting", "retry", "rate-limiting"]
1313
classifiers = [
1414
"Development Status :: 4 - Beta",
1515
"Intended Audience :: Developers",

tools/packages/codeflow-utils-python/src/codeflow_utils/__init__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
Modules:
88
logging: Structured logging utilities
99
resilience: Circuit breaker and retry patterns
10+
validation: Input, URL, email, and config validation
11+
formatting: Date, number, and string formatting
12+
common: Retry, rate limiting, and error handling
1013
"""
1114

1215
from codeflow_utils.logging import (
@@ -22,6 +25,39 @@
2225
CircuitBreakerState,
2326
CircuitBreakerStats,
2427
)
28+
from codeflow_utils.validation import (
29+
validate_config,
30+
validate_environment_variables,
31+
sanitize_input,
32+
validate_input,
33+
validate_url,
34+
is_valid_url,
35+
validate_email,
36+
is_valid_email,
37+
extract_email_domain,
38+
normalize_email,
39+
)
40+
from codeflow_utils.formatting import (
41+
format_datetime,
42+
format_iso_datetime,
43+
format_relative_time,
44+
format_number,
45+
format_bytes,
46+
format_percentage,
47+
truncate_string,
48+
slugify,
49+
camel_to_snake,
50+
snake_to_camel,
51+
)
52+
from codeflow_utils.common import (
53+
retry,
54+
CodeFlowUtilsError,
55+
format_error_message,
56+
create_error_response,
57+
RateLimiter,
58+
rate_limit,
59+
PerKeyRateLimiter,
60+
)
2561

2662
__version__ = "0.1.0"
2763

@@ -37,4 +73,36 @@
3773
"CircuitBreakerOpenError",
3874
"CircuitBreakerState",
3975
"CircuitBreakerStats",
76+
# Validation
77+
"validate_config",
78+
"validate_environment_variables",
79+
"sanitize_input",
80+
"validate_input",
81+
"validate_url",
82+
"is_valid_url",
83+
"validate_email",
84+
"is_valid_email",
85+
"extract_email_domain",
86+
"normalize_email",
87+
# Formatting - Date
88+
"format_datetime",
89+
"format_iso_datetime",
90+
"format_relative_time",
91+
# Formatting - Number
92+
"format_number",
93+
"format_bytes",
94+
"format_percentage",
95+
# Formatting - String
96+
"truncate_string",
97+
"slugify",
98+
"camel_to_snake",
99+
"snake_to_camel",
100+
# Common
101+
"retry",
102+
"CodeFlowUtilsError",
103+
"format_error_message",
104+
"create_error_response",
105+
"RateLimiter",
106+
"rate_limit",
107+
"PerKeyRateLimiter",
40108
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Common utility functions for CodeFlow."""
2+
3+
from .retry import retry
4+
from .errors import (
5+
CodeFlowUtilsError,
6+
format_error_message,
7+
create_error_response,
8+
)
9+
from .rate_limit import RateLimiter, rate_limit, PerKeyRateLimiter
10+
11+
__all__ = [
12+
"retry",
13+
"CodeFlowUtilsError",
14+
"format_error_message",
15+
"create_error_response",
16+
"RateLimiter",
17+
"rate_limit",
18+
"PerKeyRateLimiter",
19+
]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Error handling utilities."""
2+
3+
from typing import Any
4+
5+
6+
class CodeFlowUtilsError(Exception):
7+
"""Base exception for codeflow-utils-python."""
8+
pass
9+
10+
11+
def format_error_message(
12+
operation: str,
13+
error: Exception,
14+
context: dict[str, Any] | None = None,
15+
) -> str:
16+
"""
17+
Format error message with context.
18+
19+
Args:
20+
operation: Name of the operation that failed
21+
error: The exception that was raised
22+
context: Optional context dictionary
23+
24+
Returns:
25+
Formatted error message
26+
"""
27+
message = f"{operation} failed: {str(error)}"
28+
29+
if context:
30+
context_str = ", ".join(f"{k}={v}" for k, v in context.items())
31+
message += f" (context: {context_str})"
32+
33+
return message
34+
35+
36+
def create_error_response(
37+
error: Exception,
38+
operation: str | None = None,
39+
context: dict[str, Any] | None = None,
40+
) -> dict[str, Any]:
41+
"""
42+
Create standardized error response dictionary.
43+
44+
Args:
45+
error: The exception that was raised
46+
operation: Optional operation name
47+
context: Optional context dictionary
48+
49+
Returns:
50+
Error response dictionary
51+
"""
52+
response = {
53+
"error": type(error).__name__,
54+
"message": str(error),
55+
}
56+
57+
if operation:
58+
response["operation"] = operation
59+
60+
if context:
61+
response["context"] = context
62+
63+
return response
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Rate limiting utilities."""
2+
3+
import time
4+
from collections import defaultdict
5+
from threading import Lock
6+
from typing import Callable, Any
7+
8+
9+
class RateLimiter:
10+
"""Simple rate limiter using token bucket algorithm."""
11+
12+
def __init__(self, max_calls: int, period: float):
13+
"""
14+
Initialize rate limiter.
15+
16+
Args:
17+
max_calls: Maximum number of calls allowed
18+
period: Time period in seconds
19+
"""
20+
self.max_calls = max_calls
21+
self.period = period
22+
self.calls: list[float] = []
23+
self.lock = Lock()
24+
25+
def acquire(self) -> bool:
26+
"""
27+
Try to acquire a rate limit token.
28+
29+
Returns:
30+
True if token acquired, False if rate limit exceeded
31+
"""
32+
with self.lock:
33+
now = time.time()
34+
35+
# Remove old calls outside the period
36+
self.calls = [call_time for call_time in self.calls if now - call_time < self.period]
37+
38+
# Check if we can make another call
39+
if len(self.calls) < self.max_calls:
40+
self.calls.append(now)
41+
return True
42+
43+
return False
44+
45+
def wait_time(self) -> float:
46+
"""
47+
Get the wait time until next call can be made.
48+
49+
Returns:
50+
Wait time in seconds, or 0 if no wait needed
51+
"""
52+
with self.lock:
53+
if len(self.calls) < self.max_calls:
54+
return 0.0
55+
56+
oldest_call = min(self.calls)
57+
wait = self.period - (time.time() - oldest_call)
58+
return max(0.0, wait)
59+
60+
61+
def rate_limit(max_calls: int, period: float):
62+
"""
63+
Decorator to rate limit function calls.
64+
65+
Args:
66+
max_calls: Maximum number of calls allowed
67+
period: Time period in seconds
68+
69+
Returns:
70+
Decorated function with rate limiting
71+
"""
72+
limiter = RateLimiter(max_calls, period)
73+
74+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
75+
def wrapper(*args: Any, **kwargs: Any) -> Any:
76+
if not limiter.acquire():
77+
wait = limiter.wait_time()
78+
if wait > 0:
79+
time.sleep(wait)
80+
limiter.acquire()
81+
82+
return func(*args, **kwargs)
83+
84+
return wrapper
85+
86+
return decorator
87+
88+
89+
class PerKeyRateLimiter:
90+
"""Rate limiter that tracks limits per key."""
91+
92+
def __init__(self, max_calls: int, period: float):
93+
"""
94+
Initialize per-key rate limiter.
95+
96+
Args:
97+
max_calls: Maximum number of calls allowed per key
98+
period: Time period in seconds
99+
"""
100+
self.max_calls = max_calls
101+
self.period = period
102+
self.limiters: dict[str, RateLimiter] = defaultdict(
103+
lambda: RateLimiter(max_calls, period)
104+
)
105+
self.lock = Lock()
106+
107+
def acquire(self, key: str) -> bool:
108+
"""
109+
Try to acquire a rate limit token for a key.
110+
111+
Args:
112+
key: Rate limit key
113+
114+
Returns:
115+
True if token acquired, False if rate limit exceeded
116+
"""
117+
with self.lock:
118+
return self.limiters[key].acquire()
119+
120+
def wait_time(self, key: str) -> float:
121+
"""
122+
Get the wait time for a key.
123+
124+
Args:
125+
key: Rate limit key
126+
127+
Returns:
128+
Wait time in seconds
129+
"""
130+
with self.lock:
131+
return self.limiters[key].wait_time()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Retry logic utilities."""
2+
3+
import asyncio
4+
import time
5+
from functools import wraps
6+
from typing import Any, Callable, TypeVar
7+
8+
T = TypeVar("T")
9+
10+
11+
def retry(
12+
max_attempts: int = 3,
13+
delay: float = 1.0,
14+
backoff: float = 2.0,
15+
exceptions: tuple[type[Exception], ...] = (Exception,),
16+
):
17+
"""
18+
Retry decorator for functions.
19+
20+
Args:
21+
max_attempts: Maximum number of retry attempts
22+
delay: Initial delay between retries (seconds)
23+
backoff: Backoff multiplier
24+
exceptions: Tuple of exceptions to catch and retry on
25+
26+
Example:
27+
@retry(max_attempts=3, delay=1.0)
28+
def api_call():
29+
# API call that might fail
30+
pass
31+
"""
32+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
33+
@wraps(func)
34+
def sync_wrapper(*args: Any, **kwargs: Any) -> T:
35+
current_delay = delay
36+
last_exception = None
37+
38+
for attempt in range(max_attempts):
39+
try:
40+
return func(*args, **kwargs)
41+
except exceptions as e:
42+
last_exception = e
43+
if attempt < max_attempts - 1:
44+
time.sleep(current_delay)
45+
current_delay *= backoff
46+
else:
47+
raise
48+
49+
@wraps(func)
50+
async def async_wrapper(*args: Any, **kwargs: Any) -> T:
51+
current_delay = delay
52+
last_exception = None
53+
54+
for attempt in range(max_attempts):
55+
try:
56+
return await func(*args, **kwargs)
57+
except exceptions as e:
58+
last_exception = e
59+
if attempt < max_attempts - 1:
60+
await asyncio.sleep(current_delay)
61+
current_delay *= backoff
62+
else:
63+
raise
64+
65+
if asyncio.iscoroutinefunction(func):
66+
return async_wrapper
67+
return sync_wrapper
68+
69+
return decorator

0 commit comments

Comments
 (0)