provide-foundation 0.0.0.dev1__py3-none-any.whl → 0.0.0.dev3__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.
Files changed (163) hide show
  1. provide/foundation/__init__.py +36 -10
  2. provide/foundation/archive/__init__.py +1 -1
  3. provide/foundation/archive/base.py +15 -14
  4. provide/foundation/archive/bzip2.py +40 -40
  5. provide/foundation/archive/gzip.py +42 -42
  6. provide/foundation/archive/operations.py +93 -96
  7. provide/foundation/archive/tar.py +33 -31
  8. provide/foundation/archive/zip.py +52 -50
  9. provide/foundation/asynctools/__init__.py +20 -0
  10. provide/foundation/asynctools/core.py +126 -0
  11. provide/foundation/cli/__init__.py +2 -2
  12. provide/foundation/cli/commands/deps.py +15 -9
  13. provide/foundation/cli/commands/logs/__init__.py +3 -3
  14. provide/foundation/cli/commands/logs/generate.py +2 -2
  15. provide/foundation/cli/commands/logs/query.py +4 -4
  16. provide/foundation/cli/commands/logs/send.py +3 -3
  17. provide/foundation/cli/commands/logs/tail.py +3 -3
  18. provide/foundation/cli/decorators.py +11 -11
  19. provide/foundation/cli/main.py +1 -1
  20. provide/foundation/cli/testing.py +2 -40
  21. provide/foundation/cli/utils.py +21 -18
  22. provide/foundation/config/__init__.py +35 -2
  23. provide/foundation/config/base.py +2 -2
  24. provide/foundation/config/converters.py +477 -0
  25. provide/foundation/config/defaults.py +67 -0
  26. provide/foundation/config/env.py +6 -20
  27. provide/foundation/config/loader.py +10 -4
  28. provide/foundation/config/sync.py +8 -6
  29. provide/foundation/config/types.py +5 -5
  30. provide/foundation/config/validators.py +4 -4
  31. provide/foundation/console/input.py +5 -5
  32. provide/foundation/console/output.py +36 -14
  33. provide/foundation/context/__init__.py +8 -4
  34. provide/foundation/context/core.py +88 -110
  35. provide/foundation/crypto/certificates/__init__.py +9 -5
  36. provide/foundation/crypto/certificates/base.py +2 -2
  37. provide/foundation/crypto/certificates/certificate.py +48 -19
  38. provide/foundation/crypto/certificates/factory.py +26 -18
  39. provide/foundation/crypto/certificates/generator.py +24 -23
  40. provide/foundation/crypto/certificates/loader.py +24 -16
  41. provide/foundation/crypto/certificates/operations.py +17 -10
  42. provide/foundation/crypto/certificates/trust.py +21 -21
  43. provide/foundation/env/__init__.py +28 -0
  44. provide/foundation/env/core.py +218 -0
  45. provide/foundation/errors/__init__.py +3 -3
  46. provide/foundation/errors/decorators.py +0 -234
  47. provide/foundation/errors/types.py +0 -98
  48. provide/foundation/eventsets/display.py +13 -14
  49. provide/foundation/eventsets/registry.py +61 -31
  50. provide/foundation/eventsets/resolver.py +50 -46
  51. provide/foundation/eventsets/sets/das.py +8 -8
  52. provide/foundation/eventsets/sets/database.py +14 -14
  53. provide/foundation/eventsets/sets/http.py +21 -21
  54. provide/foundation/eventsets/sets/llm.py +16 -16
  55. provide/foundation/eventsets/sets/task_queue.py +13 -13
  56. provide/foundation/eventsets/types.py +7 -7
  57. provide/foundation/file/directory.py +14 -23
  58. provide/foundation/file/lock.py +4 -3
  59. provide/foundation/hub/components.py +75 -389
  60. provide/foundation/hub/config.py +157 -0
  61. provide/foundation/hub/discovery.py +63 -0
  62. provide/foundation/hub/handlers.py +89 -0
  63. provide/foundation/hub/lifecycle.py +195 -0
  64. provide/foundation/hub/manager.py +7 -4
  65. provide/foundation/hub/processors.py +49 -0
  66. provide/foundation/integrations/__init__.py +11 -0
  67. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  68. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  69. provide/foundation/{observability → integrations}/openobserve/client.py +14 -14
  70. provide/foundation/{observability → integrations}/openobserve/commands.py +12 -12
  71. provide/foundation/integrations/openobserve/config.py +37 -0
  72. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  73. provide/foundation/{observability → integrations}/openobserve/otlp.py +2 -2
  74. provide/foundation/{observability → integrations}/openobserve/search.py +2 -3
  75. provide/foundation/{observability → integrations}/openobserve/streaming.py +5 -5
  76. provide/foundation/logger/__init__.py +0 -1
  77. provide/foundation/logger/config/base.py +1 -1
  78. provide/foundation/logger/config/logging.py +69 -299
  79. provide/foundation/logger/config/telemetry.py +39 -121
  80. provide/foundation/logger/factories.py +2 -2
  81. provide/foundation/logger/processors/main.py +12 -10
  82. provide/foundation/logger/ratelimit/limiters.py +4 -4
  83. provide/foundation/logger/ratelimit/processor.py +1 -1
  84. provide/foundation/logger/setup/coordinator.py +39 -25
  85. provide/foundation/logger/setup/processors.py +3 -3
  86. provide/foundation/logger/setup/testing.py +14 -0
  87. provide/foundation/logger/trace.py +5 -5
  88. provide/foundation/metrics/__init__.py +1 -1
  89. provide/foundation/metrics/otel.py +3 -1
  90. provide/foundation/observability/__init__.py +3 -3
  91. provide/foundation/process/__init__.py +9 -0
  92. provide/foundation/process/exit.py +48 -0
  93. provide/foundation/process/lifecycle.py +69 -46
  94. provide/foundation/resilience/__init__.py +36 -0
  95. provide/foundation/resilience/circuit.py +166 -0
  96. provide/foundation/resilience/decorators.py +236 -0
  97. provide/foundation/resilience/fallback.py +208 -0
  98. provide/foundation/resilience/retry.py +327 -0
  99. provide/foundation/serialization/__init__.py +16 -0
  100. provide/foundation/serialization/core.py +70 -0
  101. provide/foundation/streams/config.py +78 -0
  102. provide/foundation/streams/console.py +4 -5
  103. provide/foundation/streams/core.py +5 -2
  104. provide/foundation/streams/file.py +12 -2
  105. provide/foundation/testing/__init__.py +29 -9
  106. provide/foundation/testing/archive/__init__.py +7 -7
  107. provide/foundation/testing/archive/fixtures.py +58 -54
  108. provide/foundation/testing/cli.py +30 -20
  109. provide/foundation/testing/common/__init__.py +13 -15
  110. provide/foundation/testing/common/fixtures.py +27 -57
  111. provide/foundation/testing/file/__init__.py +15 -15
  112. provide/foundation/testing/file/content_fixtures.py +289 -0
  113. provide/foundation/testing/file/directory_fixtures.py +107 -0
  114. provide/foundation/testing/file/fixtures.py +42 -516
  115. provide/foundation/testing/file/special_fixtures.py +145 -0
  116. provide/foundation/testing/logger.py +89 -8
  117. provide/foundation/testing/mocking/__init__.py +21 -21
  118. provide/foundation/testing/mocking/fixtures.py +80 -67
  119. provide/foundation/testing/process/__init__.py +23 -23
  120. provide/foundation/testing/process/async_fixtures.py +414 -0
  121. provide/foundation/testing/process/fixtures.py +48 -571
  122. provide/foundation/testing/process/subprocess_fixtures.py +210 -0
  123. provide/foundation/testing/threading/__init__.py +17 -17
  124. provide/foundation/testing/threading/basic_fixtures.py +105 -0
  125. provide/foundation/testing/threading/data_fixtures.py +101 -0
  126. provide/foundation/testing/threading/execution_fixtures.py +278 -0
  127. provide/foundation/testing/threading/fixtures.py +32 -502
  128. provide/foundation/testing/threading/sync_fixtures.py +100 -0
  129. provide/foundation/testing/time/__init__.py +11 -11
  130. provide/foundation/testing/time/fixtures.py +95 -83
  131. provide/foundation/testing/transport/__init__.py +9 -9
  132. provide/foundation/testing/transport/fixtures.py +54 -54
  133. provide/foundation/time/__init__.py +18 -0
  134. provide/foundation/time/core.py +63 -0
  135. provide/foundation/tools/__init__.py +2 -2
  136. provide/foundation/tools/base.py +68 -67
  137. provide/foundation/tools/cache.py +69 -74
  138. provide/foundation/tools/downloader.py +68 -62
  139. provide/foundation/tools/installer.py +51 -57
  140. provide/foundation/tools/registry.py +38 -45
  141. provide/foundation/tools/resolver.py +70 -68
  142. provide/foundation/tools/verifier.py +39 -50
  143. provide/foundation/tracer/spans.py +2 -14
  144. provide/foundation/transport/__init__.py +26 -33
  145. provide/foundation/transport/base.py +32 -30
  146. provide/foundation/transport/client.py +44 -49
  147. provide/foundation/transport/config.py +36 -107
  148. provide/foundation/transport/errors.py +13 -27
  149. provide/foundation/transport/http.py +69 -55
  150. provide/foundation/transport/middleware.py +113 -114
  151. provide/foundation/transport/registry.py +29 -27
  152. provide/foundation/transport/types.py +6 -6
  153. provide/foundation/utils/deps.py +17 -14
  154. provide/foundation/utils/parsing.py +49 -4
  155. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/METADATA +2 -2
  156. provide_foundation-0.0.0.dev3.dist-info/RECORD +233 -0
  157. provide_foundation-0.0.0.dev1.dist-info/RECORD +0 -200
  158. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  159. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  160. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/WHEEL +0 -0
  161. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/entry_points.txt +0 -0
  162. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
  163. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,208 @@
