retab 0.0.64__py3-none-any.whl → 0.0.66__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.
retab/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  from .client import AsyncRetab, Retab
2
2
  from . import utils
3
-
4
- __all__ = ["Retab", "AsyncRetab", "utils"]
3
+ from . import types
4
+ __all__ = ["Retab", "AsyncRetab", "utils", "types"]
retab/client copy.py ADDED
@@ -0,0 +1,693 @@
1
+ import json
2
+ import os
3
+ from types import TracebackType
4
+ from typing import Any, AsyncIterator, Iterator, Optional
5
+
6
+ import backoff
7
+ import backoff.types
8
+ import httpx
9
+ import truststore
10
+
11
+ from .resources import documents, models, schemas, projects
12
+ from .types.standards import PreparedRequest, FieldUnset
13
+
14
+
15
+ class MaxRetriesExceeded(Exception):
16
+ pass
17
+
18
+
19
+ def raise_max_tries_exceeded(details: backoff.types.Details) -> None:
20
+ exception = details.get("exception")
21
+ tries = details["tries"]
22
+ if isinstance(exception, BaseException):
23
+ raise Exception(f"Max tries exceeded after {tries} tries.") from exception
24
+ else:
25
+ raise Exception(f"Max tries exceeded after {tries} tries.")
26
+
27
+
28
+ class BaseRetab:
29
+ """Base class for Retab clients that handles authentication and configuration.
30
+
31
+ This class provides core functionality for API authentication, configuration, and common HTTP operations
32
+ used by both synchronous and asynchronous clients.
33
+
34
+ Args:
35
+ api_key (str, optional): Retab API key. If not provided, will look for RETAB_API_KEY env variable.
36
+ base_url (str, optional): Base URL for API requests. Defaults to https://api.retab.com
37
+ timeout (float): Request timeout in seconds. Defaults to 240.0
38
+ max_retries (int): Maximum number of retries for failed requests. Defaults to 3
39
+ openai_api_key (str, optional): OpenAI API key. Will look for OPENAI_API_KEY env variable if not provided
40
+
41
+ Raises:
42
+ ValueError: If no API key is provided through arguments or environment variables
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ api_key: Optional[str] = None,
48
+ base_url: Optional[str] = None,
49
+ timeout: float = 800.0,
50
+ max_retries: int = 3,
51
+ openai_api_key: Optional[str] = FieldUnset,
52
+ gemini_api_key: Optional[str] = FieldUnset,
53
+ xai_api_key: Optional[str] = FieldUnset,
54
+ ) -> None:
55
+ if api_key is None:
56
+ api_key = os.environ.get("RETAB_API_KEY")
57
+
58
+ if api_key is None:
59
+ raise ValueError(
60
+ "No API key provided. You can create an API key at https://retab.com\n"
61
+ "Then either pass it to the client (api_key='your-key') or set the RETAB_API_KEY environment variable"
62
+ )
63
+
64
+ if base_url is None:
65
+ base_url = os.environ.get("RETAB_API_BASE_URL", "https://api.retab.com")
66
+
67
+ truststore.inject_into_ssl()
68
+ self.api_key = api_key
69
+ self.base_url = base_url.rstrip("/")
70
+ self.timeout = timeout
71
+ self.max_retries = max_retries
72
+ self.headers = {
73
+ "Api-Key": self.api_key,
74
+ "Content-Type": "application/json",
75
+ }
76
+
77
+ # Only check environment variables if the value is FieldUnset
78
+ if openai_api_key is FieldUnset:
79
+ openai_api_key = os.environ.get("OPENAI_API_KEY")
80
+
81
+ if gemini_api_key is FieldUnset:
82
+ gemini_api_key = os.environ.get("GEMINI_API_KEY")
83
+
84
+ # Only add headers if the values are actual strings (not None or FieldUnset)
85
+ if openai_api_key and openai_api_key is not FieldUnset:
86
+ self.headers["OpenAI-Api-Key"] = openai_api_key
87
+
88
+ if xai_api_key and xai_api_key is not FieldUnset:
89
+ self.headers["XAI-Api-Key"] = xai_api_key
90
+
91
+ if gemini_api_key and gemini_api_key is not FieldUnset:
92
+ self.headers["Gemini-Api-Key"] = gemini_api_key
93
+
94
+ def _prepare_url(self, endpoint: str) -> str:
95
+ return f"{self.base_url}/{endpoint.lstrip('/')}"
96
+
97
+ def _validate_response(self, response_object: httpx.Response) -> None:
98
+ if response_object.status_code >= 500:
99
+ response_object.raise_for_status()
100
+ elif response_object.status_code == 422:
101
+ raise RuntimeError(f"Validation error (422): {response_object.text}")
102
+ elif not response_object.is_success:
103
+ raise RuntimeError(f"Request failed ({response_object.status_code}): {response_object.text}")
104
+
105
+ def _get_headers(self, idempotency_key: str | None = None) -> dict[str, Any]:
106
+ headers = self.headers.copy()
107
+ if idempotency_key:
108
+ headers["Idempotency-Key"] = idempotency_key
109
+ return headers
110
+
111
+ def _parse_response(self, response: httpx.Response) -> Any:
112
+ """Parse response based on content-type.
113
+
114
+ Returns:
115
+ Any: Parsed object. For JSON: parsed JSON. For NDJSON streams: last JSON object. For text: raw text.
116
+ """
117
+ content_type = response.headers.get("content-type", "")
118
+
119
+ # NDJSON streaming responses: pick the last valid JSON line
120
+ if "application/stream+json" in content_type:
121
+ text_body = response.text or ""
122
+ last_obj: Any | None = None
123
+ for line in text_body.splitlines():
124
+ line = line.strip()
125
+ if not line:
126
+ continue
127
+ try:
128
+ last_obj = json.loads(line)
129
+ except Exception:
130
+ # Ignore malformed lines
131
+ continue
132
+ return last_obj if last_obj is not None else text_body
133
+
134
+ # Standard JSON
135
+ if "application/json" in content_type:
136
+ return response.json()
137
+
138
+ # Text responses
139
+ if "text/plain" in content_type or "text/" in content_type:
140
+ return response.text
141
+
142
+ # Fallback: try JSON then text
143
+ try:
144
+ return response.json()
145
+ except Exception:
146
+ return response.text
147
+
148
+
149
+ class Retab(BaseRetab):
150
+ """Synchronous client for interacting with the Retab API.
151
+
152
+ This client provides synchronous access to all Retab API resources including files, fine-tuning,
153
+ prompt optimization, documents, models, processors, deployments, and schemas.
154
+
155
+ Args:
156
+ api_key (str, optional): Retab API key. If not provided, will look for RETAB_API_KEY env variable.
157
+ base_url (str, optional): Base URL for API requests. Defaults to https://api.retab.com
158
+ timeout (float): Request timeout in seconds. Defaults to 240.0
159
+ max_retries (int): Maximum number of retries for failed requests. Defaults to 3
160
+ openai_api_key (str, optional): OpenAI API key. Will look for OPENAI_API_KEY env variable if not provided
161
+ gemini_api_key (str, optional): Gemini API key. Will look for GEMINI_API_KEY env variable if not provided
162
+
163
+ Attributes:
164
+ files: Access to file operations
165
+ fine_tuning: Access to model fine-tuning operations
166
+ prompt_optimization: Access to prompt optimization operations
167
+ documents: Access to document operations
168
+ models: Access to model operations
169
+ processors: Access to processor operations
170
+ deployments: Access to deployment operations
171
+ schemas: Access to schema operations
172
+ responses: Access to responses API (OpenAI Responses API compatible interface)
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ api_key: Optional[str] = None,
178
+ base_url: Optional[str] = None,
179
+ timeout: float = 240.0,
180
+ max_retries: int = 3,
181
+ openai_api_key: Optional[str] = FieldUnset,
182
+ gemini_api_key: Optional[str] = FieldUnset,
183
+ ) -> None:
184
+ super().__init__(
185
+ api_key=api_key,
186
+ base_url=base_url,
187
+ timeout=timeout,
188
+ max_retries=max_retries,
189
+ openai_api_key=openai_api_key,
190
+ gemini_api_key=gemini_api_key,
191
+ )
192
+
193
+ self.client = httpx.Client(timeout=self.timeout)
194
+ self.projects = projects.Projects(client=self)
195
+ self.documents = documents.Documents(client=self)
196
+ self.models = models.Models(client=self)
197
+ self.schemas = schemas.Schemas(client=self)
198
+
199
+ def _request(
200
+ self,
201
+ method: str,
202
+ endpoint: str,
203
+ data: Optional[dict[str, Any]] = None,
204
+ params: Optional[dict[str, Any]] = None,
205
+ form_data: Optional[dict[str, Any]] = None,
206
+ files: Optional[dict[str, Any] | list] = None,
207
+ idempotency_key: str | None = None,
208
+ raise_for_status: bool = False,
209
+ ) -> Any:
210
+ """Makes a synchronous HTTP request to the API.
211
+
212
+ Args:
213
+ method (str): HTTP method (GET, POST, etc.)
214
+ endpoint (str): API endpoint path
215
+ data (Optional[dict]): Request payload (JSON)
216
+ params (Optional[dict]): Query parameters
217
+ form_data (Optional[dict]): Form data for multipart/form-data requests
218
+ files (Optional[dict]): Files for multipart/form-data requests
219
+ idempotency_key (str, optional): Idempotency key for request
220
+ raise_for_status (bool): Whether to raise on HTTP errors
221
+
222
+ Returns:
223
+ Any: Parsed JSON response or raw text string depending on response content-type
224
+
225
+ Raises:
226
+ RuntimeError: If request fails after max retries or validation error occurs
227
+ """
228
+
229
+ def raw_request() -> Any:
230
+ # Prepare request kwargs
231
+ request_kwargs = {
232
+ "method": method,
233
+ "url": self._prepare_url(endpoint),
234
+ "params": params,
235
+ "headers": self._get_headers(idempotency_key),
236
+ }
237
+
238
+ # Handle different content types
239
+ if files or form_data:
240
+ # For multipart/form-data requests
241
+ if form_data:
242
+ request_kwargs["data"] = form_data
243
+ if files:
244
+ request_kwargs["files"] = files
245
+ # Remove Content-Type header to let httpx set it automatically for multipart
246
+ headers = request_kwargs["headers"].copy()
247
+ headers.pop("Content-Type", None)
248
+ request_kwargs["headers"] = headers
249
+ elif data:
250
+ # For JSON requests
251
+ request_kwargs["json"] = data
252
+
253
+ response = self.client.request(**request_kwargs)
254
+ self._validate_response(response)
255
+ return self._parse_response(response)
256
+
257
+ @backoff.on_exception(backoff.expo, httpx.HTTPStatusError, max_tries=self.max_retries + 1, on_giveup=raise_max_tries_exceeded)
258
+ def wrapped_request() -> Any:
259
+ return raw_request()
260
+
261
+ if raise_for_status:
262
+ # If raise_for_status is True, we want to raise an exception if the request fails, not retry...
263
+ return raw_request()
264
+ else:
265
+ return wrapped_request()
266
+
267
+ def _request_stream(
268
+ self,
269
+ method: str,
270
+ endpoint: str,
271
+ data: Optional[dict[str, Any]] = None,
272
+ params: Optional[dict[str, Any]] = None,
273
+ form_data: Optional[dict[str, Any]] = None,
274
+ files: Optional[dict[str, Any] | list] = None,
275
+ idempotency_key: str | None = None,
276
+ raise_for_status: bool = False,
277
+ ) -> Iterator[Any]:
278
+ """Makes a streaming synchronous HTTP request to the API.
279
+
280
+ Args:
281
+ method (str): HTTP method (GET, POST, etc.)
282
+ endpoint (str): API endpoint path
283
+ data (Optional[dict]): Request payload (JSON)
284
+ params (Optional[dict]): Query parameters
285
+ form_data (Optional[dict]): Form data for multipart/form-data requests
286
+ files (Optional[dict]): Files for multipart/form-data requests
287
+ idempotency_key (str, optional): Idempotency key for request
288
+ raise_for_status (bool): Whether to raise on HTTP errors
289
+ Returns:
290
+ Iterator[Any]: Generator yielding parsed JSON objects or raw text strings from the stream
291
+
292
+ Raises:
293
+ RuntimeError: If request fails after max retries or validation error occurs
294
+ """
295
+
296
+ def raw_request() -> Iterator[Any]:
297
+ # Prepare request kwargs
298
+ stream_kwargs = {
299
+ "method": method,
300
+ "url": self._prepare_url(endpoint),
301
+ "params": params,
302
+ "headers": self._get_headers(idempotency_key),
303
+ }
304
+
305
+ # Handle different content types
306
+ if files or form_data:
307
+ # For multipart/form-data requests
308
+ if form_data:
309
+ stream_kwargs["data"] = form_data
310
+ if files:
311
+ stream_kwargs["files"] = files
312
+ # Remove Content-Type header to let httpx set it automatically for multipart
313
+ headers = stream_kwargs["headers"].copy()
314
+ headers.pop("Content-Type", None)
315
+ stream_kwargs["headers"] = headers
316
+ elif data:
317
+ # For JSON requests
318
+ stream_kwargs["json"] = data
319
+
320
+ with self.client.stream(**stream_kwargs) as response_ctx_manager:
321
+ self._validate_response(response_ctx_manager)
322
+
323
+ content_type = response_ctx_manager.headers.get("content-type", "")
324
+ is_json_stream = "application/json" in content_type or "application/stream+json" in content_type
325
+ is_text_stream = "text/plain" in content_type or ("text/" in content_type and not is_json_stream)
326
+
327
+ for chunk in response_ctx_manager.iter_lines():
328
+ if not chunk:
329
+ continue
330
+
331
+ if is_json_stream:
332
+ try:
333
+ yield json.loads(chunk)
334
+ except Exception:
335
+ pass
336
+ elif is_text_stream:
337
+ yield chunk
338
+ else:
339
+ # Default behavior: try JSON first, fall back to text
340
+ try:
341
+ yield json.loads(chunk)
342
+ except Exception:
343
+ yield chunk
344
+
345
+ @backoff.on_exception(backoff.expo, httpx.HTTPStatusError, max_tries=self.max_retries + 1, on_giveup=raise_max_tries_exceeded)
346
+ def wrapped_request() -> Iterator[Any]:
347
+ for item in raw_request():
348
+ yield item
349
+
350
+ iterator_ = raw_request() if raise_for_status else wrapped_request()
351
+
352
+ for item in iterator_:
353
+ yield item
354
+
355
+ # Simplified request methods using standard PreparedRequest object
356
+ def _prepared_request(self, request: PreparedRequest) -> Any:
357
+ return self._request(
358
+ method=request.method,
359
+ endpoint=request.url,
360
+ data=request.data,
361
+ params=request.params,
362
+ form_data=request.form_data,
363
+ files=request.files,
364
+ idempotency_key=request.idempotency_key,
365
+ raise_for_status=request.raise_for_status,
366
+ )
367
+
368
+ def _prepared_request_stream(self, request: PreparedRequest) -> Iterator[Any]:
369
+ for item in self._request_stream(
370
+ method=request.method,
371
+ endpoint=request.url,
372
+ data=request.data,
373
+ params=request.params,
374
+ form_data=request.form_data,
375
+ files=request.files,
376
+ idempotency_key=request.idempotency_key,
377
+ raise_for_status=request.raise_for_status,
378
+ ):
379
+ yield item
380
+
381
+ def close(self) -> None:
382
+ """Closes the HTTP client session."""
383
+ self.client.close()
384
+
385
+ def __enter__(self) -> "Retab":
386
+ """Context manager entry point.
387
+
388
+ Returns:
389
+ Retab: The client instance
390
+ """
391
+ return self
392
+
393
+ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
394
+ """Context manager exit point that ensures the client is properly closed.
395
+
396
+ Args:
397
+ exc_type: The type of the exception that was raised, if any
398
+ exc_value: The instance of the exception that was raised, if any
399
+ traceback: The traceback of the exception that was raised, if any
400
+ """
401
+ self.close()
402
+
403
+
404
+ class AsyncRetab(BaseRetab):
405
+ """Asynchronous client for interacting with the Retab API.
406
+
407
+ This client provides asynchronous access to all Retab API resources including files, fine-tuning,
408
+ prompt optimization, documents, models, processors, deployments, and schemas.
409
+
410
+ Args:
411
+ api_key (str, optional): Retab API key. If not provided, will look for RETAB_API_KEY env variable.
412
+ base_url (str, optional): Base URL for API requests. Defaults to https://api.retab.com
413
+ timeout (float): Request timeout in seconds. Defaults to 240.0
414
+ max_retries (int): Maximum number of retries for failed requests. Defaults to 3
415
+ openai_api_key (str, optional): OpenAI API key. Will look for OPENAI_API_KEY env variable if not provided
416
+ claude_api_key (str, optional): Claude API key. Will look for CLAUDE_API_KEY env variable if not provided
417
+ xai_api_key (str, optional): XAI API key. Will look for XAI_API_KEY env variable if not provided
418
+ gemini_api_key (str, optional): Gemini API key. Will look for GEMINI_API_KEY env variable if not provided
419
+
420
+ Attributes:
421
+ files: Access to asynchronous file operations
422
+ fine_tuning: Access to asynchronous model fine-tuning operations
423
+ prompt_optimization: Access to asynchronous prompt optimization operations
424
+ documents: Access to asynchronous document operations
425
+ models: Access to asynchronous model operations
426
+ processors: Access to asynchronous processor operations
427
+ deployments: Access to asynchronous deployment operations
428
+ schemas: Access to asynchronous schema operations
429
+ responses: Access to responses API (OpenAI Responses API compatible interface)
430
+ """
431
+
432
+ def __init__(
433
+ self,
434
+ api_key: Optional[str] = None,
435
+ base_url: Optional[str] = None,
436
+ timeout: float = 240.0,
437
+ max_retries: int = 3,
438
+ openai_api_key: Optional[str] = FieldUnset,
439
+ gemini_api_key: Optional[str] = FieldUnset,
440
+ ) -> None:
441
+ super().__init__(
442
+ api_key=api_key,
443
+ base_url=base_url,
444
+ timeout=timeout,
445
+ max_retries=max_retries,
446
+ openai_api_key=openai_api_key,
447
+ gemini_api_key=gemini_api_key,
448
+ )
449
+
450
+ self.client = httpx.AsyncClient(timeout=self.timeout)
451
+
452
+ self.projects = projects.AsyncProjects(client=self)
453
+ self.documents = documents.AsyncDocuments(client=self)
454
+ self.models = models.AsyncModels(client=self)
455
+ self.schemas = schemas.AsyncSchemas(client=self)
456
+
457
+ def _parse_response(self, response: httpx.Response) -> Any:
458
+ """Parse response based on content-type.
459
+
460
+ Returns:
461
+ Any: Parsed object. For JSON: parsed JSON. For NDJSON streams: last JSON object. For text: raw text.
462
+ """
463
+ content_type = response.headers.get("content-type", "")
464
+
465
+ # NDJSON streaming responses: pick the last valid JSON line
466
+ if "application/stream+json" in content_type:
467
+ text_body = response.text or ""
468
+ last_obj: Any | None = None
469
+ for line in text_body.splitlines():
470
+ line = line.strip()
471
+ if not line:
472
+ continue
473
+ try:
474
+ last_obj = json.loads(line)
475
+ except Exception:
476
+ # Ignore malformed lines
477
+ continue
478
+ return last_obj if last_obj is not None else text_body
479
+
480
+ # Standard JSON
481
+ if "application/json" in content_type:
482
+ return response.json()
483
+
484
+ # Text responses
485
+ if "text/plain" in content_type or "text/" in content_type:
486
+ return response.text
487
+
488
+ # Fallback: try JSON then text
489
+ try:
490
+ return response.json()
491
+ except Exception:
492
+ return response.text
493
+
494
+ async def _request(
495
+ self,
496
+ method: str,
497
+ endpoint: str,
498
+ data: Optional[dict[str, Any]] = None,
499
+ params: Optional[dict[str, Any]] = None,
500
+ form_data: Optional[dict[str, Any]] = None,
501
+ files: Optional[dict[str, Any] | list] = None,
502
+ idempotency_key: str | None = None,
503
+ raise_for_status: bool = False,
504
+ ) -> Any:
505
+ """Makes an asynchronous HTTP request to the API.
506
+
507
+ Args:
508
+ method (str): HTTP method (GET, POST, etc.)
509
+ endpoint (str): API endpoint path
510
+ data (Optional[dict]): Request payload (JSON)
511
+ params (Optional[dict]): Query parameters
512
+ form_data (Optional[dict]): Form data for multipart/form-data requests
513
+ files (Optional[dict]): Files for multipart/form-data requests
514
+ idempotency_key (str, optional): Idempotency key for request
515
+ raise_for_status (bool): Whether to raise on HTTP errors
516
+ Returns:
517
+ Any: Parsed JSON response or raw text string depending on response content-type
518
+
519
+ Raises:
520
+ RuntimeError: If request fails after max retries or validation error occurs
521
+ """
522
+
523
+ async def raw_request() -> Any:
524
+ # Prepare request kwargs
525
+ request_kwargs = {
526
+ "method": method,
527
+ "url": self._prepare_url(endpoint),
528
+ "params": params,
529
+ "headers": self._get_headers(idempotency_key),
530
+ }
531
+
532
+ # Handle different content types
533
+ if files or form_data:
534
+ # For multipart/form-data requests
535
+ if form_data:
536
+ request_kwargs["data"] = form_data
537
+ if files:
538
+ request_kwargs["files"] = files
539
+ # Remove Content-Type header to let httpx set it automatically for multipart
540
+ headers = request_kwargs["headers"].copy()
541
+ headers.pop("Content-Type", None)
542
+ request_kwargs["headers"] = headers
543
+ elif data:
544
+ # For JSON requests
545
+ request_kwargs["json"] = data
546
+
547
+ response = await self.client.request(**request_kwargs)
548
+ self._validate_response(response)
549
+ return self._parse_response(response)
550
+
551
+ @backoff.on_exception(backoff.expo, httpx.HTTPStatusError, max_tries=self.max_retries + 1, on_giveup=raise_max_tries_exceeded)
552
+ async def wrapped_request() -> Any:
553
+ return await raw_request()
554
+
555
+ if raise_for_status:
556
+ return await raw_request()
557
+ else:
558
+ return await wrapped_request()
559
+
560
+ async def _request_stream(
561
+ self,
562
+ method: str,
563
+ endpoint: str,
564
+ data: Optional[dict[str, Any]] = None,
565
+ params: Optional[dict[str, Any]] = None,
566
+ form_data: Optional[dict[str, Any]] = None,
567
+ files: Optional[dict[str, Any] | list] = None,
568
+ idempotency_key: str | None = None,
569
+ raise_for_status: bool = False,
570
+ ) -> AsyncIterator[Any]:
571
+ """Makes a streaming asynchronous HTTP request to the API.
572
+
573
+ Args:
574
+ method (str): HTTP method (GET, POST, etc.)
575
+ endpoint (str): API endpoint path
576
+ data (Optional[dict]): Request payload (JSON)
577
+ params (Optional[dict]): Query parameters
578
+ form_data (Optional[dict]): Form data for multipart/form-data requests
579
+ files (Optional[dict]): Files for multipart/form-data requests
580
+ idempotency_key (str, optional): Idempotency key for request
581
+ raise_for_status (bool): Whether to raise on HTTP errors
582
+ Returns:
583
+ AsyncIterator[Any]: Async generator yielding parsed JSON objects or raw text strings from the stream
584
+
585
+ Raises:
586
+ RuntimeError: If request fails after max retries or validation error occurs
587
+ """
588
+
589
+ async def raw_request() -> AsyncIterator[Any]:
590
+ # Prepare request kwargs
591
+ stream_kwargs = {
592
+ "method": method,
593
+ "url": self._prepare_url(endpoint),
594
+ "params": params,
595
+ "headers": self._get_headers(idempotency_key),
596
+ }
597
+
598
+ # Handle different content types
599
+ if files or form_data:
600
+ # For multipart/form-data requests
601
+ if form_data:
602
+ stream_kwargs["data"] = form_data
603
+ if files:
604
+ stream_kwargs["files"] = files
605
+ # Remove Content-Type header to let httpx set it automatically for multipart
606
+ headers = stream_kwargs["headers"].copy()
607
+ headers.pop("Content-Type", None)
608
+ stream_kwargs["headers"] = headers
609
+ elif data:
610
+ # For JSON requests
611
+ stream_kwargs["json"] = data
612
+
613
+ async with self.client.stream(**stream_kwargs) as response_ctx_manager:
614
+ self._validate_response(response_ctx_manager)
615
+
616
+ content_type = response_ctx_manager.headers.get("content-type", "")
617
+ is_json_stream = "application/json" in content_type or "application/stream+json" in content_type
618
+ is_text_stream = "text/plain" in content_type or ("text/" in content_type and not is_json_stream)
619
+
620
+ async for chunk in response_ctx_manager.aiter_lines():
621
+ if not chunk:
622
+ continue
623
+
624
+ if is_json_stream:
625
+ try:
626
+ yield json.loads(chunk)
627
+ except Exception:
628
+ pass
629
+ elif is_text_stream:
630
+ yield chunk
631
+ else:
632
+ # Default behavior: try JSON first, fall back to text
633
+ try:
634
+ yield json.loads(chunk)
635
+ except Exception:
636
+ yield chunk
637
+
638
+ @backoff.on_exception(backoff.expo, httpx.HTTPStatusError, max_tries=self.max_retries + 1, on_giveup=raise_max_tries_exceeded)
639
+ async def wrapped_request() -> AsyncIterator[Any]:
640
+ async for item in raw_request():
641
+ yield item
642
+
643
+ async_iterator_ = raw_request() if raise_for_status else wrapped_request()
644
+
645
+ async for item in async_iterator_:
646
+ yield item
647
+
648
+ async def _prepared_request(self, request: PreparedRequest) -> Any:
649
+ return await self._request(
650
+ method=request.method,
651
+ endpoint=request.url,
652
+ data=request.data,
653
+ params=request.params,
654
+ form_data=request.form_data,
655
+ files=request.files,
656
+ idempotency_key=request.idempotency_key,
657
+ raise_for_status=request.raise_for_status,
658
+ )
659
+
660
+ async def _prepared_request_stream(self, request: PreparedRequest) -> AsyncIterator[Any]:
661
+ async for item in self._request_stream(
662
+ method=request.method,
663
+ endpoint=request.url,
664
+ data=request.data,
665
+ params=request.params,
666
+ form_data=request.form_data,
667
+ files=request.files,
668
+ idempotency_key=request.idempotency_key,
669
+ raise_for_status=request.raise_for_status,
670
+ ):
671
+ yield item
672
+
673
+ async def close(self) -> None:
674
+ """Closes the async HTTP client session."""
675
+ await self.client.aclose()
676
+
677
+ async def __aenter__(self) -> "AsyncRetab":
678
+ """Async context manager entry point.
679
+
680
+ Returns:
681
+ AsyncRetab: The async client instance
682
+ """
683
+ return self
684
+
685
+ async def __aexit__(self, exc_type: type, exc_value: BaseException, traceback: TracebackType) -> None:
686
+ """Async context manager exit point that ensures the client is properly closed.
687
+
688
+ Args:
689
+ exc_type: The type of the exception that was raised, if any
690
+ exc_value: The instance of the exception that was raised, if any
691
+ traceback: The traceback of the exception that was raised, if any
692
+ """
693
+ await self.close()