typeless-sdk 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.
typeless/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """official python sdk for the typeless transcript api"""
2
+
3
+ import logging
4
+
5
+ from typeless._version import __version__
6
+ from typeless._ws import AsyncStreamConnection, StreamConnection
7
+ from typeless.async_client import AsyncTypelessClient
8
+ from typeless.client import TypelessClient
9
+ from typeless.exceptions import (
10
+ APIConnectionError,
11
+ APITimeoutError,
12
+ AudioError,
13
+ AudioFormatError,
14
+ AudioTooLargeError,
15
+ AudioTooLongError,
16
+ AuthenticationError,
17
+ ConcurrentLimitError,
18
+ InsufficientBalanceError,
19
+ InvalidRequestError,
20
+ RateLimitError,
21
+ ServerError,
22
+ TypelessError,
23
+ )
24
+ from typeless.models import (
25
+ AudioReceived,
26
+ StreamMessage,
27
+ StreamResult,
28
+ TranscriptResult,
29
+ UsageInfo,
30
+ )
31
+
32
+ logging.getLogger("typeless").addHandler(logging.NullHandler())
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "AsyncTypelessClient",
37
+ "AsyncStreamConnection",
38
+ "TypelessClient",
39
+ "StreamConnection",
40
+ "TranscriptResult",
41
+ "UsageInfo",
42
+ "AudioReceived",
43
+ "StreamResult",
44
+ "StreamMessage",
45
+ "TypelessError",
46
+ "AuthenticationError",
47
+ "InvalidRequestError",
48
+ "RateLimitError",
49
+ "ConcurrentLimitError",
50
+ "InsufficientBalanceError",
51
+ "AudioError",
52
+ "AudioTooLongError",
53
+ "AudioFormatError",
54
+ "AudioTooLargeError",
55
+ "APIConnectionError",
56
+ "APITimeoutError",
57
+ "ServerError",
58
+ ]
@@ -0,0 +1,380 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import time
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any, BinaryIO
8
+ from urllib.parse import urlencode, urlsplit
9
+
10
+ from typeless._constants import (
11
+ DEFAULT_AUDIO_FILENAME,
12
+ DEFAULT_BASE_URL,
13
+ ENV_API_KEY,
14
+ ENV_BASE_URL,
15
+ STREAM_PATH,
16
+ TRANSCRIBE_PATH,
17
+ )
18
+ from typeless._retry import backoff_delay, is_retryable
19
+ from typeless._transport import (
20
+ AsyncTransport,
21
+ MultipartFile,
22
+ Request,
23
+ Response,
24
+ Transport,
25
+ TransportConnectionError,
26
+ TransportTimeoutError,
27
+ create_default_async_transport,
28
+ create_default_transport,
29
+ )
30
+ from typeless._utils import build_user_agent
31
+ from typeless.exceptions import (
32
+ APIConnectionError,
33
+ APITimeoutError,
34
+ AuthenticationError,
35
+ ServerError,
36
+ exception_from_response,
37
+ )
38
+ from typeless.models import TranscriptResult, parse_transcript_result
39
+
40
+ logger = logging.getLogger("typeless")
41
+
42
+ AudioInput = str | Path | bytes | BinaryIO
43
+
44
+
45
+ def _normalize_base_url(base_url: str) -> str:
46
+ """ensure base_url has an http or https scheme for http and websocket endpoints"""
47
+ base_url = base_url.rstrip("/")
48
+ scheme = urlsplit(base_url).scheme
49
+ if not scheme:
50
+ return f"https://{base_url}"
51
+ if scheme in ("http", "https"):
52
+ return base_url
53
+ raise ValueError("base_url must use http:// or https://")
54
+
55
+
56
+ def _filename_from_stream(stream: BinaryIO) -> str | None:
57
+ """derive a base filename from a binary stream name attribute when available"""
58
+ name = getattr(stream, "name", None)
59
+ if isinstance(name, str) and name:
60
+ return Path(name).name
61
+ return None
62
+
63
+
64
+ def _build_audio_part(audio: AudioInput) -> MultipartFile:
65
+ """normalize an audio input into a multipart file part with a non empty filename"""
66
+ if isinstance(audio, str):
67
+ audio = Path(audio)
68
+ if isinstance(audio, Path):
69
+ return MultipartFile(
70
+ field_name="audio",
71
+ source=audio,
72
+ filename=audio.name or DEFAULT_AUDIO_FILENAME,
73
+ )
74
+ if isinstance(audio, bytes | bytearray):
75
+ return MultipartFile(
76
+ field_name="audio",
77
+ source=bytes(audio),
78
+ filename=DEFAULT_AUDIO_FILENAME,
79
+ )
80
+ return MultipartFile(
81
+ field_name="audio",
82
+ source=audio,
83
+ filename=_filename_from_stream(audio) or DEFAULT_AUDIO_FILENAME,
84
+ )
85
+
86
+
87
+ class BaseClient:
88
+ """shared configuration and pure request logic for the typeless clients"""
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ api_key: str | None,
94
+ base_url: str | None,
95
+ timeout: float,
96
+ max_retries: int,
97
+ ) -> None:
98
+ """resolve the api key base url timeout and retry budget"""
99
+ resolved_key = api_key or os.environ.get(ENV_API_KEY)
100
+ if not resolved_key:
101
+ raise AuthenticationError(
102
+ "the api key must be set either by passing api_key "
103
+ "or by setting the TYPELESS_API_KEY environment variable"
104
+ )
105
+ self._api_key = resolved_key
106
+ self._base_url = _normalize_base_url(base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL)
107
+ self._timeout = timeout
108
+ self._max_retries = max_retries
109
+
110
+ def _transcribe_url(self) -> str:
111
+ """build the absolute url for the transcription endpoint"""
112
+ return f"{self._base_url}{TRANSCRIBE_PATH}"
113
+
114
+ def _build_stream_url(
115
+ self,
116
+ *,
117
+ model: str,
118
+ sample_rate: int,
119
+ encoding: str,
120
+ channels: int,
121
+ language: str | None,
122
+ ) -> str:
123
+ """build the websocket url with the connection query parameters"""
124
+ base = self._base_url
125
+ if base.startswith("https://"):
126
+ ws_base = "wss://" + base[len("https://") :]
127
+ elif base.startswith("http://"):
128
+ ws_base = "ws://" + base[len("http://") :]
129
+ else:
130
+ raise ValueError("base_url must use http:// or https://")
131
+ params = {
132
+ "token": self._api_key,
133
+ "model": model,
134
+ "sample_rate": str(sample_rate),
135
+ "encoding": encoding,
136
+ "channels": str(channels),
137
+ }
138
+ if language is not None:
139
+ params["language"] = language
140
+ return f"{ws_base}{STREAM_PATH}?{urlencode(params)}"
141
+
142
+ def _build_headers(self) -> dict[str, str]:
143
+ """build the authorization and user agent headers"""
144
+ return {
145
+ "Authorization": f"Token {self._api_key}",
146
+ "User-Agent": build_user_agent(),
147
+ }
148
+
149
+ def _build_transcribe_request(
150
+ self,
151
+ audio: AudioInput,
152
+ *,
153
+ model: str,
154
+ language: str | None,
155
+ ) -> Request:
156
+ """build the multipart request for a transcription call"""
157
+ data: dict[str, str] = {"model": model}
158
+ if language is not None:
159
+ data["language"] = language
160
+ return Request(
161
+ method="POST",
162
+ url=self._transcribe_url(),
163
+ headers=self._build_headers(),
164
+ data=data,
165
+ files=[_build_audio_part(audio)],
166
+ timeout=self._timeout,
167
+ )
168
+
169
+ def _parse_success(self, response: Response[Any]) -> TranscriptResult:
170
+ """parse a success response body into a transcript result"""
171
+ try:
172
+ body = response.json
173
+ except (ValueError, TypeError) as exc:
174
+ raise ServerError("the response body is not valid json") from exc
175
+ if not isinstance(body, dict):
176
+ raise ServerError("the response body is not a json object")
177
+ return parse_transcript_result(body)
178
+
179
+ def _retry_after_seconds(self, headers: Mapping[str, str]) -> int | None:
180
+ """read the retry after header as a number of seconds"""
181
+ value = None
182
+ for key, header_value in headers.items():
183
+ if key.lower() == "retry-after":
184
+ value = header_value
185
+ break
186
+ if value is None:
187
+ return None
188
+ try:
189
+ return int(value)
190
+ except (ValueError, TypeError):
191
+ return None
192
+
193
+ def _extract_error(
194
+ self,
195
+ response: Response[Any],
196
+ ) -> tuple[str | None, str | None, dict[str, object] | None, str | None, int | None]:
197
+ """extract the code message details request id and retry after from a non success response"""
198
+ try:
199
+ body = response.json
200
+ except (ValueError, TypeError):
201
+ body = {}
202
+ if not isinstance(body, dict):
203
+ body = {}
204
+ error = body.get("error")
205
+ error = error if isinstance(error, dict) else {}
206
+ code = error.get("code")
207
+ message = error.get("message")
208
+ details = error.get("details")
209
+ request_id = body.get("request_id")
210
+ return (
211
+ code if isinstance(code, str) else None,
212
+ message if isinstance(message, str) else None,
213
+ details if isinstance(details, dict) else None,
214
+ request_id if isinstance(request_id, str) else None,
215
+ self._retry_after_seconds(response.headers),
216
+ )
217
+
218
+ def _can_retry(
219
+ self,
220
+ *,
221
+ attempt: int,
222
+ request: Request,
223
+ code: str | None,
224
+ status_code: int,
225
+ ) -> bool:
226
+ """decide whether a failed response can be retried"""
227
+ if attempt >= self._max_retries or not request.is_replayable:
228
+ return False
229
+ return is_retryable(code=code, status_code=status_code)
230
+
231
+ def _can_retry_connection(self, *, attempt: int, request: Request) -> bool:
232
+ """decide whether a connection failure can be retried"""
233
+ return attempt < self._max_retries and request.is_replayable
234
+
235
+ def _log_send(self, request: Request) -> None:
236
+ """log the outgoing request method url and form fields"""
237
+ logger.debug("sending request %s %s fields %s", request.method, request.url, request.data)
238
+
239
+ def _connection_retry_delay(self, exc: TransportConnectionError, *, attempt: int, request: Request) -> float:
240
+ """return the backoff delay for a connection failure or raise when it cannot be retried"""
241
+ if not self._can_retry_connection(attempt=attempt, request=request):
242
+ raise APIConnectionError("the connection to the api failed") from exc
243
+ delay = backoff_delay(attempt)
244
+ logger.warning("retrying after connection error in %.2f seconds attempt %d", delay, attempt + 1)
245
+ return delay
246
+
247
+ def _response_retry_delay(self, response: Response[Any], *, attempt: int, request: Request) -> float | None:
248
+ """return none for a final response or a backoff delay or raise the mapped error"""
249
+ logger.debug("received response status %d", response.status_code)
250
+ if response.status_code < 400:
251
+ return None
252
+ code, message, details, request_id, retry_after = self._extract_error(response)
253
+ if self._can_retry(
254
+ attempt=attempt,
255
+ request=request,
256
+ code=code,
257
+ status_code=response.status_code,
258
+ ):
259
+ delay = backoff_delay(attempt, retry_after=retry_after)
260
+ logger.warning(
261
+ "retrying after status %d in %.2f seconds attempt %d",
262
+ response.status_code,
263
+ delay,
264
+ attempt + 1,
265
+ )
266
+ return delay
267
+ raise exception_from_response(
268
+ code=code,
269
+ message=message,
270
+ request_id=request_id,
271
+ details=details,
272
+ retry_after=retry_after,
273
+ status_code=response.status_code,
274
+ )
275
+
276
+
277
+ class SyncAPIClient(BaseClient):
278
+ """synchronous client that owns a transport and runs the retry loop"""
279
+
280
+ def __init__(
281
+ self,
282
+ *,
283
+ api_key: str | None,
284
+ base_url: str | None,
285
+ timeout: float,
286
+ max_retries: int,
287
+ transport: Transport | None = None,
288
+ ) -> None:
289
+ """initialize the shared configuration and the synchronous transport"""
290
+ super().__init__(
291
+ api_key=api_key,
292
+ base_url=base_url,
293
+ timeout=timeout,
294
+ max_retries=max_retries,
295
+ )
296
+ self._transport = transport if transport is not None else create_default_transport(timeout=self._timeout)
297
+
298
+ def _request(self, request: Request) -> Response[Any]:
299
+ """send a request and retry transient failures with exponential backoff"""
300
+ attempt = 0
301
+ while True:
302
+ self._log_send(request)
303
+ try:
304
+ response = self._transport.send(request)
305
+ except TransportTimeoutError as exc:
306
+ raise APITimeoutError("the request timed out") from exc
307
+ except TransportConnectionError as exc:
308
+ time.sleep(self._connection_retry_delay(exc, attempt=attempt, request=request))
309
+ attempt += 1
310
+ continue
311
+ delay = self._response_retry_delay(response, attempt=attempt, request=request)
312
+ if delay is None:
313
+ return response
314
+ time.sleep(delay)
315
+ attempt += 1
316
+
317
+ def close(self) -> None:
318
+ """close the client and release the underlying connection pool"""
319
+ self._transport.close()
320
+
321
+ def __enter__(self) -> "SyncAPIClient":
322
+ """enter the runtime context and return the client"""
323
+ return self
324
+
325
+ def __exit__(self, *args: object) -> None:
326
+ """exit the runtime context and close the client"""
327
+ self.close()
328
+
329
+
330
+ class AsyncAPIClient(BaseClient):
331
+ """asynchronous client that owns a transport and runs the retry loop"""
332
+
333
+ def __init__(
334
+ self,
335
+ *,
336
+ api_key: str | None,
337
+ base_url: str | None,
338
+ timeout: float,
339
+ max_retries: int,
340
+ transport: AsyncTransport | None = None,
341
+ ) -> None:
342
+ """initialize the shared configuration and the asynchronous transport"""
343
+ super().__init__(
344
+ api_key=api_key,
345
+ base_url=base_url,
346
+ timeout=timeout,
347
+ max_retries=max_retries,
348
+ )
349
+ self._transport = transport if transport is not None else create_default_async_transport(timeout=self._timeout)
350
+
351
+ async def _request(self, request: Request) -> Response[Any]:
352
+ """send a request and retry transient failures with exponential backoff"""
353
+ attempt = 0
354
+ while True:
355
+ self._log_send(request)
356
+ try:
357
+ response = await self._transport.send(request)
358
+ except TransportTimeoutError as exc:
359
+ raise APITimeoutError("the request timed out") from exc
360
+ except TransportConnectionError as exc:
361
+ await asyncio.sleep(self._connection_retry_delay(exc, attempt=attempt, request=request))
362
+ attempt += 1
363
+ continue
364
+ delay = self._response_retry_delay(response, attempt=attempt, request=request)
365
+ if delay is None:
366
+ return response
367
+ await asyncio.sleep(delay)
368
+ attempt += 1
369
+
370
+ async def close(self) -> None:
371
+ """close the client and release the underlying connection pool"""
372
+ await self._transport.close()
373
+
374
+ async def __aenter__(self) -> "AsyncAPIClient":
375
+ """enter the runtime context and return the client"""
376
+ return self
377
+
378
+ async def __aexit__(self, *args: object) -> None:
379
+ """exit the runtime context and close the client"""
380
+ await self.close()
typeless/_constants.py ADDED
@@ -0,0 +1,68 @@
1
+ # default configuration values and api error code constants
2
+ DEFAULT_BASE_URL = "https://api.typelessapi.com"
3
+ TRANSCRIBE_PATH = "/v1/transcribe"
4
+ STREAM_PATH = "/v1/transcribe/stream"
5
+
6
+ ENV_API_KEY = "TYPELESS_API_KEY"
7
+ ENV_BASE_URL = "TYPELESS_BASE_URL"
8
+
9
+ DEFAULT_TIMEOUT_SECONDS = 300.0
10
+ DEFAULT_MAX_RETRIES = 3
11
+
12
+ MAX_CONNECTIONS = 100
13
+ MAX_KEEPALIVE_CONNECTIONS = 20
14
+
15
+ DEFAULT_SAMPLE_RATE = 16000
16
+ DEFAULT_CHANNELS = 1
17
+ DEFAULT_ENCODING = "pcm16"
18
+
19
+ KEEP_ALIVE_INTERVAL_SECONDS = 20.0
20
+ WS_RECEIVE_POLL_SECONDS = 0.5
21
+
22
+ RETRY_BACKOFF_BASE_SECONDS = 1.0
23
+ RETRY_BACKOFF_FACTOR = 2.0
24
+ RETRY_MAX_DELAY_SECONDS = 60.0
25
+ RETRY_JITTER_RATIO = 0.2
26
+
27
+ RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
28
+ NON_RETRYABLE_STATUS_CODES = frozenset({400, 401, 402, 403, 413})
29
+
30
+ DEFAULT_AUDIO_FILENAME = "audio"
31
+
32
+ ERROR_INVALID_REQUEST = "INVALID_REQUEST"
33
+ ERROR_INVALID_API_KEY = "INVALID_API_KEY"
34
+ ERROR_MISSING_API_KEY = "MISSING_API_KEY"
35
+ ERROR_ACCOUNT_SUSPENDED = "ACCOUNT_SUSPENDED"
36
+ ERROR_RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
37
+ ERROR_CONCURRENT_LIMIT_EXCEEDED = "CONCURRENT_LIMIT_EXCEEDED"
38
+ ERROR_INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
39
+ ERROR_AUDIO_TOO_LONG = "AUDIO_TOO_LONG"
40
+ ERROR_AUDIO_FORMAT_UNSUPPORTED = "AUDIO_FORMAT_UNSUPPORTED"
41
+ ERROR_FILE_TOO_LARGE = "FILE_TOO_LARGE"
42
+ ERROR_TRANSCRIPTION_FAILED = "TRANSCRIPTION_FAILED"
43
+ ERROR_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
44
+ ERROR_INTERNAL_ERROR = "INTERNAL_ERROR"
45
+ ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
46
+
47
+ RETRYABLE_ERROR_CODES = frozenset(
48
+ {
49
+ ERROR_RATE_LIMIT_EXCEEDED,
50
+ ERROR_TRANSCRIPTION_FAILED,
51
+ ERROR_SERVICE_UNAVAILABLE,
52
+ ERROR_INTERNAL_ERROR,
53
+ }
54
+ )
55
+ NON_RETRYABLE_ERROR_CODES = frozenset(
56
+ {
57
+ ERROR_INVALID_REQUEST,
58
+ ERROR_INVALID_API_KEY,
59
+ ERROR_MISSING_API_KEY,
60
+ ERROR_ACCOUNT_SUSPENDED,
61
+ ERROR_CONCURRENT_LIMIT_EXCEEDED,
62
+ ERROR_INSUFFICIENT_BALANCE,
63
+ ERROR_AUDIO_TOO_LONG,
64
+ ERROR_AUDIO_FORMAT_UNSUPPORTED,
65
+ ERROR_FILE_TOO_LARGE,
66
+ ERROR_QUOTA_EXCEEDED,
67
+ }
68
+ )
typeless/_retry.py ADDED
@@ -0,0 +1,41 @@
1
+ import math
2
+ import random
3
+
4
+ from typeless._constants import (
5
+ NON_RETRYABLE_ERROR_CODES,
6
+ RETRY_BACKOFF_BASE_SECONDS,
7
+ RETRY_BACKOFF_FACTOR,
8
+ RETRY_JITTER_RATIO,
9
+ RETRY_MAX_DELAY_SECONDS,
10
+ RETRYABLE_ERROR_CODES,
11
+ RETRYABLE_STATUS_CODES,
12
+ )
13
+
14
+
15
+ def is_retryable(*, code: str | None, status_code: int | None) -> bool:
16
+ """return whether a failed request should be retried"""
17
+ if code is not None:
18
+ if code in NON_RETRYABLE_ERROR_CODES:
19
+ return False
20
+ if code in RETRYABLE_ERROR_CODES:
21
+ return True
22
+
23
+ if status_code is not None:
24
+ return status_code in RETRYABLE_STATUS_CODES
25
+
26
+ return False
27
+
28
+
29
+ def backoff_delay(attempt: int, *, retry_after: float | None = None) -> float:
30
+ """compute the delay before the next retry attempt"""
31
+ if retry_after is not None:
32
+ delay = float(retry_after)
33
+ if math.isfinite(delay) and delay >= 0:
34
+ return min(delay, RETRY_MAX_DELAY_SECONDS)
35
+
36
+ base = min(
37
+ RETRY_BACKOFF_BASE_SECONDS * (RETRY_BACKOFF_FACTOR**attempt),
38
+ RETRY_MAX_DELAY_SECONDS,
39
+ )
40
+ jitter = base * RETRY_JITTER_RATIO * random.random()
41
+ return min(base + jitter, RETRY_MAX_DELAY_SECONDS)
@@ -0,0 +1,38 @@
1
+ from typeless._constants import DEFAULT_TIMEOUT_SECONDS
2
+ from typeless._transport.base import (
3
+ AsyncTransport,
4
+ MultipartFile,
5
+ Request,
6
+ Response,
7
+ Transport,
8
+ TransportConnectionError,
9
+ TransportError,
10
+ TransportTimeoutError,
11
+ )
12
+ from typeless._transport.httpx_backend import HttpxAsyncTransport, HttpxTransport
13
+
14
+
15
+ def create_default_transport(*, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Transport:
16
+ """create the default synchronous transport"""
17
+ return HttpxTransport(timeout=timeout)
18
+
19
+
20
+ def create_default_async_transport(*, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> AsyncTransport:
21
+ """create the default asynchronous transport"""
22
+ return HttpxAsyncTransport(timeout=timeout)
23
+
24
+
25
+ __all__ = [
26
+ "AsyncTransport",
27
+ "HttpxAsyncTransport",
28
+ "HttpxTransport",
29
+ "MultipartFile",
30
+ "Request",
31
+ "Response",
32
+ "Transport",
33
+ "TransportConnectionError",
34
+ "TransportError",
35
+ "TransportTimeoutError",
36
+ "create_default_async_transport",
37
+ "create_default_transport",
38
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from functools import cached_property
7
+ from pathlib import Path
8
+ from typing import Any, BinaryIO, Generic, Protocol, TypeVar
9
+
10
+ RawResponseT = TypeVar("RawResponseT")
11
+
12
+ FileSource = bytes | Path | BinaryIO
13
+
14
+
15
+ def _source_is_replayable(source: FileSource) -> bool:
16
+ """whether a multipart source can be sent again for a retry"""
17
+ if isinstance(source, bytes | Path):
18
+ return True
19
+ return source.seekable()
20
+
21
+
22
+ @dataclass
23
+ class MultipartFile:
24
+ """neutral description of a single multipart file part"""
25
+
26
+ field_name: str
27
+ source: FileSource
28
+ filename: str | None = None
29
+ content_type: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class Request:
34
+ """library agnostic http request"""
35
+
36
+ method: str
37
+ url: str
38
+ headers: dict[str, str] = field(default_factory=dict)
39
+ params: dict[str, str] = field(default_factory=dict)
40
+ data: dict[str, str] = field(default_factory=dict)
41
+ files: list[MultipartFile] = field(default_factory=list)
42
+ content: bytes | None = None
43
+ timeout: float | None = None
44
+
45
+ @property
46
+ def is_replayable(self) -> bool:
47
+ """whether the request body can be resent for a retry"""
48
+ return all(_source_is_replayable(item.source) for item in self.files)
49
+
50
+
51
+ @dataclass
52
+ class Response(Generic[RawResponseT]):
53
+ """library agnostic http response"""
54
+
55
+ status_code: int
56
+ headers: Mapping[str, str]
57
+ content: bytes
58
+ raw_response: RawResponseT
59
+
60
+ @cached_property
61
+ def json(self) -> Any:
62
+ """decode the response body as json"""
63
+ return json.loads(self.content)
64
+
65
+
66
+ class Transport(Protocol):
67
+ """synchronous transport interface"""
68
+
69
+ def send(self, request: Request) -> Response[Any]:
70
+ """send a request and return a response"""
71
+ ...
72
+
73
+ def close(self) -> None:
74
+ """release the underlying resources"""
75
+ ...
76
+
77
+
78
+ class AsyncTransport(Protocol):
79
+ """asynchronous transport interface"""
80
+
81
+ async def send(self, request: Request) -> Response[Any]:
82
+ """send a request and return a response"""
83
+ ...
84
+
85
+ async def close(self) -> None:
86
+ """release the underlying resources"""
87
+ ...
88
+
89
+
90
+ class TransportError(Exception):
91
+ """base class for transport level failures"""
92
+
93
+ pass
94
+
95
+
96
+ class TransportConnectionError(TransportError):
97
+ """raised when the connection cannot be established"""
98
+
99
+ pass
100
+
101
+
102
+ class TransportTimeoutError(TransportError):
103
+ """raised when the request exceeds the configured timeout"""
104
+
105
+ pass