useyona-einvoice 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,97 @@
1
+ """Official Python SDK for the Yona e-invoicing API: what an API key may call.
2
+
3
+ from useyona.einvoice import EInvoice
4
+
5
+ client = EInvoice(api_key=os.environ["YONA_API_KEY"])
6
+ invoice = client.invoices.create({...})
7
+ """
8
+
9
+ from . import types
10
+ from ._transport import (
11
+ API_KEY_PATTERN,
12
+ DEFAULT_BASE_URL,
13
+ IDEMPOTENCY_KEY_PATTERN,
14
+ HttpClient,
15
+ RawResponse,
16
+ RetryConfig,
17
+ generate_idempotency_key,
18
+ mode_of_api_key,
19
+ parse_retry_after,
20
+ )
21
+ from ._version import __version__
22
+ from .client import EInvoice, paginate
23
+ from .errors import (
24
+ ApiErrorDetail,
25
+ EInvoiceApiError,
26
+ EInvoiceAuthenticationError,
27
+ EInvoiceConfigError,
28
+ EInvoiceConflictError,
29
+ EInvoiceConnectionError,
30
+ EInvoiceError,
31
+ EInvoiceInsufficientCreditsError,
32
+ EInvoiceNotFoundError,
33
+ EInvoicePermissionError,
34
+ EInvoiceRateLimitError,
35
+ EInvoiceServerError,
36
+ EInvoiceTimeoutError,
37
+ EInvoiceValidationError,
38
+ EInvoiceWebhookError,
39
+ )
40
+ from .excluded_operations import EXCLUDED_OPERATIONS, ExcludedOperation
41
+ from .types import ApiMode, BinaryResponse, Page, Paginated, PaginationMeta, RequestOptions, WebhookEvent
42
+ from .webhooks import (
43
+ DEFAULT_TOLERANCE_SECONDS,
44
+ WEBHOOK_SIGNATURE_HEADER,
45
+ SignatureHeader,
46
+ compute_webhook_signature,
47
+ parse_signature_header,
48
+ sign_webhook_payload,
49
+ verify_webhook,
50
+ )
51
+
52
+ __all__ = [
53
+ "API_KEY_PATTERN",
54
+ "DEFAULT_BASE_URL",
55
+ "DEFAULT_TOLERANCE_SECONDS",
56
+ "EXCLUDED_OPERATIONS",
57
+ "IDEMPOTENCY_KEY_PATTERN",
58
+ "WEBHOOK_SIGNATURE_HEADER",
59
+ "ApiErrorDetail",
60
+ "ApiMode",
61
+ "BinaryResponse",
62
+ "EInvoice",
63
+ "EInvoiceApiError",
64
+ "EInvoiceAuthenticationError",
65
+ "EInvoiceConfigError",
66
+ "EInvoiceConflictError",
67
+ "EInvoiceConnectionError",
68
+ "EInvoiceError",
69
+ "EInvoiceInsufficientCreditsError",
70
+ "EInvoiceNotFoundError",
71
+ "EInvoicePermissionError",
72
+ "EInvoiceRateLimitError",
73
+ "EInvoiceServerError",
74
+ "EInvoiceTimeoutError",
75
+ "EInvoiceValidationError",
76
+ "EInvoiceWebhookError",
77
+ "ExcludedOperation",
78
+ "HttpClient",
79
+ "Page",
80
+ "Paginated",
81
+ "PaginationMeta",
82
+ "RawResponse",
83
+ "RequestOptions",
84
+ "RetryConfig",
85
+ "SignatureHeader",
86
+ "WebhookEvent",
87
+ "__version__",
88
+ "compute_webhook_signature",
89
+ "generate_idempotency_key",
90
+ "mode_of_api_key",
91
+ "paginate",
92
+ "parse_retry_after",
93
+ "parse_signature_header",
94
+ "sign_webhook_payload",
95
+ "types",
96
+ "verify_webhook",
97
+ ]
@@ -0,0 +1,82 @@
1
+ """Shared plumbing of every service: unwraps the ``{meta, data}`` envelope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from ._transport import HttpClient
10
+ from .types import EMPTY_PAGINATION, BinaryResponse, Page, Paginated, RequestOptions
11
+
12
+
13
+ def seg(value: str | int) -> str:
14
+ """Encodes one path segment."""
15
+ return quote(str(value), safe="")
16
+
17
+
18
+ class BaseService:
19
+ """Shared plumbing of every service."""
20
+
21
+ def __init__(self, http: HttpClient) -> None:
22
+ self._http = http
23
+
24
+ def _call(
25
+ self,
26
+ method: str,
27
+ path: str,
28
+ *,
29
+ query: Mapping[str, Any] | None = None,
30
+ body: Any = None,
31
+ idempotent: bool = False,
32
+ headers: Mapping[str, str] | None = None,
33
+ options: RequestOptions | None = None,
34
+ ) -> Any:
35
+ """One call; returns ``data``."""
36
+ return self._http.request(method, path, query=query, body=body, idempotent=idempotent, headers=headers, options=options).data
37
+
38
+ def _page(
39
+ self,
40
+ method: str,
41
+ path: str,
42
+ *,
43
+ query: Mapping[str, Any] | None = None,
44
+ options: RequestOptions | None = None,
45
+ ) -> Page[Any]:
46
+ """A list call; returns the items and ``meta.pagination``."""
47
+ res = self._http.request(method, path, query=query, options=options)
48
+ pagination = (res.meta or {}).get("pagination") or EMPTY_PAGINATION
49
+ return Page(data=list(res.data) if isinstance(res.data, list) else [], pagination=pagination, request_id=res.request_id)
50
+
51
+ def _paginated(
52
+ self,
53
+ method: str,
54
+ path: str,
55
+ *,
56
+ query: Mapping[str, Any] | None = None,
57
+ options: RequestOptions | None = None,
58
+ ) -> Paginated[Any]:
59
+ """A call whose ``data`` is an object and whose ``meta`` also carries pagination."""
60
+ res = self._http.request(method, path, query=query, options=options)
61
+ return Paginated(data=res.data, pagination=(res.meta or {}).get("pagination"), request_id=res.request_id)
62
+
63
+ def _binary(
64
+ self,
65
+ method: str,
66
+ path: str,
67
+ *,
68
+ query: Mapping[str, Any] | None = None,
69
+ idempotent: bool = False,
70
+ options: RequestOptions | None = None,
71
+ ) -> BinaryResponse:
72
+ """A binary download."""
73
+ res = self._http.request(
74
+ method, path, query=query, idempotent=idempotent, binary=True, headers={"Accept": "application/pdf"}, options=options
75
+ )
76
+ data = res.data
77
+ if isinstance(data, BinaryResponse):
78
+ return data
79
+ # The route answered JSON (e.g. a mocked transport): hand it back as bytes so the caller sees what came.
80
+ import json
81
+
82
+ return BinaryResponse(data=json.dumps(data).encode("utf-8"), content_type="application/json", request_id=res.request_id)
@@ -0,0 +1,399 @@
1
+ """The transport: authentication, the envelope, errors, idempotency keys, retries and timeouts.
2
+ Services call :meth:`HttpClient.request`; integrators normally never touch it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import random
8
+ import re
9
+ import time
10
+ import uuid
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from email.utils import parsedate_to_datetime
14
+ from typing import Any
15
+ from urllib.parse import urlencode, urlsplit
16
+
17
+ import httpx
18
+
19
+ from ._version import __version__
20
+ from .errors import (
21
+ ApiErrorDetail,
22
+ EInvoiceApiError,
23
+ EInvoiceConfigError,
24
+ EInvoiceConnectionError,
25
+ EInvoiceTimeoutError,
26
+ api_error_for,
27
+ )
28
+ from .types import ApiMode, ApiResponseMeta, BinaryResponse, RequestOptions
29
+
30
+ # ═══════════════════════════════════════════
31
+ # Constants
32
+ # ═══════════════════════════════════════════
33
+
34
+ #: The production gateway, for sandbox and live keys alike (the key's prefix picks the mode).
35
+ #:
36
+ #: THIS NAME IS PERMANENT. It is compiled into every installed copy of the SDK, so infrastructure
37
+ #: moves by repointing DNS behind it, never by changing it here.
38
+ DEFAULT_BASE_URL = "https://gp.useyona.com"
39
+
40
+ #: A Yona API key: ``sk_test_`` (sandbox) or ``sk_live_`` (live), a 16-character public id
41
+ #: (base32, lower case), then a 43-character secret (base64url).
42
+ API_KEY_PATTERN = re.compile(r"^sk_(test|live)_([a-z2-7]{16})_([A-Za-z0-9_-]{43})$")
43
+
44
+ #: What the API accepts in ``Idempotency-Key``.
45
+ IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
46
+
47
+ RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
48
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
49
+ HttpMethod = str
50
+
51
+
52
+ # ═══════════════════════════════════════════
53
+ # Configuration
54
+ # ═══════════════════════════════════════════
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class RetryConfig:
59
+ """Retry behaviour."""
60
+
61
+ #: Retries after the first attempt. Only requests that are safe to repeat are retried: GET, PUT
62
+ #: and DELETE, and writes that carry an ``Idempotency-Key``. Retried on a network error, a
63
+ #: timeout, 408, 429 and 5xx.
64
+ max_retries: int = 2
65
+ #: First backoff delay in seconds; doubles each retry, with jitter.
66
+ base_delay: float = 0.5
67
+ #: Ceiling of one backoff delay in seconds.
68
+ max_delay: float = 8.0
69
+ #: The longest ``Retry-After`` (seconds) the SDK will wait on 429/503 before retrying. A longer
70
+ #: one is raised at once, with ``retry_after`` set.
71
+ max_retry_after: int = 60
72
+
73
+
74
+ @dataclass
75
+ class RawResponse:
76
+ """A successful answer: ``data``, ``meta``, status and headers."""
77
+
78
+ status: int
79
+ data: Any
80
+ meta: ApiResponseMeta | None
81
+ request_id: str | None
82
+ headers: httpx.Headers
83
+
84
+
85
+ # ═══════════════════════════════════════════
86
+ # Helpers
87
+ # ═══════════════════════════════════════════
88
+
89
+
90
+ def mode_of_api_key(api_key: object) -> ApiMode:
91
+ """The mode a key belongs to, or raises :class:`EInvoiceConfigError` for a malformed key (never echoing it)."""
92
+ match = API_KEY_PATTERN.match(api_key.strip()) if isinstance(api_key, str) else None
93
+ if not match:
94
+ raise EInvoiceConfigError(
95
+ "The API key is malformed: expected sk_test_<16 characters>_<43 characters> (sandbox) or "
96
+ "sk_live_… (live). Copy it again from the dashboard (API keys)."
97
+ )
98
+ return "live" if match.group(1) == "live" else "sandbox"
99
+
100
+
101
+ def generate_idempotency_key() -> str:
102
+ """A fresh idempotency key (a UUID)."""
103
+ return str(uuid.uuid4())
104
+
105
+
106
+ def parse_retry_after(value: str | None, now: float | None = None) -> int | None:
107
+ """Seconds from a ``Retry-After`` header (delta-seconds or an HTTP date)."""
108
+ if not value:
109
+ return None
110
+ trimmed = value.strip()
111
+ if re.fullmatch(r"\d+", trimmed):
112
+ return int(trimmed)
113
+ try:
114
+ at = parsedate_to_datetime(trimmed).timestamp()
115
+ except (TypeError, ValueError, IndexError):
116
+ return None
117
+ current = time.time() if now is None else now
118
+ return max(0, int(-(-(at - current) // 1))) # ceil
119
+
120
+
121
+ def _unpack_errors(errors: object) -> list[ApiErrorDetail]:
122
+ """``{field: message}`` wire entries → ``ApiErrorDetail(field, message)``."""
123
+ out: list[ApiErrorDetail] = []
124
+ if not isinstance(errors, list):
125
+ return out
126
+ for entry in errors:
127
+ if isinstance(entry, dict):
128
+ for field, message in entry.items():
129
+ out.append(ApiErrorDetail(field=str(field), message=str(message)))
130
+ return out
131
+
132
+
133
+ def _parse_json(text: str) -> Any:
134
+ return json.loads(text) if text else None
135
+
136
+
137
+ class _AttemptTimeout(Exception):
138
+ pass
139
+
140
+
141
+ _FILENAME = re.compile(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)\"?", re.IGNORECASE)
142
+
143
+
144
+ # ═══════════════════════════════════════════
145
+ # HTTP client
146
+ # ═══════════════════════════════════════════
147
+
148
+
149
+ class HttpClient:
150
+ """The transport. Pass an ``httpx.Client`` (e.g. with ``httpx.MockTransport``) to control the network."""
151
+
152
+ def __init__(
153
+ self,
154
+ api_key: str,
155
+ *,
156
+ base_url: str | None = None,
157
+ assert_mode: ApiMode | None = None,
158
+ timeout: float = 30.0,
159
+ retry: RetryConfig | None = None,
160
+ headers: Mapping[str, str] | None = None,
161
+ http_client: httpx.Client | None = None,
162
+ user_agent: str | None = None,
163
+ ) -> None:
164
+ if not api_key:
165
+ raise EInvoiceConfigError('An API key is required: EInvoice(api_key="sk_test_…").')
166
+ #: The key's mode, from its prefix.
167
+ self.mode: ApiMode = mode_of_api_key(api_key)
168
+ if assert_mode is not None and assert_mode != self.mode:
169
+ prefix = "sk_live_" if self.mode == "live" else "sk_test_"
170
+ raise EInvoiceConfigError(f"This client was asserted to be {assert_mode}, but the API key is a {self.mode} key ({prefix}…).")
171
+ self._api_key = api_key.strip()
172
+ base = base_url if base_url is not None else DEFAULT_BASE_URL
173
+ parts = urlsplit(base)
174
+ if parts.scheme not in ("http", "https") or not parts.netloc:
175
+ raise EInvoiceConfigError(f"base_url is not a URL: {base}")
176
+ #: The host requests go to.
177
+ self.base_url = base.rstrip("/")
178
+ self.timeout = float(timeout)
179
+ self.retry = retry or RetryConfig()
180
+ self._default_headers = dict(headers or {})
181
+ self._user_agent = user_agent or f"useyona-einvoice-python/{__version__}"
182
+ self._owns_client = http_client is None
183
+ # The SDK's own deadline covers the whole attempt (see _attempt_once); httpx's per-operation
184
+ # timeouts are set per request so a stalled read also ends within the deadline.
185
+ self._http = http_client if http_client is not None else httpx.Client(timeout=self.timeout)
186
+
187
+ # ── lifecycle ──
188
+
189
+ def close(self) -> None:
190
+ """Closes the underlying connection pool (only one the SDK created)."""
191
+ if self._owns_client:
192
+ self._http.close()
193
+
194
+ def __enter__(self) -> HttpClient:
195
+ return self
196
+
197
+ def __exit__(self, *exc: object) -> None:
198
+ self.close()
199
+
200
+ # ── requests ──
201
+
202
+ def request(
203
+ self,
204
+ method: HttpMethod,
205
+ path: str,
206
+ *,
207
+ query: Mapping[str, Any] | None = None,
208
+ body: Any = None,
209
+ idempotent: bool = False,
210
+ binary: bool = False,
211
+ headers: Mapping[str, str] | None = None,
212
+ options: RequestOptions | None = None,
213
+ ) -> RawResponse:
214
+ """Sends one API call and returns the parsed success, or raises an :class:`EInvoiceApiError` subclass.
215
+
216
+ ``idempotent`` marks a route that honours ``Idempotency-Key``: the SDK sends one (generated
217
+ unless ``options.idempotency_key`` is given). ``binary`` expects a PDF instead of the JSON envelope.
218
+ """
219
+ ro = options or RequestOptions()
220
+ url = self._build_url(path, query)
221
+ timeout = ro.timeout if ro.timeout is not None else self.timeout
222
+ req_headers = self._build_headers(body is not None, idempotent, headers, ro)
223
+ keyed = "Idempotency-Key" in req_headers
224
+ retryable = method in IDEMPOTENT_METHODS or keyed
225
+ max_retries = (ro.max_retries if ro.max_retries is not None else self.retry.max_retries) if retryable else 0
226
+ content = None if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
227
+
228
+ attempt = 0
229
+ while True:
230
+ try:
231
+ outcome = self._attempt_once(url, method, req_headers, content, timeout, binary)
232
+ except EInvoiceApiError:
233
+ raise
234
+ except _AttemptTimeout:
235
+ if attempt < max_retries:
236
+ self._sleep(self._backoff(attempt))
237
+ attempt += 1
238
+ continue
239
+ raise EInvoiceTimeoutError(timeout) from None
240
+ except httpx.HTTPError as error:
241
+ if attempt < max_retries:
242
+ self._sleep(self._backoff(attempt))
243
+ attempt += 1
244
+ continue
245
+ raise EInvoiceConnectionError(f"Could not reach {self.base_url}: {error}", error) from error
246
+
247
+ if isinstance(outcome, RawResponse):
248
+ return outcome
249
+
250
+ refusal = outcome
251
+ if attempt < max_retries and refusal.status in RETRYABLE_STATUS:
252
+ wait = refusal.retry_after
253
+ if wait is not None and refusal.status in (429, 503):
254
+ if wait > self.retry.max_retry_after:
255
+ raise refusal
256
+ self._sleep(float(wait))
257
+ else:
258
+ self._sleep(self._backoff(attempt))
259
+ attempt += 1
260
+ continue
261
+ raise refusal
262
+
263
+ def _build_headers(self, has_body: bool, idempotent: bool, extra: Mapping[str, str] | None, ro: RequestOptions) -> dict[str, str]:
264
+ merged: dict[str, str] = {"Accept": "application/json"}
265
+ for source in (self._default_headers, extra or {}, ro.headers or {}):
266
+ for name, value in source.items():
267
+ # Later sources win; names compare case-insensitively.
268
+ for existing in [k for k in merged if k.lower() == name.lower()]:
269
+ del merged[existing]
270
+ merged[name] = value
271
+ for name in [k for k in merged if k.lower() in ("authorization", "x-api-key", "idempotency-key")]:
272
+ del merged[name]
273
+ merged["Authorization"] = f"Bearer {self._api_key}"
274
+ if self._user_agent and not any(k.lower() == "user-agent" for k in merged):
275
+ merged["User-Agent"] = self._user_agent
276
+ if has_body:
277
+ merged["Content-Type"] = "application/json"
278
+ if idempotent:
279
+ key = ro.idempotency_key if ro.idempotency_key is not None else generate_idempotency_key()
280
+ if not IDEMPOTENCY_KEY_PATTERN.match(key):
281
+ raise EInvoiceConfigError("idempotency_key must be 8–128 characters of A–Z, a–z, 0–9, _ or -.")
282
+ merged["Idempotency-Key"] = key
283
+ return merged
284
+
285
+ def _attempt_once(
286
+ self, url: str, method: str, headers: dict[str, str], content: bytes | None, timeout: float, binary: bool
287
+ ) -> RawResponse | EInvoiceApiError:
288
+ """One attempt: the request AND reading its body, all under one deadline, so a body that stalls
289
+ after the headers arrived times out like a request that never answered."""
290
+ deadline = time.monotonic() + timeout
291
+ request = self._http.build_request(method, url, headers=headers, content=content, timeout=httpx.Timeout(timeout))
292
+ try:
293
+ response = self._http.send(request, stream=True)
294
+ except httpx.TimeoutException as error:
295
+ raise _AttemptTimeout() from error
296
+ try:
297
+ try:
298
+ chunks: list[bytes] = []
299
+ for chunk in response.iter_bytes():
300
+ chunks.append(chunk)
301
+ if time.monotonic() > deadline:
302
+ raise _AttemptTimeout()
303
+ payload = b"".join(chunks)
304
+ except httpx.TimeoutException as error:
305
+ raise _AttemptTimeout() from error
306
+ finally:
307
+ response.close()
308
+ if response.is_success:
309
+ return self._parse_success(response, payload, binary)
310
+ return self._parse_error(response, payload)
311
+
312
+ def _parse_success(self, response: httpx.Response, payload: bytes, binary: bool) -> RawResponse:
313
+ header_request_id = response.headers.get("x-request-id")
314
+ content_type = response.headers.get("content-type", "")
315
+ if binary and "json" not in content_type:
316
+ disposition = response.headers.get("content-disposition", "")
317
+ match = _FILENAME.search(disposition)
318
+ name = match.group(1) if match else None
319
+ if name is not None:
320
+ from urllib.parse import unquote
321
+
322
+ name = unquote(name)
323
+ file = BinaryResponse(data=payload, content_type=content_type or "application/octet-stream", file_name=name, request_id=header_request_id)
324
+ return RawResponse(status=response.status_code, data=file, meta=None, request_id=header_request_id, headers=response.headers)
325
+ if response.status_code == 204:
326
+ return RawResponse(status=204, data=None, meta=None, request_id=header_request_id, headers=response.headers)
327
+ text = payload.decode("utf-8", errors="replace")
328
+ try:
329
+ parsed = _parse_json(text)
330
+ except ValueError:
331
+ raise EInvoiceApiError(
332
+ status=response.status_code,
333
+ message=f"The API answered {response.status_code} with a body that is not JSON",
334
+ request_id=header_request_id,
335
+ body=text,
336
+ ) from None
337
+ if isinstance(parsed, dict) and "meta" in parsed and "data" in parsed:
338
+ meta = parsed.get("meta")
339
+ meta_dict: ApiResponseMeta | None = meta if isinstance(meta, dict) else None # type: ignore[assignment]
340
+ request_id = (meta_dict or {}).get("requestId") or header_request_id
341
+ return RawResponse(status=response.status_code, data=parsed["data"], meta=meta_dict, request_id=request_id, headers=response.headers)
342
+ return RawResponse(status=response.status_code, data=parsed, meta=None, request_id=header_request_id, headers=response.headers)
343
+
344
+ def _parse_error(self, response: httpx.Response, payload: bytes) -> EInvoiceApiError:
345
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
346
+ header_request_id = response.headers.get("x-request-id")
347
+ text = payload.decode("utf-8", errors="replace")
348
+ try:
349
+ parsed = _parse_json(text)
350
+ except ValueError:
351
+ parsed = None
352
+ meta = parsed.get("meta") if isinstance(parsed, dict) else None
353
+ meta = meta if isinstance(meta, dict) else {}
354
+ message = meta.get("message")
355
+ error_code = meta.get("errorCode")
356
+ request_id = meta.get("requestId")
357
+ return api_error_for(
358
+ status=response.status_code,
359
+ message=message if isinstance(message, str) and message else f"HTTP {response.status_code}",
360
+ error_code=error_code if isinstance(error_code, str) else None,
361
+ errors=_unpack_errors(meta.get("errors")),
362
+ request_id=request_id if isinstance(request_id, str) else header_request_id,
363
+ retry_after=retry_after,
364
+ body=parsed,
365
+ )
366
+
367
+ def _build_url(self, path: str, query: Mapping[str, Any] | None) -> str:
368
+ url = f"{self.base_url}{path}"
369
+ if not query:
370
+ return url
371
+ pairs: list[tuple[str, str]] = []
372
+ for key, value in query.items():
373
+ if value is None or value == "":
374
+ continue
375
+ values = value if isinstance(value, list | tuple) else [value]
376
+ for v in values:
377
+ pairs.append((key, "true" if v is True else "false" if v is False else str(v)))
378
+ return f"{url}?{urlencode(pairs)}" if pairs else url
379
+
380
+ def _backoff(self, attempt: int) -> float:
381
+ base = min(self.retry.max_delay, self.retry.base_delay * (2.0**attempt))
382
+ return base / 2 + random.random() * (base / 2)
383
+
384
+ def _sleep(self, seconds: float) -> None:
385
+ """Waits ``seconds``. Overridable in tests."""
386
+ time.sleep(seconds)
387
+
388
+
389
+ __all__ = [
390
+ "API_KEY_PATTERN",
391
+ "DEFAULT_BASE_URL",
392
+ "IDEMPOTENCY_KEY_PATTERN",
393
+ "HttpClient",
394
+ "RawResponse",
395
+ "RetryConfig",
396
+ "generate_idempotency_key",
397
+ "mode_of_api_key",
398
+ "parse_retry_after",
399
+ ]
@@ -0,0 +1,3 @@
1
+ """The package version, read by the client's User-Agent and the guides export."""
2
+
3
+ __version__ = "0.1.0"