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.
- reflectapi_runtime/__init__.py +109 -0
- reflectapi_runtime/auth.py +507 -0
- reflectapi_runtime/batch.py +165 -0
- reflectapi_runtime/client.py +924 -0
- reflectapi_runtime/exceptions.py +120 -0
- reflectapi_runtime/hypothesis_strategies.py +275 -0
- reflectapi_runtime/middleware.py +254 -0
- reflectapi_runtime/option.py +295 -0
- reflectapi_runtime/response.py +126 -0
- reflectapi_runtime/streaming.py +435 -0
- reflectapi_runtime/testing.py +380 -0
- reflectapi_runtime/types.py +32 -0
- reflectapi_runtime-0.1.0.dist-info/METADATA +36 -0
- reflectapi_runtime-0.1.0.dist-info/RECORD +15 -0
- reflectapi_runtime-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Streaming response handling for ReflectAPI Python clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .auth import AuthHandler
|
|
12
|
+
from .exceptions import ApplicationError, NetworkError, TimeoutError
|
|
13
|
+
from .middleware import AsyncMiddleware, AsyncMiddlewareChain
|
|
14
|
+
from .option import serialize_option_dict
|
|
15
|
+
from .response import TransportMetadata
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StreamingResponse:
|
|
24
|
+
"""Wrapper for streaming HTTP responses."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, response: httpx.Response, metadata: TransportMetadata):
|
|
27
|
+
self.response = response
|
|
28
|
+
self.metadata = metadata
|
|
29
|
+
self._closed = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def status_code(self) -> int:
|
|
33
|
+
"""HTTP status code."""
|
|
34
|
+
return self.response.status_code
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def headers(self) -> httpx.Headers:
|
|
38
|
+
"""Response headers."""
|
|
39
|
+
return self.response.headers
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def content_type(self) -> str | None:
|
|
43
|
+
"""Content-Type header value."""
|
|
44
|
+
return self.response.headers.get("content-type")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def content_length(self) -> int | None:
|
|
48
|
+
"""Content-Length header value if present."""
|
|
49
|
+
try:
|
|
50
|
+
length = self.response.headers.get("content-length")
|
|
51
|
+
return int(length) if length else None
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_closed(self) -> bool:
|
|
57
|
+
"""Check if the response stream is closed."""
|
|
58
|
+
return self._closed or self.response.is_closed
|
|
59
|
+
|
|
60
|
+
async def aiter_bytes(self, chunk_size: int = 8192) -> AsyncIterator[bytes]:
|
|
61
|
+
"""Iterate over response content as bytes chunks."""
|
|
62
|
+
if self.is_closed:
|
|
63
|
+
raise RuntimeError("Cannot iterate over closed response")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
async for chunk in self.response.aiter_bytes(chunk_size):
|
|
67
|
+
yield chunk
|
|
68
|
+
finally:
|
|
69
|
+
await self.aclose()
|
|
70
|
+
|
|
71
|
+
async def aiter_text(self, chunk_size: int = 8192, encoding: str | None = None) -> AsyncIterator[str]:
|
|
72
|
+
"""Iterate over response content as text chunks."""
|
|
73
|
+
if self.is_closed:
|
|
74
|
+
raise RuntimeError("Cannot iterate over closed response")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
async for chunk in self.response.aiter_text(chunk_size, encoding):
|
|
78
|
+
yield chunk
|
|
79
|
+
finally:
|
|
80
|
+
await self.aclose()
|
|
81
|
+
|
|
82
|
+
async def aiter_lines(self, chunk_size: int = 8192, encoding: str | None = None) -> AsyncIterator[str]:
|
|
83
|
+
"""Iterate over response content line by line."""
|
|
84
|
+
if self.is_closed:
|
|
85
|
+
raise RuntimeError("Cannot iterate over closed response")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
async for line in self.response.aiter_lines(chunk_size):
|
|
89
|
+
if encoding:
|
|
90
|
+
yield line.decode(encoding) if isinstance(line, bytes) else line
|
|
91
|
+
else:
|
|
92
|
+
yield line
|
|
93
|
+
finally:
|
|
94
|
+
await self.aclose()
|
|
95
|
+
|
|
96
|
+
async def save_to_file(self, file_path: str, chunk_size: int = 8192) -> int:
|
|
97
|
+
"""Save streaming content directly to a file.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
file_path: Path where to save the file
|
|
101
|
+
chunk_size: Size of chunks to read/write
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Number of bytes written
|
|
105
|
+
"""
|
|
106
|
+
if self.is_closed:
|
|
107
|
+
raise RuntimeError("Cannot save closed response")
|
|
108
|
+
|
|
109
|
+
bytes_written = 0
|
|
110
|
+
try:
|
|
111
|
+
with open(file_path, 'wb') as f:
|
|
112
|
+
async for chunk in self.aiter_bytes(chunk_size):
|
|
113
|
+
f.write(chunk)
|
|
114
|
+
bytes_written += len(chunk)
|
|
115
|
+
except Exception:
|
|
116
|
+
# Clean up partial file on error
|
|
117
|
+
import os
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(file_path)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
return bytes_written
|
|
125
|
+
|
|
126
|
+
async def read_all(self) -> bytes:
|
|
127
|
+
"""Read all content into memory. Use with caution for large responses."""
|
|
128
|
+
if self.is_closed:
|
|
129
|
+
raise RuntimeError("Cannot read closed response")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return await self.response.aread()
|
|
133
|
+
finally:
|
|
134
|
+
await self.aclose()
|
|
135
|
+
|
|
136
|
+
async def aclose(self) -> None:
|
|
137
|
+
"""Close the response stream."""
|
|
138
|
+
if not self._closed:
|
|
139
|
+
await self.response.aclose()
|
|
140
|
+
self._closed = True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AsyncStreamingClient:
|
|
144
|
+
"""Async client specialized for streaming responses."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
base_url: str,
|
|
149
|
+
*,
|
|
150
|
+
timeout: float | None = 120.0, # Longer default timeout for streaming
|
|
151
|
+
headers: dict[str, str] | None = None,
|
|
152
|
+
middleware: list[AsyncMiddleware] | None = None,
|
|
153
|
+
auth: AuthHandler | httpx.Auth | None = None,
|
|
154
|
+
client: httpx.AsyncClient | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self.base_url = base_url.rstrip("/")
|
|
157
|
+
self.middleware_chain = AsyncMiddlewareChain(middleware or [])
|
|
158
|
+
self.auth = auth
|
|
159
|
+
|
|
160
|
+
# Use provided client or create a new one
|
|
161
|
+
if client is not None:
|
|
162
|
+
self._client = client
|
|
163
|
+
self._owns_client = False
|
|
164
|
+
else:
|
|
165
|
+
# Handle authentication for async client
|
|
166
|
+
auth_param = None
|
|
167
|
+
if isinstance(auth, AuthHandler):
|
|
168
|
+
# AuthHandler now inherits from httpx.Auth directly
|
|
169
|
+
auth_param = auth
|
|
170
|
+
elif auth is not None:
|
|
171
|
+
# Use httpx built-in auth directly
|
|
172
|
+
auth_param = auth
|
|
173
|
+
|
|
174
|
+
self._client = httpx.AsyncClient(
|
|
175
|
+
base_url=self.base_url,
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
headers=headers or {},
|
|
178
|
+
auth=auth_param,
|
|
179
|
+
# Enable response streaming
|
|
180
|
+
follow_redirects=True,
|
|
181
|
+
)
|
|
182
|
+
self._owns_client = True
|
|
183
|
+
|
|
184
|
+
async def __aenter__(self) -> AsyncStreamingClient:
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
188
|
+
await self.aclose()
|
|
189
|
+
|
|
190
|
+
async def aclose(self) -> None:
|
|
191
|
+
"""Close the underlying HTTP client if we own it."""
|
|
192
|
+
if self._owns_client:
|
|
193
|
+
await self._client.aclose()
|
|
194
|
+
|
|
195
|
+
@asynccontextmanager
|
|
196
|
+
async def stream_request(
|
|
197
|
+
self,
|
|
198
|
+
method: str,
|
|
199
|
+
path: str,
|
|
200
|
+
*,
|
|
201
|
+
params: dict[str, Any] | None = None,
|
|
202
|
+
json_data: dict[str, Any] | None = None,
|
|
203
|
+
json_model: BaseModel | None = None,
|
|
204
|
+
headers: dict[str, str] | None = None,
|
|
205
|
+
) -> AsyncIterator[StreamingResponse]:
|
|
206
|
+
"""Make a streaming HTTP request within an async context manager.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
method: HTTP method (GET, POST, etc.)
|
|
210
|
+
path: API endpoint path
|
|
211
|
+
params: Query parameters
|
|
212
|
+
json_data: JSON data to send (mutually exclusive with json_model)
|
|
213
|
+
json_model: Pydantic model to serialize and send
|
|
214
|
+
headers: Additional headers
|
|
215
|
+
|
|
216
|
+
Yields:
|
|
217
|
+
StreamingResponse: Streaming response object
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
```python
|
|
221
|
+
async with client.stream_request("GET", "/large-file") as response:
|
|
222
|
+
async for chunk in response.aiter_bytes():
|
|
223
|
+
process(chunk)
|
|
224
|
+
```
|
|
225
|
+
"""
|
|
226
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
227
|
+
start_time = time.time()
|
|
228
|
+
|
|
229
|
+
# Handle request body serialization
|
|
230
|
+
if json_model is not None and json_data is not None:
|
|
231
|
+
raise ValueError("Cannot specify both json_data and json_model")
|
|
232
|
+
|
|
233
|
+
request_headers = headers.copy() if headers else {}
|
|
234
|
+
|
|
235
|
+
# Serialize Pydantic model
|
|
236
|
+
if json_model is not None:
|
|
237
|
+
# Use Pydantic with improved ReflectapiOption serialization
|
|
238
|
+
model_dict = json_model.model_dump(exclude_none=False) # Keep explicit None
|
|
239
|
+
|
|
240
|
+
# Filter out Undefined values (ReflectapiOption serializer returns Undefined sentinel)
|
|
241
|
+
from .option import Undefined
|
|
242
|
+
final_dict = {
|
|
243
|
+
k: v for k, v in model_dict.items()
|
|
244
|
+
if not (hasattr(v, '__class__') and v is Undefined)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
import json
|
|
248
|
+
content = json.dumps(final_dict, separators=(',', ':')).encode('utf-8')
|
|
249
|
+
request_headers["Content-Type"] = "application/json"
|
|
250
|
+
|
|
251
|
+
# Build request with raw content
|
|
252
|
+
request = self._client.build_request(
|
|
253
|
+
method=method,
|
|
254
|
+
url=url,
|
|
255
|
+
params=params,
|
|
256
|
+
content=content,
|
|
257
|
+
headers=request_headers,
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
# Build request with JSON data - assume V2 processed
|
|
261
|
+
processed_json_data = json_data
|
|
262
|
+
|
|
263
|
+
request = self._client.build_request(
|
|
264
|
+
method=method,
|
|
265
|
+
url=url,
|
|
266
|
+
params=params,
|
|
267
|
+
json=processed_json_data,
|
|
268
|
+
headers=request_headers,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
response = None
|
|
272
|
+
try:
|
|
273
|
+
# Execute through middleware chain
|
|
274
|
+
if self.middleware_chain.middleware:
|
|
275
|
+
response = await self.middleware_chain.execute(request, self._client)
|
|
276
|
+
else:
|
|
277
|
+
response = await self._client.send(request, stream=True)
|
|
278
|
+
|
|
279
|
+
metadata = TransportMetadata.from_response(response, start_time)
|
|
280
|
+
|
|
281
|
+
# Handle error responses
|
|
282
|
+
if response.status_code >= 400:
|
|
283
|
+
# For streaming responses, we need to read a limited amount to get error details
|
|
284
|
+
error_data = None
|
|
285
|
+
try:
|
|
286
|
+
# Read up to 1MB for error details
|
|
287
|
+
error_content = await response.aread()
|
|
288
|
+
if len(error_content) <= 1024 * 1024: # 1MB limit
|
|
289
|
+
error_data = response.json() if error_content else None
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
raise ApplicationError.from_response(response, metadata, error_data)
|
|
294
|
+
|
|
295
|
+
# Yield the streaming response
|
|
296
|
+
streaming_response = StreamingResponse(response, metadata)
|
|
297
|
+
try:
|
|
298
|
+
yield streaming_response
|
|
299
|
+
finally:
|
|
300
|
+
# Ensure cleanup even if user doesn't call aclose()
|
|
301
|
+
await streaming_response.aclose()
|
|
302
|
+
|
|
303
|
+
except httpx.TimeoutException as e:
|
|
304
|
+
if response:
|
|
305
|
+
await response.aclose()
|
|
306
|
+
raise TimeoutError.from_httpx_timeout(e)
|
|
307
|
+
except httpx.RequestError as e:
|
|
308
|
+
if response:
|
|
309
|
+
await response.aclose()
|
|
310
|
+
raise NetworkError.from_httpx_error(e)
|
|
311
|
+
except Exception:
|
|
312
|
+
# Cleanup on any other exception
|
|
313
|
+
if response:
|
|
314
|
+
await response.aclose()
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
@asynccontextmanager
|
|
318
|
+
async def download_file(
|
|
319
|
+
self,
|
|
320
|
+
path: str,
|
|
321
|
+
file_path: str,
|
|
322
|
+
*,
|
|
323
|
+
params: dict[str, Any] | None = None,
|
|
324
|
+
chunk_size: int = 8192,
|
|
325
|
+
headers: dict[str, str] | None = None,
|
|
326
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
327
|
+
"""Download a file directly to disk with progress tracking.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
path: API endpoint path
|
|
331
|
+
file_path: Local file path to save to
|
|
332
|
+
params: Query parameters
|
|
333
|
+
chunk_size: Size of chunks to read/write
|
|
334
|
+
headers: Additional headers
|
|
335
|
+
|
|
336
|
+
Yields:
|
|
337
|
+
Dict with download progress information:
|
|
338
|
+
- bytes_written: Number of bytes written so far
|
|
339
|
+
- total_bytes: Total file size if known (from Content-Length)
|
|
340
|
+
- progress: Progress percentage (0.0-1.0) if total size known
|
|
341
|
+
- response: StreamingResponse object
|
|
342
|
+
|
|
343
|
+
Example:
|
|
344
|
+
```python
|
|
345
|
+
async with client.download_file("/files/large.zip", "local.zip") as progress:
|
|
346
|
+
print(f"Downloaded {progress['bytes_written']} bytes")
|
|
347
|
+
```
|
|
348
|
+
"""
|
|
349
|
+
async with self.stream_request("GET", path, params=params, headers=headers) as response:
|
|
350
|
+
total_bytes = response.content_length
|
|
351
|
+
bytes_written = 0
|
|
352
|
+
|
|
353
|
+
progress_info = {
|
|
354
|
+
"bytes_written": 0,
|
|
355
|
+
"total_bytes": total_bytes,
|
|
356
|
+
"progress": 0.0 if total_bytes else None,
|
|
357
|
+
"response": response,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
with open(file_path, 'wb') as f:
|
|
362
|
+
async for chunk in response.aiter_bytes(chunk_size):
|
|
363
|
+
f.write(chunk)
|
|
364
|
+
bytes_written += len(chunk)
|
|
365
|
+
|
|
366
|
+
# Update progress
|
|
367
|
+
progress_info["bytes_written"] = bytes_written
|
|
368
|
+
if total_bytes:
|
|
369
|
+
progress_info["progress"] = min(bytes_written / total_bytes, 1.0)
|
|
370
|
+
|
|
371
|
+
yield progress_info
|
|
372
|
+
|
|
373
|
+
except Exception:
|
|
374
|
+
# Clean up partial file on error
|
|
375
|
+
import os
|
|
376
|
+
try:
|
|
377
|
+
os.unlink(file_path)
|
|
378
|
+
except OSError:
|
|
379
|
+
pass
|
|
380
|
+
raise
|
|
381
|
+
|
|
382
|
+
# Convenience class methods for authentication
|
|
383
|
+
@classmethod
|
|
384
|
+
def from_bearer_token(
|
|
385
|
+
cls,
|
|
386
|
+
base_url: str,
|
|
387
|
+
token: str,
|
|
388
|
+
**kwargs: Any,
|
|
389
|
+
) -> AsyncStreamingClient:
|
|
390
|
+
"""Create a streaming client with Bearer token authentication."""
|
|
391
|
+
from .auth import BearerTokenAuth
|
|
392
|
+
return cls(base_url, auth=BearerTokenAuth(token), **kwargs)
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def from_api_key(
|
|
396
|
+
cls,
|
|
397
|
+
base_url: str,
|
|
398
|
+
api_key: str,
|
|
399
|
+
header_name: str = "X-API-Key",
|
|
400
|
+
param_name: str | None = None,
|
|
401
|
+
**kwargs: Any,
|
|
402
|
+
) -> AsyncStreamingClient:
|
|
403
|
+
"""Create a streaming client with API key authentication."""
|
|
404
|
+
from .auth import APIKeyAuth
|
|
405
|
+
return cls(base_url, auth=APIKeyAuth(api_key, header_name, param_name), **kwargs)
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def from_basic_auth(
|
|
409
|
+
cls,
|
|
410
|
+
base_url: str,
|
|
411
|
+
username: str,
|
|
412
|
+
password: str,
|
|
413
|
+
**kwargs: Any,
|
|
414
|
+
) -> AsyncStreamingClient:
|
|
415
|
+
"""Create a streaming client with HTTP Basic authentication."""
|
|
416
|
+
from .auth import BasicAuth
|
|
417
|
+
return cls(base_url, auth=BasicAuth(username, password), **kwargs)
|
|
418
|
+
|
|
419
|
+
@classmethod
|
|
420
|
+
def from_oauth2_client_credentials(
|
|
421
|
+
cls,
|
|
422
|
+
base_url: str,
|
|
423
|
+
token_url: str,
|
|
424
|
+
client_id: str,
|
|
425
|
+
client_secret: str,
|
|
426
|
+
scope: str | None = None,
|
|
427
|
+
**kwargs: Any,
|
|
428
|
+
) -> AsyncStreamingClient:
|
|
429
|
+
"""Create a streaming client with OAuth2 client credentials authentication."""
|
|
430
|
+
from .auth import OAuth2ClientCredentialsAuth
|
|
431
|
+
return cls(
|
|
432
|
+
base_url,
|
|
433
|
+
auth=OAuth2ClientCredentialsAuth(token_url, client_id, client_secret, scope),
|
|
434
|
+
**kwargs
|
|
435
|
+
)
|