goomzo 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.
goomzo/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """The Goomzo API for Python: messages, templates, customers and webhooks."""
2
+
3
+ from ._client import AsyncGoomzo, Goomzo
4
+ from ._errors import FieldProblem, GoomzoConnectionError, GoomzoError, WebhookVerificationError
5
+ from ._pagination import AsyncPage, AsyncPager, Page
6
+ from ._transport import API_VERSION, Media
7
+ from ._version import VERSION
8
+ from ._webhooks import verify_webhook
9
+
10
+ __version__ = VERSION
11
+
12
+ __all__ = [
13
+ 'API_VERSION',
14
+ 'AsyncGoomzo',
15
+ 'AsyncPage',
16
+ 'AsyncPager',
17
+ 'FieldProblem',
18
+ 'Goomzo',
19
+ 'GoomzoConnectionError',
20
+ 'GoomzoError',
21
+ 'Media',
22
+ 'Page',
23
+ 'WebhookVerificationError',
24
+ '__version__',
25
+ 'verify_webhook',
26
+ ]
goomzo/_client.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from ._transport import AsyncTransport, SyncTransport, make_config
9
+ from .resources.broadcasts import AsyncBroadcasts, Broadcasts
10
+ from .resources.customers import ActivityRecorder, AsyncActivityRecorder, AsyncCustomers, Customers
11
+ from .resources.messages import AsyncMessages, Messages
12
+ from .resources.segments import AsyncSegments, Segments
13
+ from .resources.templates import AsyncTemplates, AsyncWhatsApp, Templates, WhatsApp
14
+ from .resources.webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints
15
+
16
+
17
+ class Goomzo:
18
+ """
19
+ The Goomzo API client. Make one and share it across your app.
20
+
21
+ from goomzo import Goomzo
22
+
23
+ goomzo = Goomzo() # reads GOOMZO_API_KEY
24
+ goomzo.messages.send_one({'to': '+255712345678', 'channel': 'sms', 'content': {'text': 'Your order is ready.'}})
25
+
26
+ `api_key` is your API key's secret, `gzk_…`: keep it on your server. A failed request is tried again
27
+ `max_retries` times after a network error, a 429 or a 5xx, and each try waits up to `timeout` seconds.
28
+
29
+ Docs: https://docs.goomzo.africa/docs/developers/python-sdk
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ api_key: str | None = None,
36
+ base_url: str | None = None,
37
+ max_retries: int = 2,
38
+ timeout: float = 60.0,
39
+ http_client: httpx.Client | None = None,
40
+ ) -> None:
41
+ self._transport = SyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
42
+ self.messages = Messages(self._transport)
43
+ """Send SMS and WhatsApp messages, each to one person."""
44
+ self.broadcasts = Broadcasts(self._transport)
45
+ """Send one message to many people."""
46
+ self.templates = Templates(self._transport)
47
+ """Saved templates, sent by name."""
48
+ self.whatsapp = WhatsApp(self._transport)
49
+ """The business's WhatsApp account and its templates."""
50
+ self.customers = Customers(self._transport)
51
+ """Add and update customers."""
52
+ self.activity = ActivityRecorder(self._transport)
53
+ """Record customer activity, by phone, email or id."""
54
+ self.segments = Segments(self._transport)
55
+ """Groups of customers."""
56
+ self.webhook_endpoints = WebhookEndpoints(self._transport)
57
+ """URLs that hear webhook events."""
58
+
59
+ def close(self) -> None:
60
+ """Closes the connections it opened. Not needed for an http_client you gave it."""
61
+ self._transport.close()
62
+
63
+ def __enter__(self) -> Self:
64
+ return self
65
+
66
+ def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None) -> None:
67
+ self.close()
68
+
69
+
70
+ class AsyncGoomzo:
71
+ """
72
+ The Goomzo API client, for asyncio. The same as `Goomzo`, with every call awaited.
73
+
74
+ from goomzo import AsyncGoomzo
75
+
76
+ goomzo = AsyncGoomzo()
77
+ await goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
78
+
79
+ Docs: https://docs.goomzo.africa/docs/developers/python-sdk
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ api_key: str | None = None,
86
+ base_url: str | None = None,
87
+ max_retries: int = 2,
88
+ timeout: float = 60.0,
89
+ http_client: httpx.AsyncClient | None = None,
90
+ ) -> None:
91
+ self._transport = AsyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
92
+ self.messages = AsyncMessages(self._transport)
93
+ """Send SMS and WhatsApp messages, each to one person."""
94
+ self.broadcasts = AsyncBroadcasts(self._transport)
95
+ """Send one message to many people."""
96
+ self.templates = AsyncTemplates(self._transport)
97
+ """Saved templates, sent by name."""
98
+ self.whatsapp = AsyncWhatsApp(self._transport)
99
+ """The business's WhatsApp account and its templates."""
100
+ self.customers = AsyncCustomers(self._transport)
101
+ """Add and update customers."""
102
+ self.activity = AsyncActivityRecorder(self._transport)
103
+ """Record customer activity, by phone, email or id."""
104
+ self.segments = AsyncSegments(self._transport)
105
+ """Groups of customers."""
106
+ self.webhook_endpoints = AsyncWebhookEndpoints(self._transport)
107
+ """URLs that hear webhook events."""
108
+
109
+ async def close(self) -> None:
110
+ """Closes the connections it opened. Not needed for an http_client you gave it."""
111
+ await self._transport.close()
112
+
113
+ async def __aenter__(self) -> Self:
114
+ return self
115
+
116
+ async def __aexit__(
117
+ self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None
118
+ ) -> None:
119
+ await self.close()
goomzo/_errors.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing_extensions import TypedDict
4
+
5
+
6
+ class FieldProblem(TypedDict):
7
+ """A problem with one field of a request."""
8
+
9
+ code: str
10
+ message: str
11
+
12
+
13
+ class GoomzoError(Exception):
14
+ """
15
+ The API answered with an error.
16
+
17
+ `code` says what went wrong, such as `invalid`, `not_found` or `insufficient_sms`. `fields` names the fields that
18
+ were wrong, such as `customers[1].phone`. Include `request_id` when you contact Goomzo.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ status: int,
24
+ code: str,
25
+ message: str,
26
+ fields: dict[str, FieldProblem] | None = None,
27
+ request_id: str | None = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.code = code
32
+ self.message = message
33
+ self.fields: dict[str, FieldProblem] = fields or {}
34
+ self.request_id = request_id
35
+
36
+ def __repr__(self) -> str:
37
+ return f'GoomzoError(status={self.status}, code={self.code!r}, message={self.message!r})'
38
+
39
+
40
+ class GoomzoConnectionError(Exception):
41
+ """No answer came: the network failed, or the request took longer than the timeout."""
42
+
43
+ def __init__(self, message: str, *, timed_out: bool) -> None:
44
+ super().__init__(message)
45
+ self.timed_out = timed_out
46
+
47
+
48
+ class WebhookVerificationError(Exception):
49
+ """A webhook's signature or timestamp didn't check out. Don't trust its body."""
goomzo/_pagination.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ T = TypeVar('T')
7
+
8
+
9
+ class Page(Generic[T]):
10
+ """
11
+ One page of a list. Loop over it for every item: the next pages are asked for as you go.
12
+
13
+ page = goomzo.customers.list(limit=200)
14
+ page.items, page.next_cursor, page.total
15
+
16
+ for customer in goomzo.customers.list():
17
+ print(customer['name'])
18
+ """
19
+
20
+ def __init__(self, data: Any, fetch: Callable[[str], Page[T]]) -> None:
21
+ self.items: list[T] = data['items']
22
+ self.next_cursor: str | None = data.get('nextCursor')
23
+ self.total: int | None = data.get('total')
24
+ """How many match, on every page. Only customers, messages and broadcasts have it."""
25
+ self._fetch = fetch
26
+
27
+ def next_page(self) -> Page[T] | None:
28
+ """The next page, or None on the last."""
29
+ return self._fetch(self.next_cursor) if self.next_cursor else None
30
+
31
+ def __iter__(self) -> Iterator[T]:
32
+ page: Page[T] | None = self
33
+ while page is not None:
34
+ yield from page.items
35
+ page = page.next_page()
36
+
37
+
38
+ class AsyncPage(Generic[T]):
39
+ """One page of a list, for asyncio. `async for` over it for every item."""
40
+
41
+ def __init__(self, data: Any, fetch: Callable[[str], Awaitable[AsyncPage[T]]]) -> None:
42
+ self.items: list[T] = data['items']
43
+ self.next_cursor: str | None = data.get('nextCursor')
44
+ self.total: int | None = data.get('total')
45
+ self._fetch = fetch
46
+
47
+ async def next_page(self) -> AsyncPage[T] | None:
48
+ """The next page, or None on the last."""
49
+ return await self._fetch(self.next_cursor) if self.next_cursor else None
50
+
51
+ async def __aiter__(self) -> AsyncIterator[T]:
52
+ page: AsyncPage[T] | None = self
53
+ while page is not None:
54
+ for item in page.items:
55
+ yield item
56
+ page = await page.next_page()
57
+
58
+
59
+ class AsyncPager(Generic[T]):
60
+ """
61
+ A list for asyncio. Await it for the first page, or `async for` over it for every item.
62
+
63
+ page = await goomzo.customers.list(limit=200)
64
+
65
+ async for customer in goomzo.customers.list():
66
+ print(customer['name'])
67
+ """
68
+
69
+ def __init__(self, first: Callable[[], Awaitable[AsyncPage[T]]]) -> None:
70
+ self._first = first
71
+
72
+ def __await__(self) -> Generator[Any, None, AsyncPage[T]]:
73
+ return self._first().__await__()
74
+
75
+ async def __aiter__(self) -> AsyncIterator[T]:
76
+ async for item in await self._first():
77
+ yield item
goomzo/_transport.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import random
6
+ import time
7
+ import uuid
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal, cast
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from ._errors import FieldProblem, GoomzoConnectionError, GoomzoError
16
+ from ._version import VERSION
17
+
18
+ API_VERSION = '2026-11-08'
19
+ """The API version this release is written for. Every request is sent with it, as `Goomzo-Version`."""
20
+
21
+ DEFAULT_BASE_URL = 'https://api.goomzo.africa'
22
+
23
+ Method = Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
24
+ Query = Mapping[str, Any]
25
+
26
+ _RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Media:
31
+ """A file a customer sent on WhatsApp."""
32
+
33
+ content: bytes
34
+ content_type: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _Config:
39
+ api_key: str
40
+ base_url: str
41
+ max_retries: int
42
+ timeout: float
43
+
44
+
45
+ def make_config(api_key: str | None, base_url: str | None, max_retries: int, timeout: float) -> _Config:
46
+ key = api_key or os.environ.get('GOOMZO_API_KEY')
47
+ if not key:
48
+ raise ValueError('Give an api_key, or set GOOMZO_API_KEY.')
49
+ return _Config(key, (base_url or DEFAULT_BASE_URL).rstrip('/'), max_retries, timeout)
50
+
51
+
52
+ def path(template: str, **params: str) -> str:
53
+ """Fills a path's `{name}` parts, each encoded: `path('/v1/customers/{id}', id=...)`."""
54
+ for name, value in params.items():
55
+ if not value:
56
+ raise ValueError(f'Missing {name} for {template}')
57
+ template = template.replace('{' + name + '}', quote(value, safe=''))
58
+ return template
59
+
60
+
61
+ def _headers(config: _Config, method: Method, idempotency_key: str | None, file: bool) -> dict[str, str]:
62
+ headers = {
63
+ 'Authorization': f'Bearer {config.api_key}',
64
+ 'Accept': '*/*' if file else 'application/json',
65
+ 'Goomzo-Version': API_VERSION,
66
+ 'User-Agent': f'goomzo-python/{VERSION}',
67
+ }
68
+ if method == 'POST':
69
+ headers['Idempotency-Key'] = idempotency_key or str(uuid.uuid4())
70
+ return headers
71
+
72
+
73
+ def _params(query: Query | None) -> dict[str, str]:
74
+ out: dict[str, str] = {}
75
+ for name, value in (query or {}).items():
76
+ if value is None:
77
+ continue
78
+ out[name] = ('true' if value else 'false') if isinstance(value, bool) else str(value)
79
+ return out
80
+
81
+
82
+ def _backoff(attempt: int) -> float:
83
+ """0.5 s, 1 s, 2 s… up to 8 s, with jitter so many clients don't retry together."""
84
+ return min(0.5 * 2**attempt, 8.0) * (0.75 + random.random() * 0.5)
85
+
86
+
87
+ def _retry_after(response: httpx.Response) -> float | None:
88
+ try:
89
+ seconds = float(response.headers.get('retry-after', ''))
90
+ except ValueError:
91
+ return None
92
+ return seconds if 0 < seconds <= 60 else None
93
+
94
+
95
+ def _to_error(response: httpx.Response) -> GoomzoError:
96
+ try:
97
+ raw: Any = response.json()
98
+ except ValueError:
99
+ raw = None
100
+ body: dict[str, Any] = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
101
+ fields: dict[str, FieldProblem] = body.get('fields') or {}
102
+ return GoomzoError(
103
+ response.status_code,
104
+ str(body.get('code') or f'http_{response.status_code}'),
105
+ str(body.get('message') or f'Goomzo answered {response.status_code} {response.reason_phrase}.'),
106
+ fields,
107
+ response.headers.get('x-request-id'),
108
+ )
109
+
110
+
111
+ def _should_retry(error: GoomzoError) -> bool:
112
+ # A key still being answered is safe to wait on; any other 409 is about the request itself.
113
+ return error.status in _RETRY_STATUSES or error.code == 'idempotency_key_in_use'
114
+
115
+
116
+ def _result(response: httpx.Response, file: bool) -> Any:
117
+ if response.status_code == 204:
118
+ return None
119
+ if file:
120
+ return Media(response.content, response.headers.get('content-type', 'application/octet-stream'))
121
+ return response.json()
122
+
123
+
124
+ def _connection_error(error: httpx.TransportError, timeout: float) -> GoomzoConnectionError:
125
+ if isinstance(error, httpx.TimeoutException):
126
+ return GoomzoConnectionError(f'No answer within {timeout:g} s.', timed_out=True)
127
+ return GoomzoConnectionError(f"Couldn't reach Goomzo: {error}", timed_out=False)
128
+
129
+
130
+ class SyncTransport:
131
+ """Makes requests: authenticates, pins the API version, retries what's safe to, and raises GoomzoError."""
132
+
133
+ def __init__(self, config: _Config, http: httpx.Client | None) -> None:
134
+ self._config = config
135
+ self._owns_http = http is None
136
+ self._http = http or httpx.Client()
137
+
138
+ def request(
139
+ self,
140
+ method: Method,
141
+ route: str,
142
+ *,
143
+ query: Query | None = None,
144
+ body: object = None,
145
+ idempotency_key: str | None = None,
146
+ file: bool = False,
147
+ ) -> Any:
148
+ config = self._config
149
+ headers = _headers(config, method, idempotency_key, file)
150
+ attempt = 0
151
+ while True:
152
+ try:
153
+ response = self._http.request(
154
+ method,
155
+ config.base_url + route,
156
+ params=_params(query),
157
+ json=body,
158
+ headers=headers,
159
+ timeout=config.timeout,
160
+ )
161
+ except httpx.TransportError as error:
162
+ if attempt < config.max_retries:
163
+ time.sleep(_backoff(attempt))
164
+ attempt += 1
165
+ continue
166
+ raise _connection_error(error, config.timeout) from error
167
+ if response.is_success:
168
+ return _result(response, file)
169
+ error = _to_error(response)
170
+ if _should_retry(error) and attempt < config.max_retries:
171
+ time.sleep(_retry_after(response) or _backoff(attempt))
172
+ attempt += 1
173
+ continue
174
+ raise error
175
+
176
+ def close(self) -> None:
177
+ if self._owns_http:
178
+ self._http.close()
179
+
180
+
181
+ class AsyncTransport:
182
+ """The same as SyncTransport, for asyncio."""
183
+
184
+ def __init__(self, config: _Config, http: httpx.AsyncClient | None) -> None:
185
+ self._config = config
186
+ self._owns_http = http is None
187
+ self._http = http or httpx.AsyncClient()
188
+
189
+ async def request(
190
+ self,
191
+ method: Method,
192
+ route: str,
193
+ *,
194
+ query: Query | None = None,
195
+ body: object = None,
196
+ idempotency_key: str | None = None,
197
+ file: bool = False,
198
+ ) -> Any:
199
+ config = self._config
200
+ headers = _headers(config, method, idempotency_key, file)
201
+ attempt = 0
202
+ while True:
203
+ try:
204
+ response = await self._http.request(
205
+ method,
206
+ config.base_url + route,
207
+ params=_params(query),
208
+ json=body,
209
+ headers=headers,
210
+ timeout=config.timeout,
211
+ )
212
+ except httpx.TransportError as error:
213
+ if attempt < config.max_retries:
214
+ await asyncio.sleep(_backoff(attempt))
215
+ attempt += 1
216
+ continue
217
+ raise _connection_error(error, config.timeout) from error
218
+ if response.is_success:
219
+ return _result(response, file)
220
+ error = _to_error(response)
221
+ if _should_retry(error) and attempt < config.max_retries:
222
+ await asyncio.sleep(_retry_after(response) or _backoff(attempt))
223
+ attempt += 1
224
+ continue
225
+ raise error
226
+
227
+ async def close(self) -> None:
228
+ if self._owns_http:
229
+ await self._http.aclose()
goomzo/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ VERSION = version('goomzo')
goomzo/_webhooks.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from collections.abc import Mapping
10
+ from typing import cast
11
+
12
+ from ._errors import WebhookVerificationError
13
+ from .types import WebhookEvent
14
+
15
+ _TOLERANCE = 5 * 60
16
+
17
+
18
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
19
+ value = headers.get(name)
20
+ if value is not None:
21
+ return value
22
+ return next((v for k, v in headers.items() if k.lower() == name), None)
23
+
24
+
25
+ def _key(secret: str) -> bytes:
26
+ raw = secret.removeprefix('whsec_')
27
+ try:
28
+ return base64.b64decode(raw, validate=True)
29
+ except (binascii.Error, ValueError):
30
+ return raw.encode()
31
+
32
+
33
+ def verify_webhook(
34
+ payload: str | bytes,
35
+ headers: Mapping[str, str],
36
+ secret: str,
37
+ *,
38
+ now: float | None = None,
39
+ ) -> WebhookEvent:
40
+ """
41
+ Checks that a webhook delivery came from Goomzo and is recent, then returns its event.
42
+
43
+ Pass the body exactly as it arrived, before parsing it: the signature is of those bytes. Pass the request's
44
+ headers, and the endpoint's signing secret, `whsec_…`.
45
+
46
+ event = verify_webhook(request.body, request.headers, os.environ['GOOMZO_WEBHOOK_SECRET'])
47
+ if event['type'] == 'message.delivered':
48
+ ...
49
+
50
+ Raises WebhookVerificationError when the signature doesn't match, or the delivery is more than 5 minutes old.
51
+ """
52
+ webhook_id = _header(headers, 'webhook-id')
53
+ timestamp = _header(headers, 'webhook-timestamp')
54
+ signatures = _header(headers, 'webhook-signature')
55
+ if not webhook_id or not timestamp or not signatures:
56
+ raise WebhookVerificationError('The webhook-id, webhook-timestamp and webhook-signature headers are needed.')
57
+
58
+ try:
59
+ sent = int(timestamp)
60
+ except ValueError:
61
+ raise WebhookVerificationError("The webhook-timestamp isn't a number.") from None
62
+ if abs((time.time() if now is None else now) - sent) > _TOLERANCE:
63
+ raise WebhookVerificationError('The delivery is too old, or from the future: check your clock.')
64
+
65
+ body = payload if isinstance(payload, bytes) else payload.encode()
66
+ signed = webhook_id.encode() + b'.' + timestamp.encode() + b'.' + body
67
+ expected = base64.b64encode(hmac.new(_key(secret), signed, hashlib.sha256).digest()).decode()
68
+
69
+ # While a secret is being rotated, a delivery carries a signature for each: any of them will do.
70
+ for part in signatures.split(' '):
71
+ version, _, signature = part.partition(',')
72
+ if version == 'v1' and hmac.compare_digest(signature, expected):
73
+ break
74
+ else:
75
+ raise WebhookVerificationError("The signature doesn't match: check the secret is this endpoint's.")
76
+
77
+ try:
78
+ return cast(WebhookEvent, json.loads(body))
79
+ except ValueError:
80
+ raise WebhookVerificationError("The body isn't JSON.") from None
goomzo/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._pagination import AsyncPage, AsyncPager, Page
6
+ from .._transport import AsyncTransport, SyncTransport, path
7
+ from ..types import Broadcast, BroadcastInput, BroadcastStatus, Channel
8
+
9
+
10
+ class Broadcasts:
11
+ """
12
+ Broadcasts: one message to segments, a list of numbers, or every customer, filled in for each person.
13
+
14
+ Docs: https://docs.goomzo.africa/docs/developers/messages/broadcasts
15
+ """
16
+
17
+ def __init__(self, transport: SyncTransport) -> None:
18
+ self._t = transport
19
+
20
+ def create(self, broadcast: BroadcastInput, *, idempotency_key: str | None = None) -> Broadcast:
21
+ """
22
+ Sends a broadcast, now or at `sendAt`. `audience` is required: leaving it out is an error, never a send to
23
+ everyone.
24
+
25
+ goomzo.broadcasts.create({
26
+ 'name': 'Black Friday',
27
+ 'channel': 'sms',
28
+ 'content': {'text': 'Hi {{name|there}}, 20% off today.'},
29
+ 'audience': {'segments': [{'name': 'VIP'}]},
30
+ })
31
+ """
32
+ return cast(
33
+ Broadcast, self._t.request('POST', '/v1/broadcasts', body=broadcast, idempotency_key=idempotency_key)
34
+ )
35
+
36
+ def get(self, id: str) -> Broadcast:
37
+ """Gets a broadcast, with how its messages are doing."""
38
+ return cast(Broadcast, self._t.request('GET', path('/v1/broadcasts/{id}', id=id)))
39
+
40
+ def list(
41
+ self,
42
+ *,
43
+ q: str | None = None,
44
+ channel: Channel | None = None,
45
+ status: BroadcastStatus | None = None,
46
+ limit: int | None = None,
47
+ ) -> Page[Broadcast]:
48
+ """Lists broadcasts, newest first. Loop over the page for all of them."""
49
+ query = {'q': q, 'channel': channel, 'status': status, 'limit': limit}
50
+
51
+ def fetch(cursor: str | None = None) -> Page[Broadcast]:
52
+ return Page(self._t.request('GET', '/v1/broadcasts', query={**query, 'cursor': cursor}), fetch)
53
+
54
+ return fetch()
55
+
56
+ def cancel(self, id: str) -> Broadcast:
57
+ """Cancels a scheduled broadcast before it goes."""
58
+ return cast(Broadcast, self._t.request('POST', path('/v1/broadcasts/{id}/cancel', id=id)))
59
+
60
+
61
+ class AsyncBroadcasts:
62
+ """Broadcasts, for asyncio. See `Broadcasts`."""
63
+
64
+ def __init__(self, transport: AsyncTransport) -> None:
65
+ self._t = transport
66
+
67
+ async def create(self, broadcast: BroadcastInput, *, idempotency_key: str | None = None) -> Broadcast:
68
+ """Sends a broadcast, now or at `sendAt`."""
69
+ return cast(
70
+ Broadcast, await self._t.request('POST', '/v1/broadcasts', body=broadcast, idempotency_key=idempotency_key)
71
+ )
72
+
73
+ async def get(self, id: str) -> Broadcast:
74
+ """Gets a broadcast, with how its messages are doing."""
75
+ return cast(Broadcast, await self._t.request('GET', path('/v1/broadcasts/{id}', id=id)))
76
+
77
+ def list(
78
+ self,
79
+ *,
80
+ q: str | None = None,
81
+ channel: Channel | None = None,
82
+ status: BroadcastStatus | None = None,
83
+ limit: int | None = None,
84
+ ) -> AsyncPager[Broadcast]:
85
+ """Lists broadcasts, newest first. Await it for one page, or `async for` over it for all of them."""
86
+ query = {'q': q, 'channel': channel, 'status': status, 'limit': limit}
87
+
88
+ async def fetch(cursor: str | None = None) -> AsyncPage[Broadcast]:
89
+ return AsyncPage(await self._t.request('GET', '/v1/broadcasts', query={**query, 'cursor': cursor}), fetch)
90
+
91
+ return AsyncPager(fetch)
92
+
93
+ async def cancel(self, id: str) -> Broadcast:
94
+ """Cancels a scheduled broadcast before it goes."""
95
+ return cast(Broadcast, await self._t.request('POST', path('/v1/broadcasts/{id}/cancel', id=id)))