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 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)
bird/_constants.py ADDED
@@ -0,0 +1,8 @@
1
+ import httpx
2
+
3
+ DEFAULT_MAX_RETRIES = 2
4
+ DEFAULT_TIMEOUT = httpx.Timeout(timeout=60.0, connect=5.0)
5
+
6
+ # Jittered exponential backoff bounds, in seconds.
7
+ INITIAL_RETRY_DELAY = 0.5
8
+ MAX_RETRY_DELAY = 8.0