calibrate-python-sdk 0.0.1__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 (56) hide show
  1. artpark/__init__.py +109 -0
  2. artpark/_default_clients.py +32 -0
  3. artpark/agent_tests/__init__.py +4 -0
  4. artpark/agent_tests/client.py +356 -0
  5. artpark/agent_tests/raw_client.py +455 -0
  6. artpark/agents/__init__.py +4 -0
  7. artpark/agents/client.py +210 -0
  8. artpark/agents/raw_client.py +273 -0
  9. artpark/client.py +268 -0
  10. artpark/core/__init__.py +127 -0
  11. artpark/core/api_error.py +23 -0
  12. artpark/core/client_wrapper.py +163 -0
  13. artpark/core/datetime_utils.py +70 -0
  14. artpark/core/file.py +67 -0
  15. artpark/core/force_multipart.py +18 -0
  16. artpark/core/http_client.py +843 -0
  17. artpark/core/http_response.py +59 -0
  18. artpark/core/http_sse/__init__.py +42 -0
  19. artpark/core/http_sse/_api.py +180 -0
  20. artpark/core/http_sse/_decoders.py +61 -0
  21. artpark/core/http_sse/_exceptions.py +7 -0
  22. artpark/core/http_sse/_models.py +17 -0
  23. artpark/core/jsonable_encoder.py +120 -0
  24. artpark/core/logging.py +107 -0
  25. artpark/core/parse_error.py +36 -0
  26. artpark/core/pydantic_utilities.py +508 -0
  27. artpark/core/query_encoder.py +58 -0
  28. artpark/core/remove_none_from_dict.py +11 -0
  29. artpark/core/request_options.py +37 -0
  30. artpark/core/serialization.py +347 -0
  31. artpark/environment.py +7 -0
  32. artpark/errors/__init__.py +34 -0
  33. artpark/errors/unprocessable_entity_error.py +11 -0
  34. artpark/py.typed +0 -0
  35. artpark/types/__init__.py +83 -0
  36. artpark/types/batch_run_request.py +19 -0
  37. artpark/types/batch_test_run.py +22 -0
  38. artpark/types/batch_test_run_response.py +22 -0
  39. artpark/types/batch_test_skip.py +21 -0
  40. artpark/types/http_validation_error.py +20 -0
  41. artpark/types/judge_result.py +51 -0
  42. artpark/types/resolve_agent_names_response.py +20 -0
  43. artpark/types/routers_agent_tests_agent_response.py +27 -0
  44. artpark/types/routers_agent_tests_agent_response_type.py +5 -0
  45. artpark/types/task_create_response.py +22 -0
  46. artpark/types/test_case_result.py +33 -0
  47. artpark/types/test_output.py +21 -0
  48. artpark/types/test_run_status_response.py +37 -0
  49. artpark/types/tool_call_output.py +21 -0
  50. artpark/types/validation_error.py +22 -0
  51. artpark/types/validation_error_loc_item.py +5 -0
  52. artpark/version.py +3 -0
  53. calibrate_python_sdk-0.0.1.dist-info/LICENSE +21 -0
  54. calibrate_python_sdk-0.0.1.dist-info/METADATA +55 -0
  55. calibrate_python_sdk-0.0.1.dist-info/RECORD +56 -0
  56. calibrate_python_sdk-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,843 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ import asyncio
