silon-sdk 0.2.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.
silon/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Python SDK for the Silon messaging platform API.
2
+
3
+ Quickstart::
4
+
5
+ from silon import Silon
6
+
7
+ client = Silon(api_key="sk_live_...", workspace="acme")
8
+ sent = client.messages.send(
9
+ channel="whatsapp",
10
+ to={"client_id": "cust_001"},
11
+ content={"body": "Your order has shipped"},
12
+ )
13
+ print(sent.id, sent.status)
14
+ """
15
+
16
+ from . import types, webhooks
17
+ from ._client import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, AsyncSilon, Silon
18
+ from ._exceptions import (
19
+ APIConnectionError,
20
+ APIStatusError,
21
+ APITimeoutError,
22
+ AuthenticationError,
23
+ BadRequestError,
24
+ ConflictError,
25
+ ErrorDetail,
26
+ GoneError,
27
+ InternalServerError,
28
+ NotFoundError,
29
+ PermissionDeniedError,
30
+ RateLimitError,
31
+ SilonError,
32
+ UnprocessableEntityError,
33
+ WebhookSignatureVerificationError,
34
+ )
35
+ from ._pagination import AsyncCursorPage, SyncCursorPage
36
+ from ._version import __version__
37
+
38
+ __all__ = [
39
+ "DEFAULT_MAX_RETRIES",
40
+ "DEFAULT_TIMEOUT",
41
+ "APIConnectionError",
42
+ "APIStatusError",
43
+ "APITimeoutError",
44
+ "AsyncCursorPage",
45
+ "AsyncSilon",
46
+ "AuthenticationError",
47
+ "BadRequestError",
48
+ "ConflictError",
49
+ "ErrorDetail",
50
+ "GoneError",
51
+ "InternalServerError",
52
+ "NotFoundError",
53
+ "PermissionDeniedError",
54
+ "RateLimitError",
55
+ "Silon",
56
+ "SilonError",
57
+ "SyncCursorPage",
58
+ "UnprocessableEntityError",
59
+ "WebhookSignatureVerificationError",
60
+ "__version__",
61
+ "types",
62
+ "webhooks",
63
+ ]
silon/_client.py ADDED
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import platform
6
+ import random
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._exceptions import APIConnectionError, APITimeoutError, SilonError, make_status_error
14
+ from ._utils import parse_retry_after
15
+ from ._version import __version__
16
+
17
+ __all__ = ["DEFAULT_MAX_RETRIES", "DEFAULT_TIMEOUT", "AsyncSilon", "Silon"]
18
+
19
+ DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
20
+ DEFAULT_MAX_RETRIES = 2
21
+
22
+ _RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
23
+ _IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
24
+ _MAX_RETRY_DELAY = 30.0
25
+
26
+
27
+ class _BaseClient:
28
+ def __init__(
29
+ self,
30
+ *,
31
+ api_key: str | None = None,
32
+ workspace: str | None = None,
33
+ base_url: str | None = None,
34
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
35
+ max_retries: int = DEFAULT_MAX_RETRIES,
36
+ default_headers: Mapping[str, str] | None = None,
37
+ ) -> None:
38
+ self.api_key = api_key or os.environ.get("SILON_API_KEY")
39
+ if not self.api_key:
40
+ raise SilonError(
41
+ "No API key provided. Pass api_key=... or set the SILON_API_KEY environment "
42
+ "variable. Create a key in the dashboard under Settings > API keys."
43
+ )
44
+ base_url = base_url or os.environ.get("SILON_BASE_URL")
45
+ workspace = workspace or os.environ.get("SILON_WORKSPACE")
46
+ if base_url is None and workspace:
47
+ base_url = f"https://{workspace}.silon.tech"
48
+ if base_url is None:
49
+ raise SilonError(
50
+ "No base URL. Pass workspace='<your-workspace>' (=> https://<workspace>"
51
+ ".silon.tech), base_url=..., or set SILON_WORKSPACE / SILON_BASE_URL."
52
+ )
53
+ self.base_url = base_url.rstrip("/")
54
+ self.timeout = timeout
55
+ self.max_retries = max_retries
56
+ self.default_headers = dict(default_headers or {})
57
+
58
+ def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
59
+ headers = {
60
+ "Authorization": f"Bearer {self.api_key}",
61
+ "Accept": "application/json",
62
+ "User-Agent": f"silon-python/{__version__} python/{platform.python_version()}",
63
+ }
64
+ headers.update(self.default_headers)
65
+ if extra:
66
+ headers.update(extra)
67
+ return headers
68
+
69
+ def _url(self, path: str) -> str:
70
+ return f"{self.base_url}{path}"
71
+
72
+ def _should_retry(
73
+ self,
74
+ *,
75
+ method: str,
76
+ headers: Mapping[str, str],
77
+ attempt: int,
78
+ response: httpx.Response | None = None,
79
+ ) -> bool:
80
+ if attempt >= self.max_retries:
81
+ return False
82
+ # POST/PATCH are only replayed when the request carries an
83
+ # Idempotency-Key, so a retry can never double-send.
84
+ if method.upper() not in _IDEMPOTENT_METHODS and "Idempotency-Key" not in headers:
85
+ return False
86
+ return response is None or response.status_code in _RETRYABLE_STATUSES
87
+
88
+ def _retry_delay(self, response: httpx.Response | None, attempt: int) -> float:
89
+ delay = min(0.5 * 2**attempt, 8.0) + random.uniform(0, 0.25)
90
+ if response is not None:
91
+ advertised = parse_retry_after(response)
92
+ if advertised is not None:
93
+ delay = max(delay, advertised)
94
+ return max(0.0, min(delay, _MAX_RETRY_DELAY))
95
+
96
+ @staticmethod
97
+ def _parse(response: httpx.Response) -> Any:
98
+ if response.status_code == 204 or not response.content:
99
+ return None
100
+ try:
101
+ return response.json()
102
+ except ValueError as exc:
103
+ raise SilonError(
104
+ f"Could not parse response body as JSON (HTTP {response.status_code})."
105
+ ) from exc
106
+
107
+
108
+ class Silon(_BaseClient):
109
+ """Synchronous client for the Silon API.
110
+
111
+ Usage::
112
+
113
+ from silon import Silon
114
+
115
+ client = Silon(api_key="sk_live_...", workspace="acme")
116
+ sent = client.messages.send(
117
+ channel="whatsapp",
118
+ to={"client_id": "cust_001"},
119
+ content={"body": "Your order has shipped"},
120
+ )
121
+
122
+ Configuration falls back to the ``SILON_API_KEY``, ``SILON_WORKSPACE``
123
+ and ``SILON_BASE_URL`` environment variables.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ *,
129
+ api_key: str | None = None,
130
+ workspace: str | None = None,
131
+ base_url: str | None = None,
132
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
133
+ max_retries: int = DEFAULT_MAX_RETRIES,
134
+ default_headers: Mapping[str, str] | None = None,
135
+ http_client: httpx.Client | None = None,
136
+ ) -> None:
137
+ super().__init__(
138
+ api_key=api_key,
139
+ workspace=workspace,
140
+ base_url=base_url,
141
+ timeout=timeout,
142
+ max_retries=max_retries,
143
+ default_headers=default_headers,
144
+ )
145
+ self._http = http_client or httpx.Client(timeout=self.timeout)
146
+
147
+ from .resources import (
148
+ Auth,
149
+ Broadcasts,
150
+ Bulk,
151
+ ClientGroups,
152
+ Clients,
153
+ Events,
154
+ Messages,
155
+ Otp,
156
+ Profile,
157
+ Push,
158
+ Reports,
159
+ Suppressions,
160
+ Templates,
161
+ WebhookEndpoints,
162
+ WhatsAppTemplates,
163
+ )
164
+
165
+ self.messages = Messages(self)
166
+ self.broadcasts = Broadcasts(self)
167
+ self.otp = Otp(self)
168
+ self.clients = Clients(self)
169
+ self.client_groups = ClientGroups(self)
170
+ self.bulk = Bulk(self)
171
+ self.reports = Reports(self)
172
+ self.whatsapp_templates = WhatsAppTemplates(self)
173
+ self.templates = Templates(self)
174
+ self.webhook_endpoints = WebhookEndpoints(self)
175
+ self.events = Events(self)
176
+ self.suppressions = Suppressions(self)
177
+ self.push = Push(self)
178
+ self.profile = Profile(self)
179
+ self.auth = Auth(self)
180
+
181
+ # -- transport ---------------------------------------------------------
182
+
183
+ def request(
184
+ self,
185
+ method: str,
186
+ path: str,
187
+ *,
188
+ params: Mapping[str, Any] | None = None,
189
+ json: Any = None,
190
+ files: Any = None,
191
+ headers: Mapping[str, str] | None = None,
192
+ ) -> Any:
193
+ request_headers = self._headers(headers)
194
+ attempt = 0
195
+ while True:
196
+ try:
197
+ response = self._http.request(
198
+ method,
199
+ self._url(path),
200
+ params=params,
201
+ json=json,
202
+ files=files,
203
+ headers=request_headers,
204
+ )
205
+ except httpx.TimeoutException as exc:
206
+ if self._should_retry(method=method, headers=request_headers, attempt=attempt):
207
+ self._sleep(self._retry_delay(None, attempt))
208
+ attempt += 1
209
+ continue
210
+ raise APITimeoutError() from exc
211
+ except httpx.HTTPError as exc:
212
+ if self._should_retry(method=method, headers=request_headers, attempt=attempt):
213
+ self._sleep(self._retry_delay(None, attempt))
214
+ attempt += 1
215
+ continue
216
+ message = f"Connection error while requesting {path}: {exc}"
217
+ raise APIConnectionError(message) from exc
218
+
219
+ if response.status_code >= 400:
220
+ if self._should_retry(
221
+ method=method, headers=request_headers, attempt=attempt, response=response
222
+ ):
223
+ self._sleep(self._retry_delay(response, attempt))
224
+ attempt += 1
225
+ continue
226
+ raise make_status_error(response)
227
+ return self._parse(response)
228
+
229
+ def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
230
+ return self.request("GET", path, params=params)
231
+
232
+ def post(
233
+ self,
234
+ path: str,
235
+ *,
236
+ json: Any = None,
237
+ files: Any = None,
238
+ params: Mapping[str, Any] | None = None,
239
+ headers: Mapping[str, str] | None = None,
240
+ ) -> Any:
241
+ return self.request("POST", path, json=json, files=files, params=params, headers=headers)
242
+
243
+ def patch(self, path: str, *, json: Any = None) -> Any:
244
+ return self.request("PATCH", path, json=json)
245
+
246
+ def put(self, path: str, *, json: Any = None) -> Any:
247
+ return self.request("PUT", path, json=json)
248
+
249
+ def delete(self, path: str) -> Any:
250
+ return self.request("DELETE", path)
251
+
252
+ # -- lifecycle ---------------------------------------------------------
253
+
254
+ @staticmethod
255
+ def _sleep(seconds: float) -> None:
256
+ time.sleep(seconds)
257
+
258
+ def close(self) -> None:
259
+ self._http.close()
260
+
261
+ def __enter__(self) -> Silon:
262
+ return self
263
+
264
+ def __exit__(self, *exc_info: object) -> None:
265
+ self.close()
266
+
267
+
268
+ class AsyncSilon(_BaseClient):
269
+ """Asynchronous client for the Silon API.
270
+
271
+ Usage::
272
+
273
+ from silon import AsyncSilon
274
+
275
+ async with AsyncSilon(api_key="sk_live_...", workspace="acme") as client:
276
+ sent = await client.messages.send(
277
+ channel="sms",
278
+ to={"phone_number": "+96512345678"},
279
+ content={"body": "Your code is 424242"},
280
+ )
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ *,
286
+ api_key: str | None = None,
287
+ workspace: str | None = None,
288
+ base_url: str | None = None,
289
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
290
+ max_retries: int = DEFAULT_MAX_RETRIES,
291
+ default_headers: Mapping[str, str] | None = None,
292
+ http_client: httpx.AsyncClient | None = None,
293
+ ) -> None:
294
+ super().__init__(
295
+ api_key=api_key,
296
+ workspace=workspace,
297
+ base_url=base_url,
298
+ timeout=timeout,
299
+ max_retries=max_retries,
300
+ default_headers=default_headers,
301
+ )
302
+ self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
303
+
304
+ from .resources import (
305
+ AsyncAuth,
306
+ AsyncBroadcasts,
307
+ AsyncBulk,
308
+ AsyncClientGroups,
309
+ AsyncClients,
310
+ AsyncEvents,
311
+ AsyncMessages,
312
+ AsyncOtp,
313
+ AsyncProfile,
314
+ AsyncPush,
315
+ AsyncReports,
316
+ AsyncSuppressions,
317
+ AsyncTemplates,
318
+ AsyncWebhookEndpoints,
319
+ AsyncWhatsAppTemplates,
320
+ )
321
+
322
+ self.messages = AsyncMessages(self)
323
+ self.broadcasts = AsyncBroadcasts(self)
324
+ self.otp = AsyncOtp(self)
325
+ self.clients = AsyncClients(self)
326
+ self.client_groups = AsyncClientGroups(self)
327
+ self.bulk = AsyncBulk(self)
328
+ self.reports = AsyncReports(self)
329
+ self.whatsapp_templates = AsyncWhatsAppTemplates(self)
330
+ self.templates = AsyncTemplates(self)
331
+ self.webhook_endpoints = AsyncWebhookEndpoints(self)
332
+ self.events = AsyncEvents(self)
333
+ self.suppressions = AsyncSuppressions(self)
334
+ self.push = AsyncPush(self)
335
+ self.profile = AsyncProfile(self)
336
+ self.auth = AsyncAuth(self)
337
+
338
+ # -- transport ---------------------------------------------------------
339
+
340
+ async def request(
341
+ self,
342
+ method: str,
343
+ path: str,
344
+ *,
345
+ params: Mapping[str, Any] | None = None,
346
+ json: Any = None,
347
+ files: Any = None,
348
+ headers: Mapping[str, str] | None = None,
349
+ ) -> Any:
350
+ request_headers = self._headers(headers)
351
+ attempt = 0
352
+ while True:
353
+ try:
354
+ response = await self._http.request(
355
+ method,
356
+ self._url(path),
357
+ params=params,
358
+ json=json,
359
+ files=files,
360
+ headers=request_headers,
361
+ )
362
+ except httpx.TimeoutException as exc:
363
+ if self._should_retry(method=method, headers=request_headers, attempt=attempt):
364
+ await self._sleep(self._retry_delay(None, attempt))
365
+ attempt += 1
366
+ continue
367
+ raise APITimeoutError() from exc
368
+ except httpx.HTTPError as exc:
369
+ if self._should_retry(method=method, headers=request_headers, attempt=attempt):
370
+ await self._sleep(self._retry_delay(None, attempt))
371
+ attempt += 1
372
+ continue
373
+ message = f"Connection error while requesting {path}: {exc}"
374
+ raise APIConnectionError(message) from exc
375
+
376
+ if response.status_code >= 400:
377
+ if self._should_retry(
378
+ method=method, headers=request_headers, attempt=attempt, response=response
379
+ ):
380
+ await self._sleep(self._retry_delay(response, attempt))
381
+ attempt += 1
382
+ continue
383
+ raise make_status_error(response)
384
+ return self._parse(response)
385
+
386
+ async def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
387
+ return await self.request("GET", path, params=params)
388
+
389
+ async def post(
390
+ self,
391
+ path: str,
392
+ *,
393
+ json: Any = None,
394
+ files: Any = None,
395
+ params: Mapping[str, Any] | None = None,
396
+ headers: Mapping[str, str] | None = None,
397
+ ) -> Any:
398
+ return await self.request(
399
+ "POST", path, json=json, files=files, params=params, headers=headers
400
+ )
401
+
402
+ async def patch(self, path: str, *, json: Any = None) -> Any:
403
+ return await self.request("PATCH", path, json=json)
404
+
405
+ async def put(self, path: str, *, json: Any = None) -> Any:
406
+ return await self.request("PUT", path, json=json)
407
+
408
+ async def delete(self, path: str) -> Any:
409
+ return await self.request("DELETE", path)
410
+
411
+ # -- lifecycle ---------------------------------------------------------
412
+
413
+ @staticmethod
414
+ async def _sleep(seconds: float) -> None:
415
+ await asyncio.sleep(seconds)
416
+
417
+ async def close(self) -> None:
418
+ await self._http.aclose()
419
+
420
+ async def __aenter__(self) -> AsyncSilon:
421
+ return self
422
+
423
+ async def __aexit__(self, *exc_info: object) -> None:
424
+ await self.close()