reflectapi-runtime 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,120 @@
1
+ """Exception hierarchy for ReflectAPI Python clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx # noqa: TC002
8
+
9
+ from .response import TransportMetadata # noqa: TC001
10
+
11
+
12
+ class ApiError(Exception):
13
+ """Base class for all API-related errors.
14
+
15
+ Always contains transport metadata when available, providing access to
16
+ status codes, headers, timing, and the raw response.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ metadata: TransportMetadata | None = None,
24
+ cause: Exception | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.metadata = metadata
28
+ self.cause = cause
29
+
30
+ @property
31
+ def status_code(self) -> int | None:
32
+ """HTTP status code if available."""
33
+ return self.metadata.status_code if self.metadata else None
34
+
35
+ def __repr__(self) -> str:
36
+ parts = [f"message={self.args[0]!r}"]
37
+ if self.metadata:
38
+ parts.append(f"status_code={self.metadata.status_code}")
39
+ if self.cause:
40
+ parts.append(f"cause={self.cause!r}")
41
+ return f"{self.__class__.__name__}({', '.join(parts)})"
42
+
43
+
44
+ class NetworkError(ApiError):
45
+ """Network-level errors (connection failures, DNS resolution, etc.)."""
46
+
47
+ @classmethod
48
+ def from_httpx_error(cls, error: httpx.RequestError) -> NetworkError:
49
+ """Create a NetworkError from an httpx RequestError."""
50
+ return cls(
51
+ f"Network error: {error}",
52
+ cause=error,
53
+ )
54
+
55
+
56
+ class TimeoutError(NetworkError):
57
+ """Request timeout errors."""
58
+
59
+ @classmethod
60
+ def from_httpx_timeout(cls, error: httpx.TimeoutException) -> TimeoutError:
61
+ """Create a TimeoutError from an httpx TimeoutException."""
62
+ return cls(
63
+ f"Request timed out: {error}",
64
+ cause=error,
65
+ )
66
+
67
+
68
+ class ApplicationError(ApiError):
69
+ """Application-level errors (4xx and 5xx HTTP responses).
70
+
71
+ These represent errors returned by the API server, as opposed to
72
+ network or client-side validation errors.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ *,
79
+ metadata: TransportMetadata,
80
+ error_data: Any | None = None,
81
+ ) -> None:
82
+ super().__init__(message, metadata=metadata)
83
+ self.error_data = error_data
84
+
85
+ @property
86
+ def status_code(self) -> int:
87
+ """Get the HTTP status code from metadata."""
88
+ return self.metadata.status_code if self.metadata else 0
89
+
90
+ @classmethod
91
+ def from_response(
92
+ cls,
93
+ response: httpx.Response,
94
+ metadata: TransportMetadata,
95
+ error_data: Any | None = None,
96
+ ) -> ApplicationError:
97
+ """Create an ApplicationError from an HTTP response."""
98
+ message = f"API error {response.status_code}: {response.reason_phrase}"
99
+ if error_data:
100
+ message += f" - {error_data}"
101
+
102
+ return cls(
103
+ message,
104
+ metadata=metadata,
105
+ error_data=error_data,
106
+ )
107
+
108
+
109
+ class ValidationError(ApiError):
110
+ """Client-side validation errors (malformed requests, invalid data, etc.)."""
111
+
112
+ def __init__(
113
+ self,
114
+ message: str,
115
+ *,
116
+ validation_errors: list[Any] | None = None,
117
+ cause: Exception | None = None,
118
+ ) -> None:
119
+ super().__init__(message, cause=cause)
120
+ self.validation_errors = validation_errors or []
@@ -0,0 +1,275 @@
1
+ """Hypothesis strategies for generated Pydantic models.
2
+
3
+ This module provides utilities to automatically generate Hypothesis strategies
4
+ for ReflectAPI-generated Pydantic models, enabling property-based testing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import uuid
11
+ from typing import Any, Union, get_args, get_origin
12
+
13
+ try:
14
+ import hypothesis
15
+ from hypothesis import strategies as st
16
+ HAS_HYPOTHESIS = True
17
+ except ImportError:
18
+ HAS_HYPOTHESIS = False
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from .option import ReflectapiOption, Undefined
23
+
24
+ if HAS_HYPOTHESIS:
25
+
26
+ def strategy_for_option(inner_strategy: st.SearchStrategy) -> st.SearchStrategy:
27
+ """Create a strategy for ReflectapiOption types.
28
+
29
+ Args:
30
+ inner_strategy: Strategy for the inner type T in Option<T>.
31
+
32
+ Returns:
33
+ Strategy that produces ReflectapiOption instances with undefined, None, or values.
34
+ """
35
+ return st.one_of(
36
+ st.just(ReflectapiOption(Undefined)), # Undefined case
37
+ st.just(ReflectapiOption(None)), # None case
38
+ inner_strategy.map(ReflectapiOption), # Some(value) case
39
+ )
40
+
41
+
42
+ def strategy_for_type(type_hint: type) -> st.SearchStrategy:
43
+ """Generate a Hypothesis strategy for a given type hint.
44
+
45
+ This function maps Python types to appropriate Hypothesis strategies,
46
+ with special handling for ReflectAPI types.
47
+
48
+ Args:
49
+ type_hint: The type to generate a strategy for.
50
+
51
+ Returns:
52
+ Hypothesis strategy for the given type.
53
+ """
54
+ # Handle basic types
55
+ if type_hint is str:
56
+ return st.text()
57
+ elif type_hint is int:
58
+ return st.integers()
59
+ elif type_hint is float:
60
+ return st.floats(allow_nan=False, allow_infinity=False)
61
+ elif type_hint is bool:
62
+ return st.booleans()
63
+ elif type_hint is bytes:
64
+ return st.binary()
65
+ elif type_hint is datetime.datetime:
66
+ return st.datetimes()
67
+ elif type_hint is datetime.date:
68
+ return st.dates()
69
+ elif type_hint is uuid.UUID:
70
+ return st.uuids()
71
+
72
+ # Handle ReflectapiOption specifically
73
+ if hasattr(type_hint, '__origin__') and type_hint.__origin__ is ReflectapiOption:
74
+ inner_type = get_args(type_hint)[0] if get_args(type_hint) else Any
75
+ inner_strategy = strategy_for_type(inner_type)
76
+ return strategy_for_option(inner_strategy)
77
+
78
+ # Handle generic types
79
+ origin = get_origin(type_hint)
80
+ args = get_args(type_hint)
81
+
82
+ if origin is Union:
83
+ # Handle Optional[T] (Union[T, None]) and other unions
84
+ if len(args) == 2 and type(None) in args:
85
+ # Optional type
86
+ non_none_type = args[0] if args[1] is type(None) else args[1]
87
+ return st.one_of(st.none(), strategy_for_type(non_none_type))
88
+ else:
89
+ # General union
90
+ return st.one_of(*[strategy_for_type(arg) for arg in args])
91
+
92
+ elif origin is list:
93
+ inner_type = args[0] if args else Any
94
+ return st.lists(strategy_for_type(inner_type), max_size=10)
95
+
96
+ elif origin is dict:
97
+ key_type = args[0] if len(args) > 0 else str
98
+ value_type = args[1] if len(args) > 1 else Any
99
+ return st.dictionaries(
100
+ strategy_for_type(key_type),
101
+ strategy_for_type(value_type),
102
+ max_size=10
103
+ )
104
+
105
+ elif origin is tuple:
106
+ if args:
107
+ return st.tuples(*[strategy_for_type(arg) for arg in args])
108
+ else:
109
+ return st.tuples()
110
+
111
+ # Handle Pydantic models
112
+ if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
113
+ return strategy_for_pydantic_model(type_hint)
114
+
115
+ # Fallback for unknown types
116
+ return st.none()
117
+
118
+
119
+ def strategy_for_pydantic_model(model_class: type[BaseModel]) -> st.SearchStrategy:
120
+ """Generate a Hypothesis strategy for a Pydantic model.
121
+
122
+ This creates a strategy that generates valid instances of the given
123
+ Pydantic model class, respecting field types and constraints.
124
+
125
+ Args:
126
+ model_class: The Pydantic model class to generate strategies for.
127
+
128
+ Returns:
129
+ Strategy that produces instances of the model class.
130
+ """
131
+ field_strategies = {}
132
+
133
+ # Handle Pydantic models
134
+ if hasattr(model_class, 'model_fields'):
135
+ for field_name, field_info in model_class.model_fields.items():
136
+ field_type = field_info.annotation
137
+ field_strategies[field_name] = strategy_for_type(field_type)
138
+ else:
139
+ raise ValueError(f"Unsupported Pydantic model: {model_class}. Only Pydantic V2 models are supported.")
140
+
141
+ # Create a strategy that builds the model
142
+ return st.builds(model_class, **field_strategies)
143
+
144
+
145
+ def register_custom_strategy(type_hint: type, strategy: st.SearchStrategy) -> None:
146
+ """Register a custom strategy for a specific type.
147
+
148
+ This allows users to override the default strategy generation
149
+ for specific types or add support for custom types.
150
+
151
+ Args:
152
+ type_hint: The type to register a strategy for.
153
+ strategy: The Hypothesis strategy to use for this type.
154
+ """
155
+ if not hasattr(register_custom_strategy, '_custom_strategies'):
156
+ register_custom_strategy._custom_strategies = {}
157
+ register_custom_strategy._custom_strategies[type_hint] = strategy
158
+
159
+
160
+ def get_custom_strategy(type_hint: type) -> st.SearchStrategy | None:
161
+ """Get a custom strategy for a type, if registered.
162
+
163
+ Args:
164
+ type_hint: The type to look up.
165
+
166
+ Returns:
167
+ Custom strategy if registered, None otherwise.
168
+ """
169
+ if hasattr(register_custom_strategy, '_custom_strategies'):
170
+ return register_custom_strategy._custom_strategies.get(type_hint)
171
+ return None
172
+
173
+
174
+ def enhanced_strategy_for_type(type_hint: type) -> st.SearchStrategy:
175
+ """Enhanced strategy generation with custom strategy support.
176
+
177
+ This is the main entry point that checks for custom strategies
178
+ before falling back to the default generation logic.
179
+
180
+ Args:
181
+ type_hint: The type to generate a strategy for.
182
+
183
+ Returns:
184
+ Hypothesis strategy for the given type.
185
+ """
186
+ # Check for custom strategies first
187
+ custom_strategy = get_custom_strategy(type_hint)
188
+ if custom_strategy is not None:
189
+ return custom_strategy
190
+
191
+ # Fall back to default logic
192
+ return strategy_for_type(type_hint)
193
+
194
+
195
+ # Convenience function for common patterns
196
+ def api_model_strategy(
197
+ model_class: type[BaseModel],
198
+ **field_overrides: st.SearchStrategy
199
+ ) -> st.SearchStrategy:
200
+ """Create a strategy for an API model with field overrides.
201
+
202
+ This is useful when you want to use the default strategy for most fields
203
+ but customize specific fields for testing scenarios.
204
+
205
+ Args:
206
+ model_class: The Pydantic model class.
207
+ **field_overrides: Custom strategies for specific fields.
208
+
209
+ Returns:
210
+ Strategy for the model with custom field strategies applied.
211
+
212
+ Example:
213
+ ```python
214
+ # Override the 'id' field to use positive integers
215
+ strategy = api_model_strategy(
216
+ UserModel,
217
+ id=st.integers(min_value=1),
218
+ email=st.emails()
219
+ )
220
+ ```
221
+ """
222
+ field_strategies = {}
223
+
224
+ # Get default strategies for all fields
225
+ if hasattr(model_class, 'model_fields'):
226
+ # Pydantic models
227
+ for field_name, field_info in model_class.model_fields.items():
228
+ if field_name in field_overrides:
229
+ field_strategies[field_name] = field_overrides[field_name]
230
+ else:
231
+ field_strategies[field_name] = enhanced_strategy_for_type(field_info.annotation)
232
+ else:
233
+ raise ValueError(f"Unsupported Pydantic model: {model_class}. Only Pydantic V2 models are supported.")
234
+
235
+ return st.builds(model_class, **field_strategies)
236
+
237
+
238
+ else:
239
+ # Hypothesis not available - provide stub implementations
240
+
241
+ def strategy_for_option(inner_strategy):
242
+ """Stub implementation when Hypothesis is not available."""
243
+ raise ImportError("Hypothesis is required for strategy generation")
244
+
245
+ def strategy_for_type(type_hint):
246
+ """Stub implementation when Hypothesis is not available."""
247
+ raise ImportError("Hypothesis is required for strategy generation")
248
+
249
+ def strategy_for_pydantic_model(model_class):
250
+ """Stub implementation when Hypothesis is not available."""
251
+ raise ImportError("Hypothesis is required for strategy generation")
252
+
253
+ def register_custom_strategy(type_hint, strategy):
254
+ """Stub implementation when Hypothesis is not available."""
255
+ raise ImportError("Hypothesis is required for strategy generation")
256
+
257
+ def enhanced_strategy_for_type(type_hint):
258
+ """Stub implementation when Hypothesis is not available."""
259
+ raise ImportError("Hypothesis is required for strategy generation")
260
+
261
+ def api_model_strategy(model_class, **field_overrides):
262
+ """Stub implementation when Hypothesis is not available."""
263
+ raise ImportError("Hypothesis is required for strategy generation")
264
+
265
+
266
+ # Export the main functions
267
+ __all__ = [
268
+ 'HAS_HYPOTHESIS',
269
+ 'strategy_for_option',
270
+ 'strategy_for_type',
271
+ 'strategy_for_pydantic_model',
272
+ 'enhanced_strategy_for_type',
273
+ 'register_custom_strategy',
274
+ 'api_model_strategy',
275
+ ]
@@ -0,0 +1,254 @@
1
+ """Middleware system for ReflectAPI Python clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Union
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Type aliases for handlers in the middleware chain
17
+ AsyncNextHandler = Callable[[httpx.Request], Awaitable[httpx.Response]]
18
+ SyncNextHandler = Callable[[httpx.Request], httpx.Response]
19
+ NextHandler = Union[AsyncNextHandler, SyncNextHandler]
20
+
21
+
22
+ class AsyncMiddleware(ABC):
23
+ """Base class for asynchronous client middleware.
24
+
25
+ Middleware can intercept and transform requests and responses,
26
+ enabling cross-cutting concerns like caching, logging, retry logic, etc.
27
+ """
28
+
29
+ @abstractmethod
30
+ async def handle(
31
+ self, request: httpx.Request, next_call: AsyncNextHandler
32
+ ) -> httpx.Response:
33
+ """Handle a request and return a response.
34
+
35
+ Args:
36
+ request: The HTTP request to process
37
+ next_call: Async callable to continue the middleware chain
38
+
39
+ Returns:
40
+ The HTTP response (possibly modified)
41
+ """
42
+ pass
43
+
44
+
45
+ class SyncMiddleware(ABC):
46
+ """Base class for synchronous client middleware.
47
+
48
+ Middleware can intercept and transform requests and responses,
49
+ enabling cross-cutting concerns like caching, logging, retry logic, etc.
50
+ """
51
+
52
+ @abstractmethod
53
+ def handle(
54
+ self, request: httpx.Request, next_call: SyncNextHandler
55
+ ) -> httpx.Response:
56
+ """Handle a request and return a response.
57
+
58
+ Args:
59
+ request: The HTTP request to process
60
+ next_call: Callable to continue the middleware chain
61
+
62
+ Returns:
63
+ The HTTP response (possibly modified)
64
+ """
65
+ pass
66
+
67
+
68
+ class AsyncLoggingMiddleware(AsyncMiddleware):
69
+ """Async middleware that logs request and response information."""
70
+
71
+ def __init__(self, logger_name: str = "reflectapi.client") -> None:
72
+ self.logger = logging.getLogger(logger_name)
73
+
74
+ async def handle(
75
+ self, request: httpx.Request, next_call: AsyncNextHandler
76
+ ) -> httpx.Response:
77
+ """Log request and response details."""
78
+ self.logger.debug(
79
+ "Making request",
80
+ extra={
81
+ "method": request.method,
82
+ "url": str(request.url),
83
+ "headers": dict(request.headers),
84
+ },
85
+ )
86
+
87
+ response = await next_call(request)
88
+
89
+ self.logger.debug(
90
+ "Received response",
91
+ extra={
92
+ "status_code": response.status_code,
93
+ "headers": dict(response.headers),
94
+ "url": str(request.url),
95
+ },
96
+ )
97
+
98
+ return response
99
+
100
+
101
+ class SyncLoggingMiddleware(SyncMiddleware):
102
+ """Sync middleware that logs request and response information."""
103
+
104
+ def __init__(self, logger_name: str = "reflectapi.client") -> None:
105
+ self.logger = logging.getLogger(logger_name)
106
+
107
+ def handle(
108
+ self, request: httpx.Request, next_call: SyncNextHandler
109
+ ) -> httpx.Response:
110
+ """Log request and response details."""
111
+ self.logger.debug(
112
+ "Making request",
113
+ extra={
114
+ "method": request.method,
115
+ "url": str(request.url),
116
+ "headers": dict(request.headers),
117
+ },
118
+ )
119
+
120
+ response = next_call(request)
121
+
122
+ self.logger.debug(
123
+ "Received response",
124
+ extra={
125
+ "status_code": response.status_code,
126
+ "headers": dict(response.headers),
127
+ "url": str(request.url),
128
+ },
129
+ )
130
+
131
+ return response
132
+
133
+
134
+ class RetryMiddleware(AsyncMiddleware):
135
+ """Middleware that retries requests on transient failures with exponential backoff and jitter."""
136
+
137
+ # Methods that are safe to retry automatically on network failures
138
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
139
+
140
+ def __init__(
141
+ self,
142
+ max_retries: int = 3,
143
+ retry_status_codes: set[int] | None = None,
144
+ backoff_factor: float = 0.5, # Start with a shorter backoff
145
+ ) -> None:
146
+ self.max_retries = max_retries
147
+ self.retry_status_codes = retry_status_codes or {429, 502, 503, 504}
148
+ self.backoff_factor = backoff_factor
149
+
150
+ async def handle(
151
+ self, request: httpx.Request, next_call: AsyncNextHandler
152
+ ) -> httpx.Response:
153
+ """Retry requests on transient failures."""
154
+ last_exception = None
155
+
156
+ for attempt in range(self.max_retries + 1):
157
+ try:
158
+ response = await next_call(request)
159
+
160
+ if attempt == self.max_retries or response.status_code not in self.retry_status_codes:
161
+ return response
162
+
163
+ # If we are going to retry, store the response as the last exception
164
+ last_exception = response
165
+
166
+ except httpx.RequestError as e:
167
+ last_exception = e
168
+ # Do not retry non-idempotent methods on network errors
169
+ if request.method not in self.IDEMPOTENT_METHODS or attempt == self.max_retries:
170
+ raise
171
+
172
+ # Backoff with Jitter (AWS-recommended approach)
173
+ # Cap the backoff at 30 seconds and add jitter for better distribution
174
+ temp = min(self.backoff_factor * (2 ** attempt), 30.0) # Cap backoff
175
+ sleep_duration = temp / 2 + random.uniform(0, temp / 2)
176
+
177
+ logger.debug(
178
+ f"Retrying request to {request.url} after {sleep_duration:.2f}s (attempt {attempt + 1}/{self.max_retries})"
179
+ )
180
+ await asyncio.sleep(sleep_duration)
181
+
182
+ # This part should be unreachable, but linters might complain.
183
+ # It's better to re-raise the last known exception.
184
+ if isinstance(last_exception, httpx.Response):
185
+ return last_exception
186
+ raise last_exception from None
187
+
188
+
189
+ class AsyncMiddlewareChain:
190
+ """Manages a chain of async middleware for processing requests."""
191
+
192
+ def __init__(self, middleware: list[AsyncMiddleware]) -> None:
193
+ self.middleware = middleware
194
+
195
+ async def execute(
196
+ self,
197
+ request: httpx.Request,
198
+ transport: httpx.AsyncClient,
199
+ ) -> httpx.Response:
200
+ """Execute the middleware chain with the given request."""
201
+
202
+ async def create_handler(
203
+ middleware_list: list[AsyncMiddleware], index: int
204
+ ) -> AsyncNextHandler:
205
+ """Create a handler for the middleware at the given index."""
206
+
207
+ async def handler(req: httpx.Request) -> httpx.Response:
208
+ if index >= len(middleware_list):
209
+ # End of chain - make the actual HTTP request
210
+ return await transport.send(req)
211
+
212
+ # Process through next middleware
213
+ next_handler = await create_handler(middleware_list, index + 1)
214
+ return await middleware_list[index].handle(req, next_handler)
215
+
216
+ return handler
217
+
218
+ handler = await create_handler(self.middleware, 0)
219
+ return await handler(request)
220
+
221
+
222
+ class SyncMiddlewareChain:
223
+ """Manages a chain of sync middleware for processing requests."""
224
+
225
+ def __init__(self, middleware: list[SyncMiddleware]) -> None:
226
+ self.middleware = middleware
227
+
228
+ def execute(
229
+ self,
230
+ request: httpx.Request,
231
+ transport: httpx.Client,
232
+ ) -> httpx.Response:
233
+ """Execute the middleware chain with the given request."""
234
+
235
+ def create_handler(
236
+ middleware_list: list[SyncMiddleware], index: int
237
+ ) -> SyncNextHandler:
238
+ """Create a handler for the middleware at the given index."""
239
+
240
+ def handler(req: httpx.Request) -> httpx.Response:
241
+ if index >= len(middleware_list):
242
+ # End of chain - make the actual HTTP request
243
+ return transport.send(req)
244
+
245
+ # Process through next middleware
246
+ next_handler = create_handler(middleware_list, index + 1)
247
+ return middleware_list[index].handle(req, next_handler)
248
+
249
+ return handler
250
+
251
+ handler = create_handler(self.middleware, 0)
252
+ return handler(request)
253
+
254
+