messagebird-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.
- bird/__init__.py +65 -0
- bird/_base_client.py +256 -0
- bird/_client.py +278 -0
- bird/_constants.py +8 -0
- bird/_exceptions.py +227 -0
- bird/_generated.py +1390 -0
- bird/_models.py +43 -0
- bird/_response.py +37 -0
- bird/_types.py +123 -0
- bird/_version.py +1 -0
- bird/pagination.py +115 -0
- bird/py.typed +0 -0
- bird/resources/__init__.py +4 -0
- bird/resources/email.py +366 -0
- bird/resources/webhooks.py +111 -0
- messagebird_sdk-0.1.0.dist-info/METADATA +185 -0
- messagebird_sdk-0.1.0.dist-info/RECORD +19 -0
- messagebird_sdk-0.1.0.dist-info/WHEEL +4 -0
- messagebird_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
bird/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The official Python SDK for the Bird email platform (ADR-0045).
|
|
2
|
+
|
|
3
|
+
The wire models are generated from the OpenAPI spec into ``bird._generated`` and
|
|
4
|
+
never hand-edited; this package is the hand-written, idiomatic layer on top — a
|
|
5
|
+
synchronous ``Bird`` client and an asynchronous ``AsyncBird`` client, a typed
|
|
6
|
+
exception hierarchy, safe retries, pagination, and webhook verification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from bird._client import AsyncBird, Bird
|
|
12
|
+
from bird._response import APIResponse
|
|
13
|
+
from bird._types import (
|
|
14
|
+
Attachment,
|
|
15
|
+
EmailDefaults,
|
|
16
|
+
EmailListParams,
|
|
17
|
+
EmailSendParams,
|
|
18
|
+
RequestOptions,
|
|
19
|
+
)
|
|
20
|
+
from bird._generated import (
|
|
21
|
+
EmailMessage,
|
|
22
|
+
WebhookEvent,
|
|
23
|
+
WebhookEventType,
|
|
24
|
+
)
|
|
25
|
+
from bird._exceptions import (
|
|
26
|
+
APIConnectionError,
|
|
27
|
+
APIError,
|
|
28
|
+
APIStatusError,
|
|
29
|
+
APITimeoutError,
|
|
30
|
+
BirdError,
|
|
31
|
+
ErrorDetail,
|
|
32
|
+
ErrorType,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
WebhookVerificationError,
|
|
36
|
+
)
|
|
37
|
+
from bird.pagination import AsyncPage, SyncPage
|
|
38
|
+
from bird._version import __version__
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Bird",
|
|
42
|
+
"AsyncBird",
|
|
43
|
+
"RequestOptions",
|
|
44
|
+
"EmailDefaults",
|
|
45
|
+
"Attachment",
|
|
46
|
+
"EmailSendParams",
|
|
47
|
+
"EmailListParams",
|
|
48
|
+
"APIResponse",
|
|
49
|
+
"SyncPage",
|
|
50
|
+
"AsyncPage",
|
|
51
|
+
"EmailMessage",
|
|
52
|
+
"WebhookEvent",
|
|
53
|
+
"WebhookEventType",
|
|
54
|
+
"BirdError",
|
|
55
|
+
"APIError",
|
|
56
|
+
"APIStatusError",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"ValidationError",
|
|
59
|
+
"APIConnectionError",
|
|
60
|
+
"APITimeoutError",
|
|
61
|
+
"WebhookVerificationError",
|
|
62
|
+
"ErrorDetail",
|
|
63
|
+
"ErrorType",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|
bird/_base_client.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The request lifecycle shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
``BaseClient`` owns header assembly (SDK-owned headers always win), retries with
|
|
4
|
+
jittered backoff that honors ``Retry-After``, a per-attempt timeout, and the
|
|
5
|
+
once-and-reuse idempotency key for mutations — generated once per logical call so
|
|
6
|
+
a retried write never double-applies (ADR-0045). ``SyncAPIClient`` and
|
|
7
|
+
``AsyncAPIClient`` add the transport loop; a resource method calls ``request()``
|
|
8
|
+
and never implements retries itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import platform
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Mapping, TypeVar
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from bird._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, INITIAL_RETRY_DELAY, MAX_RETRY_DELAY
|
|
24
|
+
from bird._exceptions import APIConnectionError, APITimeoutError, from_response, parse_retry_after
|
|
25
|
+
from bird._types import NOT_GIVEN, NotGiven
|
|
26
|
+
from bird._version import __version__
|
|
27
|
+
|
|
28
|
+
USER_AGENT = f"bird-sdk-python/{__version__} ({platform.python_implementation().lower()}/{platform.python_version()})"
|
|
29
|
+
|
|
30
|
+
_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
31
|
+
# SDK-owned headers a caller's extra_headers must never override.
|
|
32
|
+
_RESERVED_HEADERS = {"authorization", "user-agent", "x-bird-api-version", "idempotency-key"}
|
|
33
|
+
|
|
34
|
+
# Bound to the concrete client so `with`/`async with` preserve the subclass type
|
|
35
|
+
# (e.g. `with Bird(...) as c` keeps `c` typed as Bird, not SyncAPIClient).
|
|
36
|
+
_SyncClientT = TypeVar("_SyncClientT", bound="SyncAPIClient")
|
|
37
|
+
_AsyncClientT = TypeVar("_AsyncClientT", bound="AsyncAPIClient")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_request_path(base_url: str, path: str) -> None:
|
|
41
|
+
"""Reject a caller path that would move the API key off the configured origin.
|
|
42
|
+
|
|
43
|
+
The verb-method escape hatch joins ``path`` onto ``base_url`` and then attaches
|
|
44
|
+
the bearer token, so an unvalidated path can redirect the key to another host —
|
|
45
|
+
``//host``, ``user@host``, an absolute URL, or a bare-relative segment. Require a
|
|
46
|
+
single leading slash and assert the resolved origin equals the base-URL origin.
|
|
47
|
+
"""
|
|
48
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
49
|
+
raise ValueError(f"request path must be an absolute path starting with a single '/': got {path!r}")
|
|
50
|
+
base = urlsplit(base_url)
|
|
51
|
+
full = urlsplit(base_url + path)
|
|
52
|
+
if (full.scheme, full.netloc) != (base.scheme, base.netloc):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"request path {path!r} must stay on the configured Bird API origin {base.scheme}://{base.netloc}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseClient:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str,
|
|
63
|
+
api_key: str,
|
|
64
|
+
api_version: str | None = None,
|
|
65
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
66
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
67
|
+
default_headers: Mapping[str, str] | None = None,
|
|
68
|
+
default_query: Mapping[str, Any] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
self.api_key = api_key
|
|
72
|
+
self.api_version = api_version
|
|
73
|
+
self.max_retries = max_retries
|
|
74
|
+
self.timeout: httpx.Timeout | float | None = DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout
|
|
75
|
+
self._default_headers = dict(default_headers or {})
|
|
76
|
+
self._default_query = dict(default_query or {})
|
|
77
|
+
|
|
78
|
+
def _headers(self, extra_headers: Mapping[str, str] | None, idempotency_key: str | None) -> dict[str, str]:
|
|
79
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
80
|
+
headers.update(self._default_headers)
|
|
81
|
+
for key, value in (extra_headers or {}).items():
|
|
82
|
+
if key.lower() not in _RESERVED_HEADERS:
|
|
83
|
+
headers[key] = value
|
|
84
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
85
|
+
headers["User-Agent"] = USER_AGENT
|
|
86
|
+
if self.api_version:
|
|
87
|
+
headers["X-Bird-API-Version"] = self.api_version
|
|
88
|
+
if idempotency_key:
|
|
89
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
90
|
+
return headers
|
|
91
|
+
|
|
92
|
+
def _build_request(
|
|
93
|
+
self,
|
|
94
|
+
client: httpx.Client | httpx.AsyncClient,
|
|
95
|
+
method: str,
|
|
96
|
+
path: str,
|
|
97
|
+
*,
|
|
98
|
+
body: Any,
|
|
99
|
+
extra_headers: Mapping[str, str] | None,
|
|
100
|
+
extra_query: Mapping[str, Any] | None,
|
|
101
|
+
extra_body: Mapping[str, Any] | None,
|
|
102
|
+
timeout: httpx.Timeout | float | None | NotGiven,
|
|
103
|
+
idempotency_key: str | None,
|
|
104
|
+
) -> httpx.Request:
|
|
105
|
+
_validate_request_path(self.base_url, path)
|
|
106
|
+
if extra_body:
|
|
107
|
+
body = {**(body or {}), **extra_body}
|
|
108
|
+
query = {**self._default_query, **(extra_query or {})}
|
|
109
|
+
return client.build_request(
|
|
110
|
+
method,
|
|
111
|
+
self.base_url + path,
|
|
112
|
+
json=body,
|
|
113
|
+
params=query or None,
|
|
114
|
+
headers=self._headers(extra_headers, idempotency_key),
|
|
115
|
+
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _should_retry(response: httpx.Response) -> bool:
|
|
120
|
+
# 409 is a semantic conflict a retry cannot resolve; 501 is not implemented.
|
|
121
|
+
code = response.status_code
|
|
122
|
+
return code == 429 or (500 <= code < 600 and code != 501)
|
|
123
|
+
|
|
124
|
+
def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float:
|
|
125
|
+
if response is not None:
|
|
126
|
+
advised = parse_retry_after(response.headers)
|
|
127
|
+
if advised is not None:
|
|
128
|
+
return min(advised, MAX_RETRY_DELAY)
|
|
129
|
+
delay = min(INITIAL_RETRY_DELAY * 2**attempt, MAX_RETRY_DELAY)
|
|
130
|
+
return delay * (1.0 + random.random() * 0.25)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _idempotency_key(method: str, given: str | None) -> str | None:
|
|
134
|
+
if given is not None:
|
|
135
|
+
return given
|
|
136
|
+
return str(uuid.uuid4()) if method.upper() in _MUTATING_METHODS else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SyncAPIClient(BaseClient):
|
|
140
|
+
def __init__(self, *, http_client: httpx.Client | None = None, **kwargs: Any) -> None:
|
|
141
|
+
super().__init__(**kwargs)
|
|
142
|
+
# Own (and close) the client only when we created it. A client shared in via
|
|
143
|
+
# with_options() or injected by the caller is theirs to close.
|
|
144
|
+
self._owns_client = http_client is None
|
|
145
|
+
self._client = http_client or httpx.Client()
|
|
146
|
+
|
|
147
|
+
def request(
|
|
148
|
+
self,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
*,
|
|
152
|
+
body: Any = None,
|
|
153
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
154
|
+
extra_query: Mapping[str, Any] | None = None,
|
|
155
|
+
extra_body: Mapping[str, Any] | None = None,
|
|
156
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
157
|
+
idempotency_key: str | None = None,
|
|
158
|
+
max_retries: int | None = None,
|
|
159
|
+
) -> httpx.Response:
|
|
160
|
+
request = self._build_request(
|
|
161
|
+
self._client, method, path,
|
|
162
|
+
body=body, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body,
|
|
163
|
+
timeout=timeout, idempotency_key=self._idempotency_key(method, idempotency_key),
|
|
164
|
+
)
|
|
165
|
+
retries_left = self.max_retries if max_retries is None else max_retries
|
|
166
|
+
attempt = 0
|
|
167
|
+
while True:
|
|
168
|
+
last: httpx.Response | None = None
|
|
169
|
+
try:
|
|
170
|
+
response = self._client.send(request)
|
|
171
|
+
except httpx.TimeoutException as exc:
|
|
172
|
+
if retries_left <= 0:
|
|
173
|
+
raise APITimeoutError() from exc
|
|
174
|
+
except httpx.HTTPError as exc:
|
|
175
|
+
if retries_left <= 0:
|
|
176
|
+
raise APIConnectionError() from exc
|
|
177
|
+
else:
|
|
178
|
+
if response.is_success:
|
|
179
|
+
return response
|
|
180
|
+
if retries_left <= 0 or not self._should_retry(response):
|
|
181
|
+
raise from_response(response.status_code, response.content, response.headers)
|
|
182
|
+
response.close()
|
|
183
|
+
last = response
|
|
184
|
+
time.sleep(self._retry_delay(attempt, last))
|
|
185
|
+
retries_left -= 1
|
|
186
|
+
attempt += 1
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
if self._owns_client:
|
|
190
|
+
self._client.close()
|
|
191
|
+
|
|
192
|
+
def __enter__(self: _SyncClientT) -> _SyncClientT:
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, *exc: object) -> None:
|
|
196
|
+
self.close()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AsyncAPIClient(BaseClient):
|
|
200
|
+
def __init__(self, *, http_client: httpx.AsyncClient | None = None, **kwargs: Any) -> None:
|
|
201
|
+
super().__init__(**kwargs)
|
|
202
|
+
# Own (and close) the client only when we created it. A client shared in via
|
|
203
|
+
# with_options() or injected by the caller is theirs to close.
|
|
204
|
+
self._owns_client = http_client is None
|
|
205
|
+
self._client = http_client or httpx.AsyncClient()
|
|
206
|
+
|
|
207
|
+
async def request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
body: Any = None,
|
|
213
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
214
|
+
extra_query: Mapping[str, Any] | None = None,
|
|
215
|
+
extra_body: Mapping[str, Any] | None = None,
|
|
216
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
217
|
+
idempotency_key: str | None = None,
|
|
218
|
+
max_retries: int | None = None,
|
|
219
|
+
) -> httpx.Response:
|
|
220
|
+
request = self._build_request(
|
|
221
|
+
self._client, method, path,
|
|
222
|
+
body=body, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body,
|
|
223
|
+
timeout=timeout, idempotency_key=self._idempotency_key(method, idempotency_key),
|
|
224
|
+
)
|
|
225
|
+
retries_left = self.max_retries if max_retries is None else max_retries
|
|
226
|
+
attempt = 0
|
|
227
|
+
while True:
|
|
228
|
+
last: httpx.Response | None = None
|
|
229
|
+
try:
|
|
230
|
+
response = await self._client.send(request)
|
|
231
|
+
except httpx.TimeoutException as exc:
|
|
232
|
+
if retries_left <= 0:
|
|
233
|
+
raise APITimeoutError() from exc
|
|
234
|
+
except httpx.HTTPError as exc:
|
|
235
|
+
if retries_left <= 0:
|
|
236
|
+
raise APIConnectionError() from exc
|
|
237
|
+
else:
|
|
238
|
+
if response.is_success:
|
|
239
|
+
return response
|
|
240
|
+
if retries_left <= 0 or not self._should_retry(response):
|
|
241
|
+
raise from_response(response.status_code, response.content, response.headers)
|
|
242
|
+
await response.aclose()
|
|
243
|
+
last = response
|
|
244
|
+
await asyncio.sleep(self._retry_delay(attempt, last))
|
|
245
|
+
retries_left -= 1
|
|
246
|
+
attempt += 1
|
|
247
|
+
|
|
248
|
+
async def close(self) -> None:
|
|
249
|
+
if self._owns_client:
|
|
250
|
+
await self._client.aclose()
|
|
251
|
+
|
|
252
|
+
async def __aenter__(self: _AsyncClientT) -> _AsyncClientT:
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
256
|
+
await self.close()
|
bird/_client.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""The public clients: ``Bird`` (synchronous) and ``AsyncBird`` (asynchronous).
|
|
2
|
+
|
|
3
|
+
Both resolve configuration the same way — the API key from the ``api_key``
|
|
4
|
+
argument or ``BIRD_API_KEY``; the base URL from ``base_url``, ``BIRD_BASE_URL``,
|
|
5
|
+
or the region (explicit ``region`` or inferred from the ``bk_{region}_…`` key
|
|
6
|
+
prefix, ADR-0036). They add the escape-hatch verb methods over the request
|
|
7
|
+
lifecycle in ``_base_client``; resource namespaces attach on top.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pydantic
|
|
18
|
+
|
|
19
|
+
from bird._base_client import AsyncAPIClient, SyncAPIClient
|
|
20
|
+
from bird._constants import DEFAULT_MAX_RETRIES
|
|
21
|
+
from bird._exceptions import BirdError
|
|
22
|
+
from bird._types import NOT_GIVEN, EmailDefaults, NotGiven
|
|
23
|
+
from bird.resources.email import AsyncEmail, Email
|
|
24
|
+
from bird.resources.webhooks import AsyncWebhooks, Webhooks
|
|
25
|
+
|
|
26
|
+
_REGION_PREFIX = re.compile(r"^bk_([a-z]{2}[0-9]+)_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _infer_region(api_key: str) -> str | None:
|
|
30
|
+
match = _REGION_PREFIX.match(api_key)
|
|
31
|
+
return match.group(1) if match else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve(api_key: str | None, base_url: str | None, region: str | None) -> tuple[str, str]:
|
|
35
|
+
api_key = api_key or os.environ.get("BIRD_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise BirdError("missing API key: pass api_key= or set BIRD_API_KEY")
|
|
38
|
+
base_url = base_url or os.environ.get("BIRD_BASE_URL")
|
|
39
|
+
if not base_url:
|
|
40
|
+
region = region or _infer_region(api_key)
|
|
41
|
+
if not region:
|
|
42
|
+
raise BirdError(
|
|
43
|
+
"could not determine region: pass region= or base_url=, "
|
|
44
|
+
"or use a bk_{region}_{token} API key"
|
|
45
|
+
)
|
|
46
|
+
base_url = f"https://{region}.platform.bird.com"
|
|
47
|
+
return api_key, base_url
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode(response: httpx.Response, cast_to: type[pydantic.BaseModel] | None) -> Any:
|
|
51
|
+
if response.status_code == 204 or not response.content:
|
|
52
|
+
return None
|
|
53
|
+
data = response.json()
|
|
54
|
+
return cast_to.model_validate(data) if cast_to is not None else data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _with_overrides(
|
|
58
|
+
config: dict[str, Any], live_client: httpx.Client | httpx.AsyncClient, overrides: dict[str, Any]
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Build constructor kwargs for a client derived via ``with_options``: start from
|
|
61
|
+
the parent's resolved config, reuse the live HTTP client (so the derived client
|
|
62
|
+
shares the pool and doesn't own it), then apply the caller's non-default
|
|
63
|
+
overrides. Overriding ``api_key`` or ``region`` re-derives the base URL from the
|
|
64
|
+
new key's region prefix (ADR-0036) unless an explicit ``base_url`` — or the
|
|
65
|
+
``BIRD_BASE_URL`` env var, the deployment-wide override _resolve honors above
|
|
66
|
+
region — is set, matching the constructor's precedence."""
|
|
67
|
+
merged: dict[str, Any] = {**config, "http_client": live_client}
|
|
68
|
+
given = {key: value for key, value in overrides.items() if not isinstance(value, NotGiven)}
|
|
69
|
+
# api_key drives the region (ADR-0036): a new key (or region) without an explicit
|
|
70
|
+
# base_url must re-resolve the endpoint, not inherit the parent's resolved one.
|
|
71
|
+
if ("api_key" in given or "region" in given) and "base_url" not in given:
|
|
72
|
+
merged.pop("base_url", None)
|
|
73
|
+
merged.update(given)
|
|
74
|
+
return merged
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Bird(SyncAPIClient):
|
|
78
|
+
"""The synchronous Bird client.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import os
|
|
82
|
+
from bird import Bird, APIStatusError, RateLimitError
|
|
83
|
+
|
|
84
|
+
client = Bird(api_key=os.environ["BIRD_API_KEY"]) # region inferred from the key prefix
|
|
85
|
+
try:
|
|
86
|
+
msg = client.email.send(from_="hello@acme.com", to=["c@x.com"], subject="Hi", html="<p>hi</p>")
|
|
87
|
+
except RateLimitError as err:
|
|
88
|
+
wait = err.retry_after
|
|
89
|
+
except APIStatusError as err:
|
|
90
|
+
print(err.status_code, err.code, err.request_id)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Reach `client.email` and `client.webhooks`, or any other endpoint via the
|
|
94
|
+
verb methods (`client.get` / `post` / …). Use it as a context manager
|
|
95
|
+
(`with Bird(...) as client`) to close the underlying HTTP client.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# bird:snippet:start client.verbs
|
|
99
|
+
from bird import EmailMessage
|
|
100
|
+
|
|
101
|
+
message = client.get("/v1/email/messages/em_01krd...", cast_to=EmailMessage)
|
|
102
|
+
client.post("/v1/some/new/endpoint", body={"key": "value"})
|
|
103
|
+
# bird:snippet:end client.verbs
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A single `Bird` instance is safe to share across threads — the `httpx` client
|
|
107
|
+
pools connections and every call builds its own request state — so create one
|
|
108
|
+
client and reuse it rather than one per request.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
api_key: str | None = None,
|
|
115
|
+
region: str | None = None,
|
|
116
|
+
base_url: str | None = None,
|
|
117
|
+
api_version: str | None = None,
|
|
118
|
+
webhook_secret: str | None = None,
|
|
119
|
+
email_defaults: EmailDefaults | None = None,
|
|
120
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
121
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
122
|
+
default_headers: Mapping[str, str] | None = None,
|
|
123
|
+
default_query: Mapping[str, Any] | None = None,
|
|
124
|
+
http_client: httpx.Client | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
api_key, base_url = _resolve(api_key, base_url, region)
|
|
127
|
+
self._config: dict[str, Any] = {
|
|
128
|
+
"api_key": api_key,
|
|
129
|
+
"region": region,
|
|
130
|
+
"base_url": base_url,
|
|
131
|
+
"api_version": api_version,
|
|
132
|
+
"webhook_secret": webhook_secret,
|
|
133
|
+
"email_defaults": email_defaults,
|
|
134
|
+
"timeout": timeout,
|
|
135
|
+
"max_retries": max_retries,
|
|
136
|
+
"default_headers": default_headers,
|
|
137
|
+
"default_query": default_query,
|
|
138
|
+
"http_client": http_client,
|
|
139
|
+
}
|
|
140
|
+
# region is kept so with_options() can re-resolve correctly, but it isn't a base-client arg.
|
|
141
|
+
super().__init__(**{k: v for k, v in self._config.items() if k not in ("webhook_secret", "email_defaults", "region")})
|
|
142
|
+
self.webhook_secret = webhook_secret
|
|
143
|
+
self.email = Email(self, email_defaults)
|
|
144
|
+
self.webhooks = Webhooks(webhook_secret)
|
|
145
|
+
|
|
146
|
+
def with_options(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
api_key: str | None | NotGiven = NOT_GIVEN,
|
|
150
|
+
region: str | None | NotGiven = NOT_GIVEN,
|
|
151
|
+
base_url: str | None | NotGiven = NOT_GIVEN,
|
|
152
|
+
api_version: str | None | NotGiven = NOT_GIVEN,
|
|
153
|
+
webhook_secret: str | None | NotGiven = NOT_GIVEN,
|
|
154
|
+
email_defaults: EmailDefaults | None | NotGiven = NOT_GIVEN,
|
|
155
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
156
|
+
max_retries: int | NotGiven = NOT_GIVEN,
|
|
157
|
+
default_headers: Mapping[str, str] | None | NotGiven = NOT_GIVEN,
|
|
158
|
+
default_query: Mapping[str, Any] | None | NotGiven = NOT_GIVEN,
|
|
159
|
+
http_client: httpx.Client | None | NotGiven = NOT_GIVEN,
|
|
160
|
+
) -> "Bird":
|
|
161
|
+
"""Return a new client with some options overridden, reusing this client's
|
|
162
|
+
HTTP connection pool (the derived client never closes it) unless you pass
|
|
163
|
+
your own ``http_client``. Overriding ``api_key`` or ``region`` re-resolves the
|
|
164
|
+
base URL from the new key's region prefix — unless an explicit ``base_url`` or
|
|
165
|
+
the ``BIRD_BASE_URL`` env var is set, which win as the deployment-wide endpoint
|
|
166
|
+
(the same precedence the constructor uses)."""
|
|
167
|
+
return Bird(**_with_overrides(self._config, self._client, {
|
|
168
|
+
"api_key": api_key, "region": region, "base_url": base_url, "api_version": api_version,
|
|
169
|
+
"webhook_secret": webhook_secret, "email_defaults": email_defaults, "timeout": timeout,
|
|
170
|
+
"max_retries": max_retries, "default_headers": default_headers, "default_query": default_query,
|
|
171
|
+
"http_client": http_client,
|
|
172
|
+
}))
|
|
173
|
+
|
|
174
|
+
def get(self, path: str, *, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
175
|
+
return _decode(self.request("GET", path, **options), cast_to)
|
|
176
|
+
|
|
177
|
+
def post(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
178
|
+
return _decode(self.request("POST", path, body=body, **options), cast_to)
|
|
179
|
+
|
|
180
|
+
def put(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
181
|
+
return _decode(self.request("PUT", path, body=body, **options), cast_to)
|
|
182
|
+
|
|
183
|
+
def patch(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
184
|
+
return _decode(self.request("PATCH", path, body=body, **options), cast_to)
|
|
185
|
+
|
|
186
|
+
def delete(self, path: str, *, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
187
|
+
return _decode(self.request("DELETE", path, **options), cast_to)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AsyncBird(AsyncAPIClient):
|
|
191
|
+
"""The asynchronous Bird client — the async mirror of `Bird`.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
async with AsyncBird(api_key="bk_eu1_...") as client:
|
|
195
|
+
msg = await client.email.send(from_="hello@acme.com", to=["c@x.com"], subject="Hi", text="hi")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A single `AsyncBird` instance is safe to share across concurrent tasks (e.g.
|
|
199
|
+
`asyncio.gather`) — reuse one client rather than creating one per request.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
api_key: str | None = None,
|
|
206
|
+
region: str | None = None,
|
|
207
|
+
base_url: str | None = None,
|
|
208
|
+
api_version: str | None = None,
|
|
209
|
+
webhook_secret: str | None = None,
|
|
210
|
+
email_defaults: EmailDefaults | None = None,
|
|
211
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
212
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
213
|
+
default_headers: Mapping[str, str] | None = None,
|
|
214
|
+
default_query: Mapping[str, Any] | None = None,
|
|
215
|
+
http_client: httpx.AsyncClient | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
api_key, base_url = _resolve(api_key, base_url, region)
|
|
218
|
+
self._config: dict[str, Any] = {
|
|
219
|
+
"api_key": api_key,
|
|
220
|
+
"region": region,
|
|
221
|
+
"base_url": base_url,
|
|
222
|
+
"api_version": api_version,
|
|
223
|
+
"webhook_secret": webhook_secret,
|
|
224
|
+
"email_defaults": email_defaults,
|
|
225
|
+
"timeout": timeout,
|
|
226
|
+
"max_retries": max_retries,
|
|
227
|
+
"default_headers": default_headers,
|
|
228
|
+
"default_query": default_query,
|
|
229
|
+
"http_client": http_client,
|
|
230
|
+
}
|
|
231
|
+
# region is kept so with_options() can re-resolve correctly, but it isn't a base-client arg.
|
|
232
|
+
super().__init__(**{k: v for k, v in self._config.items() if k not in ("webhook_secret", "email_defaults", "region")})
|
|
233
|
+
self.webhook_secret = webhook_secret
|
|
234
|
+
self.email = AsyncEmail(self, email_defaults)
|
|
235
|
+
self.webhooks = AsyncWebhooks(webhook_secret)
|
|
236
|
+
|
|
237
|
+
def with_options(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
api_key: str | None | NotGiven = NOT_GIVEN,
|
|
241
|
+
region: str | None | NotGiven = NOT_GIVEN,
|
|
242
|
+
base_url: str | None | NotGiven = NOT_GIVEN,
|
|
243
|
+
api_version: str | None | NotGiven = NOT_GIVEN,
|
|
244
|
+
webhook_secret: str | None | NotGiven = NOT_GIVEN,
|
|
245
|
+
email_defaults: EmailDefaults | None | NotGiven = NOT_GIVEN,
|
|
246
|
+
timeout: httpx.Timeout | float | None | NotGiven = NOT_GIVEN,
|
|
247
|
+
max_retries: int | NotGiven = NOT_GIVEN,
|
|
248
|
+
default_headers: Mapping[str, str] | None | NotGiven = NOT_GIVEN,
|
|
249
|
+
default_query: Mapping[str, Any] | None | NotGiven = NOT_GIVEN,
|
|
250
|
+
http_client: httpx.AsyncClient | None | NotGiven = NOT_GIVEN,
|
|
251
|
+
) -> "AsyncBird":
|
|
252
|
+
"""Return a new client with some options overridden, reusing this client's
|
|
253
|
+
HTTP connection pool (the derived client never closes it) unless you pass
|
|
254
|
+
your own ``http_client``. Overriding ``api_key`` or ``region`` re-resolves the
|
|
255
|
+
base URL from the new key's region prefix — unless an explicit ``base_url`` or
|
|
256
|
+
the ``BIRD_BASE_URL`` env var is set, which win as the deployment-wide endpoint
|
|
257
|
+
(the same precedence the constructor uses)."""
|
|
258
|
+
return AsyncBird(**_with_overrides(self._config, self._client, {
|
|
259
|
+
"api_key": api_key, "region": region, "base_url": base_url, "api_version": api_version,
|
|
260
|
+
"webhook_secret": webhook_secret, "email_defaults": email_defaults, "timeout": timeout,
|
|
261
|
+
"max_retries": max_retries, "default_headers": default_headers, "default_query": default_query,
|
|
262
|
+
"http_client": http_client,
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
async def get(self, path: str, *, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
266
|
+
return _decode(await self.request("GET", path, **options), cast_to)
|
|
267
|
+
|
|
268
|
+
async def post(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
269
|
+
return _decode(await self.request("POST", path, body=body, **options), cast_to)
|
|
270
|
+
|
|
271
|
+
async def put(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
272
|
+
return _decode(await self.request("PUT", path, body=body, **options), cast_to)
|
|
273
|
+
|
|
274
|
+
async def patch(self, path: str, *, body: Any = None, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
275
|
+
return _decode(await self.request("PATCH", path, body=body, **options), cast_to)
|
|
276
|
+
|
|
277
|
+
async def delete(self, path: str, *, cast_to: type[pydantic.BaseModel] | None = None, **options: Any) -> Any:
|
|
278
|
+
return _decode(await self.request("DELETE", path, **options), cast_to)
|