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,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
+ )