4
+ import email.utils
5
+ import re
6
+ import time
7
+ import typing
8
+ from contextlib import asynccontextmanager, contextmanager
9
+ from random import random
10
+
11
+ import httpx
12
+ from .file import File, convert_file_dict_to_httpx_tuples
13
+ from .force_multipart import FORCE_MULTIPART
14
+ from .jsonable_encoder import jsonable_encoder
15
+ from .logging import LogConfig, Logger, create_logger
16
+ from .query_encoder import encode_query
17
+ from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict
18
+ from .request_options import RequestOptions
19
+ from httpx._types import RequestFiles
20
+
21
+ INITIAL_RETRY_DELAY_SECONDS = 1.0
22
+ MAX_RETRY_DELAY_SECONDS = 60.0
23
+ JITTER_FACTOR = 0.2 # 20% random jitter
24
+
25
+
26
+ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]:
27
+ """
28
+ This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait.
29
+
30
+ Inspired by the urllib3 retry implementation.
31
+ """
32
+ retry_after_ms = response_headers.get("retry-after-ms")
33
+ if retry_after_ms is not None:
34
+ try:
35
+ return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0
36
+ except Exception:
37
+ pass
38
+
39
+ retry_after = response_headers.get("retry-after")
40
+ if retry_after is None:
41
+ return None
42
+
43
+ # Attempt to parse the header as an int.
44
+ if re.match(r"^\s*[0-9]+\s*$", retry_after):
45
+ seconds = float(retry_after)
46
+ # Fallback to parsing it as a date.
47
+ else:
48
+ retry_date_tuple = email.utils.parsedate_tz(retry_after)
49
+ if retry_date_tuple is None:
50
+ return None
51
+ if retry_date_tuple[9] is None: # Python 2
52
+ # Assume UTC if no timezone was specified
53
+ # On Python2.7, parsedate_tz returns None for a timezone offset
54
+ # instead of 0 if no timezone is given, where mktime_tz treats
55
+ # a None timezone offset as local time.
56
+ retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:]
57
+
58
+ retry_date = email.utils.mktime_tz(retry_date_tuple)
59
+ seconds = retry_date - time.time()
60
+
61
+ if seconds < 0:
62
+ seconds = 0
63
+
64
+ return seconds
65
+
66
+
67
+ def _add_positive_jitter(delay: float) -> float:
68
+ """Add positive jitter (0-20%) to prevent thundering herd."""
69
+ jitter_multiplier = 1 + random() * JITTER_FACTOR
70
+ return delay * jitter_multiplier
71
+
72
+
73
+ def _add_symmetric_jitter(delay: float) -> float:
74
+ """Add symmetric jitter (±10%) for exponential backoff."""
75
+ jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR
76
+ return delay * jitter_multiplier
77
+
78
+
79
+ def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]:
80
+ """
81
+ Parse the X-RateLimit-Reset header (Unix timestamp in seconds).
82
+ Returns seconds to wait, or None if header is missing/invalid.
83
+ """
84
+ reset_time_str = response_headers.get("x-ratelimit-reset")
85
+ if reset_time_str is None:
86
+ return None
87
+
88
+ try:
89
+ reset_time = int(reset_time_str)
90
+ delay = reset_time - time.time()
91
+ if delay > 0:
92
+ return delay
93
+ except (ValueError, TypeError):
94
+ pass
95
+
96
+ return None
97
+
98
+
99
+ def _retry_timeout(response: httpx.Response, retries: int) -> float:
100
+ """
101
+ Determine the amount of time to wait before retrying a request.
102
+ This function begins by trying to parse a retry-after header from the response, and then proceeds to use exponential backoff
103
+ with a jitter to determine the number of seconds to wait.
104
+ """
105
+
106
+ # 1. Check Retry-After header first
107
+ retry_after = _parse_retry_after(response.headers)
108
+ if retry_after is not None and retry_after > 0:
109
+ return min(retry_after, MAX_RETRY_DELAY_SECONDS)
110
+
111
+ # 2. Check X-RateLimit-Reset header (with positive jitter)
112
+ ratelimit_reset = _parse_x_ratelimit_reset(response.headers)
113
+ if ratelimit_reset is not None:
114
+ return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS))
115
+
116
+ # 3. Fall back to exponential backoff (with symmetric jitter)
117
+ backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
118
+ return _add_symmetric_jitter(backoff)
119
+
120
+
121
+ def _retry_timeout_from_retries(retries: int) -> float:
122
+ """Determine retry timeout using exponential backoff when no response is available."""
123
+ backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
124
+ return _add_symmetric_jitter(backoff)
125
+
126
+
127
+ def _should_retry(response: httpx.Response) -> bool:
128
+ return response.status_code >= 500 or response.status_code in [429, 408, 409]
129
+
130
+
131
+ _SENSITIVE_HEADERS = frozenset(
132
+ {
133
+ "authorization",
134
+ "www-authenticate",
135
+ "x-api-key",
136
+ "api-key",
137
+ "apikey",
138
+ "x-api-token",
139
+ "x-auth-token",
140
+ "auth-token",
141
+ "cookie",
142
+ "set-cookie",
143
+ "proxy-authorization",
144
+ "proxy-authenticate",
145
+ "x-csrf-token",
146
+ "x-xsrf-token",
147
+ "x-session-token",
148
+ "x-access-token",
149
+ }
150
+ )
151
+
152
+
153
+ def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]:
154
+ return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}
155
+
156
+
157
+ def _build_url(base_url: str, path: typing.Optional[str]) -> str:
158
+ """
159
+ Build a full URL by joining a base URL with a path.
160
+
161
+ This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs)
162
+ by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly
163
+ strip path components when the path starts with '/'.
164
+
165
+ Example:
166
+ >>> _build_url("https://cloud.example.com/org/tenant/api", "/users")
167
+ 'https://cloud.example.com/org/tenant/api/users'
168
+
169
+ Args:
170
+ base_url: The base URL, which may contain path prefixes.
171
+ path: The path to append. Can be None or empty string.
172
+
173
+ Returns:
174
+ The full URL with base_url and path properly joined.
175
+ """
176
+ if not path:
177
+ return base_url
178
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
179
+
180
+
181
+ def _maybe_filter_none_from_multipart_data(
182
+ data: typing.Optional[typing.Any],
183
+ request_files: typing.Optional[RequestFiles],
184
+ force_multipart: typing.Optional[bool],
185
+ ) -> typing.Optional[typing.Any]:
186
+ """
187
+ Filter None values from data body for multipart/form requests.
188
+ This prevents httpx from converting None to empty strings in multipart encoding.
189
+ Only applies when files are present or force_multipart is True.
190
+ """
191
+ if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart):
192
+ return remove_none_from_dict(data)
193
+ return data
194
+
195
+
196
+ def remove_omit_from_dict(
197
+ original: typing.Dict[str, typing.Optional[typing.Any]],
198
+ omit: typing.Optional[typing.Any],
199
+ ) -> typing.Dict[str, typing.Any]:
200
+ if omit is None:
201
+ return original
202
+ new: typing.Dict[str, typing.Any] = {}
203
+ for key, value in original.items():
204
+ if value is not omit:
205
+ new[key] = value
206
+ return new
207
+
208
+
209
+ def maybe_filter_request_body(
210
+ data: typing.Optional[typing.Any],
211
+ request_options: typing.Optional[RequestOptions],
212
+ omit: typing.Optional[typing.Any],
213
+ ) -> typing.Optional[typing.Any]:
214
+ if data is None:
215
+ return (
216
+ jsonable_encoder(request_options.get("additional_body_parameters", {})) or {}
217
+ if request_options is not None
218
+ else None
219
+ )
220
+ elif not isinstance(data, typing.Mapping):
221
+ data_content = jsonable_encoder(data)
222
+ else:
223
+ data_content = {
224
+ **(jsonable_encoder(remove_omit_from_dict(data, omit))), # type: ignore
225
+ **(
226
+ jsonable_encoder(request_options.get("additional_body_parameters", {})) or {}
227
+ if request_options is not None
228
+ else {}
229
+ ),
230
+ }
231
+ return data_content
232
+
233
+
234
+ # Abstracted out for testing purposes
235
+ def get_request_body(
236
+ *,
237
+ json: typing.Optional[typing.Any],
238
+ data: typing.Optional[typing.Any],
239
+ request_options: typing.Optional[RequestOptions],
240
+ omit: typing.Optional[typing.Any],
241
+ ) -> typing.Tuple[typing.Optional[typing.Any], typing.Optional[typing.Any]]:
242
+ json_body = None
243
+ data_body = None
244
+ if data is not None:
245
+ data_body = maybe_filter_request_body(data, request_options, omit)
246
+ else:
247
+ # If both data and json are None, we send json data in the event extra properties are specified
248
+ json_body = maybe_filter_request_body(json, request_options, omit)
249
+
250
+ has_additional_body_parameters = bool(
251
+ request_options is not None and request_options.get("additional_body_parameters")
252
+ )
253
+
254
+ # Only collapse empty dict to None when the body was not explicitly provided
255
+ # and there are no additional body parameters. This preserves explicit empty
256
+ # bodies (e.g., when an endpoint has a request body type but all fields are optional).
257
+ if json_body == {} and json is None and not has_additional_body_parameters:
258
+ json_body = None
259
+ if data_body == {} and data is None and not has_additional_body_parameters:
260
+ data_body = None
261
+
262
+ return json_body, data_body
263
+
264
+
265
+ class HttpClient:
266
+ def __init__(
267
+ self,
268
+ *,
269
+ httpx_client: httpx.Client,
270
+ base_timeout: typing.Callable[[], typing.Optional[float]],
271
+ base_headers: typing.Callable[[], typing.Dict[str, str]],
272
+ base_url: typing.Optional[typing.Callable[[], str]] = None,
273
+ base_max_retries: int = 2,
274
+ logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None,
275
+ ):
276
+ self.base_url = base_url
277
+ self.base_timeout = base_timeout
278
+ self.base_headers = base_headers
279
+ self.base_max_retries = base_max_retries
280
+ self.httpx_client = httpx_client
281
+ self.logger = create_logger(logging_config)
282
+
283
+ def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str:
284
+ base_url = maybe_base_url
285
+ if self.base_url is not None and base_url is None:
286
+ base_url = self.base_url()
287
+
288
+ if base_url is None:
289
+ raise ValueError("A base_url is required to make this request, please provide one and try again.")
290
+ return base_url
291
+
292
+ def request(
293
+ self,
294
+ path: typing.Optional[str] = None,
295
+ *,
296
+ method: str,
297
+ base_url: typing.Optional[str] = None,
298
+ params: typing.Optional[typing.Dict[str, typing.Any]] = None,
299
+ json: typing.Optional[typing.Any] = None,
300
+ data: typing.Optional[typing.Any] = None,
301
+ content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None,
302
+ files: typing.Optional[
303
+ typing.Union[
304
+ typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]],
305
+ typing.List[typing.Tuple[str, File]],
306
+ ]
307
+ ] = None,
308
+ headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
309
+ request_options: typing.Optional[RequestOptions] = None,
310
+ retries: int = 0,
311
+ omit: typing.Optional[typing.Any] = None,
312
+ force_multipart: typing.Optional[bool] = None,
313
+ ) -> httpx.Response:
314
+ base_url = self.get_base_url(base_url)
315
+ _timeout = (
316
+ request_options.get("timeout_in_seconds")
317
+ if request_options is not None and request_options.get("timeout_in_seconds") is not None
318
+ else self.base_timeout()
319
+ )
320
+ timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT
321
+
322
+ json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
323
+
324
+ request_files: typing.Optional[RequestFiles] = (
325
+ convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit))
326
+ if (files is not None and files is not omit and isinstance(files, dict))
327
+ else None
328
+ )
329
+
330
+ if (request_files is None or len(request_files) == 0) and force_multipart:
331
+ request_files = FORCE_MULTIPART
332
+
333
+ data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
334
+
335
+ # Compute encoded params separately to avoid passing empty list to httpx
336
+ # (httpx strips existing query params from URL when params=[] is passed)
337
+ _encoded_params = encode_query(
338
+ jsonable_encoder(
339
+ remove_none_from_dict(
340
+ remove_omit_from_dict(
341
+ {
342
+ **(params if params is not None else {}),
343
+ **(
344
+ request_options.get("additional_query_parameters", {}) or {}
345
+ if request_options is not None
346
+ else {}
347
+ ),
348
+ },
349
+ omit,
350
+ )
351
+ )
352
+ )
353
+ )
354
+
355
+ _request_url = _build_url(base_url, path)
356
+ _request_headers = jsonable_encoder(
357
+ remove_none_from_dict(
358
+ {
359
+ **self.base_headers(),
360
+ **(headers if headers is not None else {}),
361
+ **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
362
+ }
363
+ )
364
+ )
365
+
366
+ if self.logger.is_debug():
367
+ self.logger.debug(
368
+ "Making HTTP request",
369
+ method=method,
370
+ url=_request_url,
371
+ headers=_redact_headers(_request_headers),
372
+ has_body=json_body is not None or data_body is not None,
373
+ )
374
+
375
+ max_retries: int = (
376
+ request_options.get("max_retries", self.base_max_retries)
377
+ if request_options is not None
378
+ else self.base_max_retries
379
+ )
380
+
381
+ try:
382
+ response = self.httpx_client.request(
383
+ method=method,
384
+ url=_request_url,
385
+ headers=_request_headers,
386
+ params=_encoded_params if _encoded_params else None,
387
+ json=json_body,
388
+ data=data_body,
389
+ content=content,
390
+ files=request_files,
391
+ timeout=timeout,
392
+ )
393
+ except (httpx.ConnectError, httpx.RemoteProtocolError):
394
+ if retries < max_retries:
395
+ time.sleep(_retry_timeout_from_retries(retries=retries))
396
+ return self.request(
397
+ path=path,
398
+ method=method,
399
+ base_url=base_url,
400
+ params=params,
401
+ json=json,
402
+ data=data,
403
+ content=content,
404
+ files=files,
405
+ headers=headers,
406
+ request_options=request_options,
407
+ retries=retries + 1,
408
+ omit=omit,
409
+ force_multipart=force_multipart,
410
+ )
411
+ raise
412
+
413
+ if _should_retry(response=response):
414
+ if retries < max_retries:
415
+ time.sleep(_retry_timeout(response=response, retries=retries))
416
+ return self.request(
417
+ path=path,
418
+ method=method,
419
+ base_url=base_url,
420
+ params=params,
421
+ json=json,
422
+ data=data,
423
+ content=content,
424
+ files=files,
425
+ headers=headers,
426
+ request_options=request_options,
427
+ retries=retries + 1,
428
+ omit=omit,
429
+ force_multipart=force_multipart,
430
+ )
431
+
432
+ if self.logger.is_debug():
433
+ if 200 <= response.status_code < 400:
434
+ self.logger.debug(
435
+ "HTTP request succeeded",
436
+ method=method,
437
+ url=_request_url,
438
+ status_code=response.status_code,
439
+ )
440
+
441
+ if self.logger.is_error():
442
+ if response.status_code >= 400:
443
+ self.logger.error(
444
+ "HTTP request failed with error status",
445
+ method=method,
446
+ url=_request_url,
447
+ status_code=response.status_code,
448
+ )
449
+
450
+ return response
451
+
452
+ @contextmanager
453
+ def stream(
454
+ self,
455
+ path: typing.Optional[str] = None,
456
+ *,
457
+ method: str,
458
+ base_url: typing.Optional[str] = None,
459
+ params: typing.Optional[typing.Dict[str, typing.Any]] = None,
460
+ json: typing.Optional[typing.Any] = None,
461
+ data: typing.Optional[typing.Any] = None,
462
+ content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None,
463
+ files: typing.Optional[
464
+ typing.Union[
465
+ typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]],
466
+ typing.List[typing.Tuple[str, File]],
467
+ ]
468
+ ] = None,
469
+ headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
470
+ request_options: typing.Optional[RequestOptions] = None,
471
+ retries: int = 0,
472
+ omit: typing.Optional[typing.Any] = None,
473
+ force_multipart: typing.Optional[bool] = None,
474
+ ) -> typing.Iterator[httpx.Response]:
475
+ base_url = self.get_base_url(base_url)
476
+ _timeout = (
477
+ request_options.get("timeout_in_seconds")
478
+ if request_options is not None and request_options.get("timeout_in_seconds") is not None
479
+ else self.base_timeout()
480
+ )
481
+ timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT
482
+
483
+ request_files: typing.Optional[RequestFiles] = (
484
+ convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit))
485
+ if (files is not None and files is not omit and isinstance(files, dict))
486
+ else None
487
+ )
488
+
489
+ if (request_files is None or len(request_files) == 0) and force_multipart:
490
+ request_files = FORCE_MULTIPART
491
+
492
+ json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
493
+
494
+ data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
495
+
496
+ # Compute encoded params separately to avoid passing empty list to httpx
497
+ # (httpx strips existing query params from URL when params=[] is passed)
498
+ _encoded_params = encode_query(
499
+ jsonable_encoder(
500
+ remove_none_from_dict(
501
+ remove_omit_from_dict(
502
+ {
503
+ **(params if params is not None else {}),
504
+ **(
505
+ request_options.get("additional_query_parameters", {})
506
+ if request_options is not None
507
+ else {}
508
+ ),
509
+ },
510
+ omit,
511
+ )
512
+ )
513
+ )
514
+ )
515
+
516
+ _request_url = _build_url(base_url, path)
517
+ _request_headers = jsonable_encoder(
518
+ remove_none_from_dict(
519
+ {
520
+ **self.base_headers(),
521
+ **(headers if headers is not None else {}),
522
+ **(request_options.get("additional_headers", {}) if request_options is not None else {}),
523
+ }
524
+ )
525
+ )
526
+
527
+ if self.logger.is_debug():
528
+ self.logger.debug(
529
+ "Making streaming HTTP request",
530
+ method=method,
531
+ url=_request_url,
532
+ headers=_redact_headers(_request_headers),
533
+ )
534
+
535
+ with self.httpx_client.stream(
536
+ method=method,
537
+ url=_request_url,
538
+ headers=_request_headers,
539
+ params=_encoded_params if _encoded_params else None,
540
+ json=json_body,
541
+ data=data_body,
542
+ content=content,
543
+ files=request_files,
544
+ timeout=timeout,
545
+ ) as stream:
546
+ yield stream
547
+
548
+
549
+ class AsyncHttpClient:
550
+ def __init__(
551
+ self,
552
+ *,
553
+ httpx_client: httpx.AsyncClient,
554
+ base_timeout: typing.Callable[[], typing.Optional[float]],
555
+ base_headers: typing.Callable[[], typing.Dict[str, str]],
556
+ base_url: typing.Optional[typing.Callable[[], str]] = None,
557
+ base_max_retries: int = 2,
558
+ async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None,
559
+ logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None,
560
+ ):
561
+ self.base_url = base_url
562
+ self.base_timeout = base_timeout
563
+ self.base_headers = base_headers
564
+ self.base_max_retries = base_max_retries
565
+ self.async_base_headers = async_base_headers
566
+ self.httpx_client = httpx_client
567
+ self.logger = create_logger(logging_config)
568
+
569
+ async def _get_headers(self) -> typing.Dict[str, str]:
570
+ if self.async_base_headers is not None:
571
+ return await self.async_base_headers()
572
+ return self.base_headers()
573
+
574
+ def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str:
575
+ base_url = maybe_base_url
576
+ if self.base_url is not None and base_url is None:
577
+ base_url = self.base_url()
578
+
579
+ if base_url is None:
580
+ raise ValueError("A base_url is required to make this request, please provide one and try again.")
581
+ return base_url
582
+
583
+ async def request(
584
+ self,
585
+ path: typing.Optional[str] = None,
586
+ *,
587
+ method: str,
588
+ base_url: typing.Optional[str] = None,
589
+ params: typing.Optional[typing.Dict[str, typing.Any]] = None,
590
+ json: typing.Optional[typing.Any] = None,
591
+ data: typing.Optional[typing.Any] = None,
592
+ content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None,
593
+ files: typing.Optional[
594
+ typing.Union[
595
+ typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]],
596
+ typing.List[typing.Tuple[str, File]],
597
+ ]
598
+ ] = None,
599
+ headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
600
+ request_options: typing.Optional[RequestOptions] = None,
601
+ retries: int = 0,
602
+ omit: typing.Optional[typing.Any] = None,
603
+ force_multipart: typing.Optional[bool] = None,
604
+ ) -> httpx.Response:
605
+ base_url = self.get_base_url(base_url)
606
+ _timeout = (
607
+ request_options.get("timeout_in_seconds")
608
+ if request_options is not None and request_options.get("timeout_in_seconds") is not None
609
+ else self.base_timeout()
610
+ )
611
+ timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT
612
+
613
+ request_files: typing.Optional[RequestFiles] = (
614
+ convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit))
615
+ if (files is not None and files is not omit and isinstance(files, dict))
616
+ else None
617
+ )
618
+
619
+ if (request_files is None or len(request_files) == 0) and force_multipart:
620
+ request_files = FORCE_MULTIPART
621
+
622
+ json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
623
+
624
+ data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
625
+
626
+ # Get headers (supports async token providers)
627
+ _headers = await self._get_headers()
628
+
629
+ # Compute encoded params separately to avoid passing empty list to httpx
630
+ # (httpx strips existing query params from URL when params=[] is passed)
631
+ _encoded_params = encode_query(
632
+ jsonable_encoder(
633
+ remove_none_from_dict(
634
+ remove_omit_from_dict(
635
+ {
636
+ **(params if params is not None else {}),
637
+ **(
638
+ request_options.get("additional_query_parameters", {}) or {}
639
+ if request_options is not None
640
+ else {}
641
+ ),
642
+ },
643
+ omit,
644
+ )
645
+ )
646
+ )
647
+ )
648
+
649
+ _request_url = _build_url(base_url, path)
650
+ _request_headers = jsonable_encoder(
651
+ remove_none_from_dict(
652
+ {
653
+ **_headers,
654
+ **(headers if headers is not None else {}),
655
+ **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
656
+ }
657
+ )
658
+ )
659
+
660
+ if self.logger.is_debug():
661
+ self.logger.debug(
662
+ "Making HTTP request",
663
+ method=method,
664
+ url=_request_url,
665
+ headers=_redact_headers(_request_headers),
666
+ has_body=json_body is not None or data_body is not None,
667
+ )
668
+
669
+ max_retries: int = (
670
+ request_options.get("max_retries", self.base_max_retries)
671
+ if request_options is not None
672
+ else self.base_max_retries
673
+ )
674
+
675
+ try:
676
+ response = await self.httpx_client.request(
677
+ method=method,
678
+ url=_request_url,
679
+ headers=_request_headers,
680
+ params=_encoded_params if _encoded_params else None,
681
+ json=json_body,
682
+ data=data_body,
683
+ content=content,
684
+ files=request_files,
685
+ timeout=timeout,
686
+ )
687
+ except (httpx.ConnectError, httpx.RemoteProtocolError):
688
+ if retries < max_retries:
689
+ await asyncio.sleep(_retry_timeout_from_retries(retries=retries))
690
+ return await self.request(
691
+ path=path,
692
+ method=method,
693
+ base_url=base_url,
694
+ params=params,
695
+ json=json,
696
+ data=data,
697
+ content=content,
698
+ files=files,
699
+ headers=headers,
700
+ request_options=request_options,
701
+ retries=retries + 1,
702
+ omit=omit,
703
+ force_multipart=force_multipart,
704
+ )
705
+ raise
706
+
707
+ if _should_retry(response=response):
708
+ if retries < max_retries:
709
+ await asyncio.sleep(_retry_timeout(response=response, retries=retries))
710
+ return await self.request(
711
+ path=path,
712
+ method=method,
713
+ base_url=base_url,
714
+ params=params,
715
+ json=json,
716
+ data=data,
717
+ content=content,
718
+ files=files,
719
+ headers=headers,
720
+ request_options=request_options,
721
+ retries=retries + 1,
722
+ omit=omit,
723
+ force_multipart=force_multipart,
724
+ )
725
+
726
+ if self.logger.is_debug():
727
+ if 200 <= response.status_code < 400:
728
+ self.logger.debug(
729
+ "HTTP request succeeded",
730
+ method=method,
731
+ url=_request_url,
732
+ status_code=response.status_code,
733
+ )
734
+
735
+ if self.logger.is_error():
736
+ if response.status_code >= 400:
737
+ self.logger.error(
738
+ "HTTP request failed with error status",
739
+ method=method,
740
+ url=_request_url,
741
+ status_code=response.status_code,
742
+ )
743
+
744
+ return response
745
+
746
+ @asynccontextmanager
747
+ async def stream(
748
+ self,
749
+ path: typing.Optional[str] = None,
750
+ *,
751
+ method: str,
752
+ base_url: typing.Optional[str] = None,
753
+ params: typing.Optional[typing.Dict[str, typing.Any]] = None,
754
+ json: typing.Optional[typing.Any] = None,
755
+ data: typing.Optional[typing.Any] = None,
756
+ content: typing.Optional[typing.Union[bytes, typing.Iterator[bytes], typing.AsyncIterator[bytes]]] = None,
757
+ files: typing.Optional[
758
+ typing.Union[
759
+ typing.Dict[str, typing.Optional[typing.Union[File, typing.List[File]]]],
760
+ typing.List[typing.Tuple[str, File]],
761
+ ]
762
+ ] = None,
763
+ headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
764
+ request_options: typing.Optional[RequestOptions] = None,
765
+ retries: int = 0,
766
+ omit: typing.Optional[typing.Any] = None,
767
+ force_multipart: typing.Optional[bool] = None,
768
+ ) -> typing.AsyncIterator[httpx.Response]:
769
+ base_url = self.get_base_url(base_url)
770
+ _timeout = (
771
+ request_options.get("timeout_in_seconds")
772
+ if request_options is not None and request_options.get("timeout_in_seconds") is not None
773
+ else self.base_timeout()
774
+ )
775
+ timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT
776
+
777
+ request_files: typing.Optional[RequestFiles] = (
778
+ convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit))
779
+ if (files is not None and files is not omit and isinstance(files, dict))
780
+ else None
781
+ )
782
+
783
+ if (request_files is None or len(request_files) == 0) and force_multipart:
784
+ request_files = FORCE_MULTIPART
785
+
786
+ json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit)
787
+
788
+ data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart)
789
+
790
+ # Get headers (supports async token providers)
791
+ _headers = await self._get_headers()
792
+
793
+ # Compute encoded params separately to avoid passing empty list to httpx
794
+ # (httpx strips existing query params from URL when params=[] is passed)
795
+ _encoded_params = encode_query(
796
+ jsonable_encoder(
797
+ remove_none_from_dict(
798
+ remove_omit_from_dict(
799
+ {
800
+ **(params if params is not None else {}),
801
+ **(
802
+ request_options.get("additional_query_parameters", {})
803
+ if request_options is not None
804
+ else {}
805
+ ),
806
+ },
807
+ omit=omit,
808
+ )
809
+ )
810
+ )
811
+ )
812
+
813
+ _request_url = _build_url(base_url, path)
814
+ _request_headers = jsonable_encoder(
815
+ remove_none_from_dict(
816
+ {
817
+ **_headers,
818
+ **(headers if headers is not None else {}),
819
+ **(request_options.get("additional_headers", {}) if request_options is not None else {}),
820
+ }
821
+ )
822
+ )
823
+
824
+ if self.logger.is_debug():
825
+ self.logger.debug(
826
+ "Making streaming HTTP request",
827
+ method=method,
828
+ url=_request_url,
829
+ headers=_redact_headers(_request_headers),
830
+ )
831
+
832
+ async with self.httpx_client.stream(
833
+ method=method,
834
+ url=_request_url,
835
+ headers=_request_headers,
836
+ params=_encoded_params if _encoded_params else None,
837
+ json=json_body,
838
+ data=data_body,
839
+ content=content,
840
+ files=request_files,
841
+ timeout=timeout,
842
+ ) as stream:
843
+ yield stream