1
+ """
2
+ Fallback implementation for graceful degradation.
3
+ """
4
+
5
+ import asyncio
6
+ import functools
7
+ from typing import Callable, TypeVar
8
+
9
+ from attrs import define, field
10
+
11
+ from provide.foundation.logger import logger
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ @define(kw_only=True, slots=True)
17
+ class FallbackChain:
18
+ """Chain of fallback strategies for graceful degradation.
19
+
20
+ Executes fallback functions in order when primary function fails.
21
+ """
22
+
23
+ fallbacks: list[Callable[..., T]] = field(factory=list)
24
+ expected_exceptions: tuple[type[Exception], ...] = field(
25
+ factory=lambda: (Exception,)
26
+ )
27
+
28
+ def add_fallback(self, fallback_func: Callable[..., T]) -> None:
29
+ """Add a fallback function to the chain."""
30
+ self.fallbacks.append(fallback_func)
31
+ logger.debug(
32
+ "Added fallback to chain",
33
+ fallback_count=len(self.fallbacks),
34
+ fallback_name=getattr(fallback_func, "__name__", "anonymous"),
35
+ )
36
+
37
+ def execute(self, primary_func: Callable[..., T], *args, **kwargs) -> T:
38
+ """Execute primary function with fallback chain (sync)."""
39
+ # Try primary function first
40
+ primary_exception = None
41
+ try:
42
+ result = primary_func(*args, **kwargs)
43
+ logger.trace(
44
+ "Primary function succeeded",
45
+ func=getattr(primary_func, "__name__", "anonymous"),
46
+ )
47
+ return result
48
+ except Exception as e:
49
+ primary_exception = e
50
+ if not isinstance(e, self.expected_exceptions):
51
+ # Unexpected exception type, don't use fallbacks
52
+ logger.debug(
53
+ "Primary function failed with unexpected exception type",
54
+ exception_type=type(e).__name__,
55
+ expected_types=[t.__name__ for t in self.expected_exceptions],
56
+ )
57
+ raise
58
+
59
+ logger.warning(
60
+ "Primary function failed, trying fallbacks",
61
+ func=getattr(primary_func, "__name__", "anonymous"),
62
+ error=str(e),
63
+ fallback_count=len(self.fallbacks),
64
+ )
65
+
66
+ # Try fallbacks in order
67
+ last_exception = None
68
+ for i, fallback_func in enumerate(self.fallbacks):
69
+ try:
70
+ result = fallback_func(*args, **kwargs)
71
+ logger.info(
72
+ "Fallback succeeded",
73
+ fallback_index=i,
74
+ fallback_name=getattr(fallback_func, "__name__", "anonymous"),
75
+ )
76
+ return result
77
+ except Exception as e:
78
+ last_exception = e
79
+ logger.warning(
80
+ "Fallback failed",
81
+ fallback_index=i,
82
+ fallback_name=getattr(fallback_func, "__name__", "anonymous"),
83
+ error=str(e),
84
+ )
85
+ continue
86
+
87
+ # All fallbacks failed
88
+ logger.error(
89
+ "All fallbacks exhausted",
90
+ primary_func=getattr(primary_func, "__name__", "anonymous"),
91
+ fallback_count=len(self.fallbacks),
92
+ )
93
+
94
+ # Raise the last exception from fallbacks, or original if no fallbacks
95
+ if last_exception is not None:
96
+ raise last_exception
97
+ if primary_exception is not None:
98
+ raise primary_exception
99
+ # This should never happen but provide fallback
100
+ raise RuntimeError(
101
+ "Fallback chain execution failed with no recorded exceptions"
102
+ )
103
+
104
+ async def execute_async(self, primary_func: Callable[..., T], *args, **kwargs) -> T:
105
+ """Execute primary function with fallback chain (async)."""
106
+ # Try primary function first
107
+ primary_exception = None
108
+ try:
109
+ if asyncio.iscoroutinefunction(primary_func):
110
+ result = await primary_func(*args, **kwargs)
111
+ else:
112
+ result = primary_func(*args, **kwargs)
113
+ logger.trace(
114
+ "Primary function succeeded",
115
+ func=getattr(primary_func, "__name__", "anonymous"),
116
+ )
117
+ return result
118
+ except Exception as e:
119
+ primary_exception = e
120
+ if not isinstance(e, self.expected_exceptions):
121
+ # Unexpected exception type, don't use fallbacks
122
+ logger.debug(
123
+ "Primary function failed with unexpected exception type",
124
+ exception_type=type(e).__name__,
125
+ expected_types=[t.__name__ for t in self.expected_exceptions],
126
+ )
127
+ raise
128
+
129
+ logger.warning(
130
+ "Primary function failed, trying fallbacks",
131
+ func=getattr(primary_func, "__name__", "anonymous"),
132
+ error=str(e),
133
+ fallback_count=len(self.fallbacks),
134
+ )
135
+
136
+ # Try fallbacks in order
137
+ last_exception = None
138
+ for i, fallback_func in enumerate(self.fallbacks):
139
+ try:
140
+ if asyncio.iscoroutinefunction(fallback_func):
141
+ result = await fallback_func(*args, **kwargs)
142
+ else:
143
+ result = fallback_func(*args, **kwargs)
144
+ logger.info(
145
+ "Fallback succeeded",
146
+ fallback_index=i,
147
+ fallback_name=getattr(fallback_func, "__name__", "anonymous"),
148
+ )
149
+ return result
150
+ except Exception as e:
151
+ last_exception = e
152
+ logger.warning(
153
+ "Fallback failed",
154
+ fallback_index=i,
155
+ fallback_name=getattr(fallback_func, "__name__", "anonymous"),
156
+ error=str(e),
157
+ )
158
+ continue
159
+
160
+ # All fallbacks failed
161
+ logger.error(
162
+ "All fallbacks exhausted",
163
+ primary_func=getattr(primary_func, "__name__", "anonymous"),
164
+ fallback_count=len(self.fallbacks),
165
+ )
166
+
167
+ # Raise the last exception from fallbacks, or original if no fallbacks
168
+ if last_exception is not None:
169
+ raise last_exception
170
+ if primary_exception is not None:
171
+ raise primary_exception
172
+ # This should never happen but provide fallback
173
+ raise RuntimeError(
174
+ "Fallback chain execution failed with no recorded exceptions"
175
+ )
176
+
177
+
178
+ def fallback(*fallback_funcs: Callable[..., T]) -> Callable:
179
+ """Decorator to add fallback functions to a primary function.
180
+
181
+ Args:
182
+ *fallback_funcs: Functions to use as fallbacks, in order of preference
183
+
184
+ Returns:
185
+ Decorated function that uses fallback chain
186
+ """
187
+
188
+ def decorator(primary_func: Callable[..., T]) -> Callable[..., T]:
189
+ chain = FallbackChain()
190
+ for fallback_func in fallback_funcs:
191
+ chain.add_fallback(fallback_func)
192
+
193
+ if asyncio.iscoroutinefunction(primary_func):
194
+
195
+ @functools.wraps(primary_func)
196
+ async def async_wrapper(*args, **kwargs):
197
+ return await chain.execute_async(primary_func, *args, **kwargs)
198
+
199
+ return async_wrapper
200
+ else:
201
+
202
+ @functools.wraps(primary_func)
203
+ def sync_wrapper(*args, **kwargs):
204
+ return chain.execute(primary_func, *args, **kwargs)
205
+
206
+ return sync_wrapper
207
+
208
+ return decorator
@@ -0,0 +1,327 @@
1
+ """
2
+ Unified retry execution engine and policy configuration.
3
+
4
+ This module provides the core retry functionality used throughout foundation,
5
+ eliminating duplication between decorators and middleware.
6
+ """
7
+
8
+ import asyncio
9
+ from enum import Enum
10
+ import random
11
+ import time
12
+ from typing import Any, Callable, TypeVar
13
+
14
+ from attrs import define, field, validators
15
+
16
+ from provide.foundation.logger import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ class BackoffStrategy(str, Enum):
24
+ """Backoff strategies for retry delays."""
25
+
26
+ FIXED = "fixed" # Same delay every time
27
+ LINEAR = "linear" # Linear increase (delay * attempt)
28
+ EXPONENTIAL = "exponential" # Exponential increase (delay * 2^attempt)
29
+ FIBONACCI = "fibonacci" # Fibonacci sequence delays
30
+
31
+
32
+ @define(frozen=True, kw_only=True)
33
+ class RetryPolicy:
34
+ """
35
+ Configuration for retry behavior.
36
+
37
+ This policy can be used with both the @retry decorator and transport middleware,
38
+ providing a unified configuration model for all retry scenarios.
39
+
40
+ Attributes:
41
+ max_attempts: Maximum number of retry attempts (must be >= 1)
42
+ backoff: Backoff strategy to use for delays
43
+ base_delay: Base delay in seconds between retries
44
+ max_delay: Maximum delay in seconds (caps exponential growth)
45
+ jitter: Whether to add random jitter to delays (±25%)
46
+ retryable_errors: Tuple of exception types to retry (None = all)
47
+ retryable_status_codes: Set of HTTP status codes to retry (for middleware)
48
+ """
49
+
50
+ max_attempts: int = field(default=3, validator=validators.instance_of(int))
51
+ backoff: BackoffStrategy = field(default=BackoffStrategy.EXPONENTIAL)
52
+ base_delay: float = field(
53
+ default=1.0, validator=validators.instance_of((int, float))
54
+ )
55
+ max_delay: float = field(
56
+ default=60.0, validator=validators.instance_of((int, float))
57
+ )
58
+ jitter: bool = field(default=True)
59
+ retryable_errors: tuple[type[Exception], ...] | None = field(default=None)
60
+ retryable_status_codes: set[int] | None = field(default=None)
61
+
62
+ @max_attempts.validator
63
+ def _validate_max_attempts(self, attribute: object, value: int) -> None:
64
+ """Validate max_attempts is at least 1."""
65
+ if value < 1:
66
+ raise ValueError("max_attempts must be at least 1")
67
+
68
+ @base_delay.validator
69
+ def _validate_base_delay(self, attribute: object, value: float) -> None:
70
+ """Validate base_delay is positive."""
71
+ if value < 0:
72
+ raise ValueError("base_delay must be positive")
73
+
74
+ @max_delay.validator
75
+ def _validate_max_delay(self, attribute: object, value: float) -> None:
76
+ """Validate max_delay is positive and >= base_delay."""
77
+ if value < 0:
78
+ raise ValueError("max_delay must be positive")
79
+ if value < self.base_delay:
80
+ raise ValueError("max_delay must be >= base_delay")
81
+
82
+ def calculate_delay(self, attempt: int) -> float:
83
+ """
84
+ Calculate delay for a given attempt number.
85
+
86
+ Args:
87
+ attempt: Attempt number (1-based)
88
+
89
+ Returns:
90
+ Delay in seconds
91
+ """
92
+ if attempt <= 0:
93
+ return 0
94
+
95
+ if self.backoff == BackoffStrategy.FIXED:
96
+ delay = self.base_delay
97
+ elif self.backoff == BackoffStrategy.LINEAR:
98
+ delay = self.base_delay * attempt
99
+ elif self.backoff == BackoffStrategy.EXPONENTIAL:
100
+ delay = self.base_delay * (2 ** (attempt - 1))
101
+ elif self.backoff == BackoffStrategy.FIBONACCI:
102
+ # Calculate fibonacci number for attempt
103
+ a, b = 0, 1
104
+ for _ in range(attempt):
105
+ a, b = b, a + b
106
+ delay = self.base_delay * a
107
+ else:
108
+ delay = self.base_delay
109
+
110
+ # Cap at max delay
111
+ delay = min(delay, self.max_delay)
112
+
113
+ # Add jitter if configured (±25% random variation)
114
+ if self.jitter:
115
+ jitter_factor = 0.75 + (random.random() * 0.5)
116
+ delay *= jitter_factor
117
+
118
+ return delay
119
+
120
+ def should_retry(self, error: Exception, attempt: int) -> bool:
121
+ """
122
+ Determine if an error should be retried.
123
+
124
+ Args:
125
+ error: The exception that occurred
126
+ attempt: Current attempt number (1-based)
127
+
128
+ Returns:
129
+ True if should retry, False otherwise
130
+ """
131
+ # Check attempt limit
132
+ if attempt >= self.max_attempts:
133
+ return False
134
+
135
+ # Check error type if filter is configured
136
+ if self.retryable_errors is not None:
137
+ return isinstance(error, self.retryable_errors)
138
+
139
+ # Default to retry for any error
140
+ return True
141
+
142
+ def should_retry_response(self, response: Any, attempt: int) -> bool:
143
+ """
144
+ Check if HTTP response should be retried.
145
+
146
+ Args:
147
+ response: Response object with status attribute
148
+ attempt: Current attempt number (1-based)
149
+
150
+ Returns:
151
+ True if should retry, False otherwise
152
+ """
153
+ # Check attempt limit
154
+ if attempt >= self.max_attempts:
155
+ return False
156
+
157
+ # Check status code if configured
158
+ if self.retryable_status_codes is not None:
159
+ return getattr(response, "status", None) in self.retryable_status_codes
160
+
161
+ # Default to no retry for responses
162
+ return False
163
+
164
+ def __str__(self) -> str:
165
+ """Human-readable string representation."""
166
+ return (
167
+ f"RetryPolicy(max_attempts={self.max_attempts}, "
168
+ f"backoff={self.backoff.value}, base_delay={self.base_delay}s)"
169
+ )
170
+
171
+
172
+ class RetryExecutor:
173
+ """
174
+ Unified retry execution engine.
175
+
176
+ This executor handles the actual retry loop logic for both sync and async
177
+ functions, using a RetryPolicy for configuration. It's used internally by
178
+ both the @retry decorator and RetryMiddleware.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ policy: RetryPolicy,
184
+ on_retry: Callable[[int, Exception], None] | None = None,
185
+ ):
186
+ """
187
+ Initialize retry executor.
188
+
189
+ Args:
190
+ policy: Retry policy configuration
191
+ on_retry: Optional callback for retry events (attempt, error)
192
+ """
193
+ self.policy = policy
194
+ self.on_retry = on_retry
195
+
196
+ def execute_sync(self, func: Callable[..., T], *args, **kwargs) -> T:
197
+ """
198
+ Execute synchronous function with retry logic.
199
+
200
+ Args:
201
+ func: Function to execute
202
+ *args: Positional arguments for func
203
+ **kwargs: Keyword arguments for func
204
+
205
+ Returns:
206
+ Result from successful execution
207
+
208
+ Raises:
209
+ Last exception if all retries are exhausted
210
+ """
211
+ last_exception = None
212
+
213
+ for attempt in range(1, self.policy.max_attempts + 1):
214
+ try:
215
+ return func(*args, **kwargs)
216
+ except Exception as e:
217
+ last_exception = e
218
+
219
+ # Don't retry on last attempt - log and raise
220
+ if attempt >= self.policy.max_attempts:
221
+ logger.error(
222
+ f"All {self.policy.max_attempts} retry attempts failed",
223
+ attempts=self.policy.max_attempts,
224
+ error=str(e),
225
+ error_type=type(e).__name__,
226
+ )
227
+ raise
228
+
229
+ # Check if we should retry this error
230
+ if not self.policy.should_retry(e, attempt):
231
+ raise
232
+
233
+ # Calculate delay
234
+ delay = self.policy.calculate_delay(attempt)
235
+
236
+ # Log retry attempt
237
+ logger.info(
238
+ f"Retry {attempt}/{self.policy.max_attempts} after {delay:.2f}s",
239
+ attempt=attempt,
240
+ max_attempts=self.policy.max_attempts,
241
+ delay=delay,
242
+ error=str(e),
243
+ error_type=type(e).__name__,
244
+ )
245
+
246
+ # Call retry callback if provided
247
+ if self.on_retry:
248
+ try:
249
+ self.on_retry(attempt, e)
250
+ except Exception as callback_error:
251
+ logger.warning(
252
+ "Retry callback failed", error=str(callback_error)
253
+ )
254
+
255
+ # Wait before retry
256
+ time.sleep(delay)
257
+
258
+ # Should never reach here, but for safety
259
+ raise last_exception
260
+
261
+ async def execute_async(self, func: Callable[..., T], *args, **kwargs) -> T:
262
+ """
263
+ Execute asynchronous function with retry logic.
264
+
265
+ Args:
266
+ func: Async function to execute
267
+ *args: Positional arguments for func
268
+ **kwargs: Keyword arguments for func
269
+
270
+ Returns:
271
+ Result from successful execution
272
+
273
+ Raises:
274
+ Last exception if all retries are exhausted
275
+ """
276
+ last_exception = None
277
+
278
+ for attempt in range(1, self.policy.max_attempts + 1):
279
+ try:
280
+ return await func(*args, **kwargs)
281
+ except Exception as e:
282
+ last_exception = e
283
+
284
+ # Don't retry on last attempt - log and raise
285
+ if attempt >= self.policy.max_attempts:
286
+ logger.error(
287
+ f"All {self.policy.max_attempts} retry attempts failed",
288
+ attempts=self.policy.max_attempts,
289
+ error=str(e),
290
+ error_type=type(e).__name__,
291
+ )
292
+ raise
293
+
294
+ # Check if we should retry this error
295
+ if not self.policy.should_retry(e, attempt):
296
+ raise
297
+
298
+ # Calculate delay
299
+ delay = self.policy.calculate_delay(attempt)
300
+
301
+ # Log retry attempt
302
+ logger.info(
303
+ f"Retry {attempt}/{self.policy.max_attempts} after {delay:.2f}s",
304
+ attempt=attempt,
305
+ max_attempts=self.policy.max_attempts,
306
+ delay=delay,
307
+ error=str(e),
308
+ error_type=type(e).__name__,
309
+ )
310
+
311
+ # Call retry callback if provided
312
+ if self.on_retry:
313
+ try:
314
+ if asyncio.iscoroutinefunction(self.on_retry):
315
+ await self.on_retry(attempt, e)
316
+ else:
317
+ self.on_retry(attempt, e)
318
+ except Exception as callback_error:
319
+ logger.warning(
320
+ "Retry callback failed", error=str(callback_error)
321
+ )
322
+
323
+ # Wait before retry
324
+ await asyncio.sleep(delay)
325
+
326
+ # Should never reach here, but for safety
327
+ raise last_exception
@@ -0,0 +1,16 @@
1
+ """
2
+ Serialization utilities for Foundation.
3
+
4
+ Provides consistent serialization handling with validation,
5
+ testing support, and integration with Foundation's configuration system.
6
+ """
7
+
8
+ from provide.foundation.serialization.core import (
9
+ provide_dumps,
10
+ provide_loads,
11
+ )
12
+
13
+ __all__ = [
14
+ "provide_dumps",
15
+ "provide_loads",
16
+ ]
@@ -0,0 +1,70 @@
1
+ """Core serialization utilities for Foundation."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from provide.foundation.errors import ValidationError
7
+
8
+
9
+ def provide_dumps(
10
+ obj: Any,
11
+ *,
12
+ ensure_ascii: bool = False,
13
+ indent: int | None = None,
14
+ sort_keys: bool = False,
15
+ ) -> str:
16
+ """
17
+ Serialize object to JSON string with Foundation tracking.
18
+
19
+ Args:
20
+ obj: Object to serialize
21
+ ensure_ascii: If True, non-ASCII characters are escaped
22
+ indent: Number of spaces for indentation (None for compact)
23
+ sort_keys: Whether to sort dictionary keys
24
+
25
+ Returns:
26
+ JSON string representation
27
+
28
+ Raises:
29
+ ValidationError: If object cannot be serialized
30
+
31
+ Example:
32
+ >>> provide_dumps({"key": "value"})
33
+ '{"key": "value"}'
34
+ >>> provide_dumps({"b": 1, "a": 2}, sort_keys=True, indent=2)
35
+ '{\\n "a": 2,\\n "b": 1\\n}'
36
+ """
37
+ try:
38
+ return json.dumps(
39
+ obj, ensure_ascii=ensure_ascii, indent=indent, sort_keys=sort_keys
40
+ )
41
+ except (TypeError, ValueError) as e:
42
+ raise ValidationError(f"Cannot serialize object to JSON: {e}") from e
43
+
44
+
45
+ def provide_loads(s: str) -> Any:
46
+ """
47
+ Deserialize JSON string to Python object with Foundation tracking.
48
+
49
+ Args:
50
+ s: JSON string to deserialize
51
+
52
+ Returns:
53
+ Deserialized Python object
54
+
55
+ Raises:
56
+ ValidationError: If string is not valid JSON
57
+
58
+ Example:
59
+ >>> provide_loads('{"key": "value"}')
60
+ {'key': 'value'}
61
+ >>> provide_loads('[1, 2, 3]')
62
+ [1, 2, 3]
63
+ """
64
+ if not isinstance(s, str):
65
+ raise ValidationError("Input must be a string")
66
+
67
+ try:
68
+ return json.loads(s)
69
+ except json.JSONDecodeError as e:
70
+ raise ValidationError(f"Invalid JSON string: {e}") from e
@@ -0,0 +1,78 @@
1
+ """
2
+ Stream configuration for console output settings.
3
+
4
+ This module provides configuration for console stream behavior,
5
+ including color support and testing mode detection.
6
+ """
7
+
8
+ from attrs import define
9
+
10
+ from provide.foundation.config.base import field
11
+ from provide.foundation.config.converters import parse_bool_extended
12
+ from provide.foundation.config.env import RuntimeConfig
13
+
14
+
15
+ @define(slots=True, repr=False)
16
+ class StreamConfig(RuntimeConfig):
17
+ """Configuration for console stream output behavior."""
18
+
19
+ no_color: bool = field(
20
+ default=False,
21
+ env_var="NO_COLOR",
22
+ converter=parse_bool_extended,
23
+ description="Disable color output in console",
24
+ )
25
+
26
+ force_color: bool = field(
27
+ default=False,
28
+ env_var="FORCE_COLOR",
29
+ converter=parse_bool_extended,
30
+ description="Force color output even when not in TTY",
31
+ )
32
+
33
+ click_testing: bool = field(
34
+ default=False,
35
+ env_var="CLICK_TESTING",
36
+ converter=parse_bool_extended,
37
+ description="Indicates if running inside Click testing framework",
38
+ )
39
+
40
+ def supports_color(self) -> bool:
41
+ """
42
+ Determine if the console supports color output.
43
+
44
+ Returns:
45
+ True if color is supported, False otherwise
46
+ """
47
+ if self.no_color:
48
+ return False
49
+
50
+ if self.force_color:
51
+ return True
52
+
53
+ # Additional logic for TTY detection would go here
54
+ # For now, just return based on the flags
55
+ return not self.no_color
56
+
57
+
58
+ # Global instance for easy access
59
+ _stream_config: StreamConfig | None = None
60
+
61
+
62
+ def get_stream_config() -> StreamConfig:
63
+ """
64
+ Get the global stream configuration instance.
65
+
66
+ Returns:
67
+ StreamConfig instance loaded from environment
68
+ """
69
+ global _stream_config
70
+ if _stream_config is None:
71
+ _stream_config = StreamConfig.from_env()
72
+ return _stream_config
73
+
74
+
75
+ def reset_stream_config() -> None:
76
+ """Reset the global stream configuration (mainly for testing)."""
77
+ global _stream_config
78
+ _stream_config = None