opensms 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.
- opensms/__init__.py +30 -0
- opensms/_serialize.py +60 -0
- opensms/_transport.py +260 -0
- opensms/_version.py +3 -0
- opensms/client.py +127 -0
- opensms/errors.py +62 -0
- opensms/models.py +453 -0
- opensms/pagination.py +32 -0
- opensms/py.typed +0 -0
- opensms/resources/__init__.py +1 -0
- opensms/resources/_base.py +19 -0
- opensms/resources/analytics.py +95 -0
- opensms/resources/batches.py +116 -0
- opensms/resources/compliance.py +25 -0
- opensms/resources/contact_groups.py +83 -0
- opensms/resources/contacts.py +51 -0
- opensms/resources/countries.py +29 -0
- opensms/resources/inbound.py +28 -0
- opensms/resources/lookups.py +24 -0
- opensms/resources/messages.py +80 -0
- opensms/resources/numbers.py +96 -0
- opensms/resources/otp.py +42 -0
- opensms/resources/pricing.py +17 -0
- opensms/resources/sandbox.py +20 -0
- opensms/resources/sender_ids.py +160 -0
- opensms/resources/suppressions.py +41 -0
- opensms/resources/templates.py +52 -0
- opensms/resources/wallet.py +44 -0
- opensms/resources/webhooks.py +123 -0
- opensms/signature.py +120 -0
- opensms-0.1.0.dist-info/METADATA +285 -0
- opensms-0.1.0.dist-info/RECORD +34 -0
- opensms-0.1.0.dist-info/WHEEL +4 -0
- opensms-0.1.0.dist-info/licenses/LICENSE +21 -0
opensms/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Official Python client for OpenSMS, the prepaid SMS API.
|
|
2
|
+
|
|
3
|
+
from opensms import Opensms
|
|
4
|
+
|
|
5
|
+
client = Opensms(api_key="sk_test_...")
|
|
6
|
+
client.messages.send(to="+254700000012", text="Your order has shipped")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._transport import Transport, TransportRequest, TransportResponse, urllib_transport
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .client import Opensms
|
|
12
|
+
from .errors import OpensmsError
|
|
13
|
+
from .models import Page
|
|
14
|
+
from .pagination import paginate
|
|
15
|
+
from .signature import SIGNATURE_HEADER, construct_event, verify_signature
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Opensms",
|
|
19
|
+
"OpensmsError",
|
|
20
|
+
"Page",
|
|
21
|
+
"paginate",
|
|
22
|
+
"verify_signature",
|
|
23
|
+
"construct_event",
|
|
24
|
+
"SIGNATURE_HEADER",
|
|
25
|
+
"Transport",
|
|
26
|
+
"TransportRequest",
|
|
27
|
+
"TransportResponse",
|
|
28
|
+
"urllib_transport",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
opensms/_serialize.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Internal helpers that turn Python values into the exact wire format the API
|
|
2
|
+
expects: path segments, query strings and JSON bodies. Not part of the public
|
|
3
|
+
API."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def path_id(value: Any, name: str = "id") -> str:
|
|
13
|
+
"""Validate a non-empty id and URL-escape it as one path segment."""
|
|
14
|
+
if value is None or str(value).strip() == "":
|
|
15
|
+
raise ValueError(f"{name} is required")
|
|
16
|
+
return quote(str(value), safe="")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_rfc3339(value: Any) -> Any:
|
|
20
|
+
"""Serialize ``datetime`` as RFC 3339 UTC (``2026-09-24T10:00:00Z``).
|
|
21
|
+
|
|
22
|
+
A naive ``datetime`` is taken to be UTC. ``date`` becomes ``YYYY-MM-DD``.
|
|
23
|
+
Strings pass through unchanged.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(value, datetime):
|
|
26
|
+
if value.tzinfo is None:
|
|
27
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
28
|
+
text = value.astimezone(timezone.utc).isoformat()
|
|
29
|
+
return text.replace("+00:00", "Z")
|
|
30
|
+
if isinstance(value, date):
|
|
31
|
+
return value.isoformat()
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prune(body: Mapping[str, Any]) -> Dict[str, Any]:
|
|
36
|
+
"""Drop keys whose value is ``None`` so unset optionals are absent, never
|
|
37
|
+
``null`` (the API rejects unknown fields and treats null differently)."""
|
|
38
|
+
return {k: v for k, v in body.items() if v is not None}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _query_value(value: Any) -> str:
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return "true" if value else "false"
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return ",".join(_query_value(v) for v in value)
|
|
46
|
+
value = to_rfc3339(value)
|
|
47
|
+
return str(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_query(params: Optional[Mapping[str, Any]]) -> str:
|
|
51
|
+
"""Build ``?a=1&b=2`` from params, skipping ``None``. Lists are joined with
|
|
52
|
+
commas. ``+`` is encoded as ``%2B``. Returns ``""`` when nothing is set."""
|
|
53
|
+
if not params:
|
|
54
|
+
return ""
|
|
55
|
+
parts = []
|
|
56
|
+
for key, value in params.items():
|
|
57
|
+
if value is None:
|
|
58
|
+
continue
|
|
59
|
+
parts.append(f"{quote(key, safe='')}={quote(_query_value(value), safe=',')}")
|
|
60
|
+
return "?" + "&".join(parts) if parts else ""
|
opensms/_transport.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""HTTP transport: the only code that touches the network.
|
|
2
|
+
|
|
3
|
+
Owns authentication headers, JSON encoding, the Idempotency-Key, timeouts,
|
|
4
|
+
retries with backoff, and turning non-2xx responses into
|
|
5
|
+
:class:`~opensms.errors.OpensmsError`. The default network layer uses only the
|
|
6
|
+
standard library (``urllib``); tests inject a transport callable instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import email.utils
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import socket
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
22
|
+
|
|
23
|
+
from ._serialize import build_query
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .errors import OpensmsError
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.opensms.io"
|
|
28
|
+
USER_AGENT = f"opensms-python/{__version__}"
|
|
29
|
+
|
|
30
|
+
#: Statuses that may be retried (when the request is safe to repeat).
|
|
31
|
+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
32
|
+
#: A ``Retry-After`` above this many seconds is not waited for.
|
|
33
|
+
MAX_RETRY_AFTER = 60.0
|
|
34
|
+
_BACKOFF_BASE = 0.5
|
|
35
|
+
_BACKOFF_CAP = 8.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TransportRequest:
|
|
40
|
+
"""One HTTP attempt handed to a transport callable."""
|
|
41
|
+
|
|
42
|
+
method: str
|
|
43
|
+
url: str
|
|
44
|
+
headers: Dict[str, str]
|
|
45
|
+
body: Optional[bytes]
|
|
46
|
+
timeout: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TransportResponse:
|
|
51
|
+
"""What a transport callable returns for any HTTP status (including
|
|
52
|
+
4xx and 5xx). Header names are matched case-insensitively."""
|
|
53
|
+
|
|
54
|
+
status: int
|
|
55
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
56
|
+
body: bytes = b""
|
|
57
|
+
|
|
58
|
+
def header(self, name: str) -> Optional[str]:
|
|
59
|
+
lower = name.lower()
|
|
60
|
+
for key, value in self.headers.items():
|
|
61
|
+
if key.lower() == lower:
|
|
62
|
+
return value
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#: A transport performs one HTTP attempt. It returns a response for every
|
|
67
|
+
#: status and raises (any ``OSError``, ``http.client.HTTPException`` or
|
|
68
|
+
#: ``TimeoutError``) only when no response was received.
|
|
69
|
+
Transport = Callable[[TransportRequest], TransportResponse]
|
|
70
|
+
|
|
71
|
+
_NETWORK_ERRORS = (OSError, http.client.HTTPException, TimeoutError, socket.timeout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
75
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def urllib_transport(request: TransportRequest) -> TransportResponse:
|
|
83
|
+
"""Default transport built on ``urllib.request``."""
|
|
84
|
+
req = urllib.request.Request(
|
|
85
|
+
request.url, data=request.body, method=request.method, headers=request.headers
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
with _opener.open(req, timeout=request.timeout) as resp:
|
|
89
|
+
return TransportResponse(resp.status, dict(resp.headers.items()), resp.read())
|
|
90
|
+
except urllib.error.HTTPError as err:
|
|
91
|
+
try:
|
|
92
|
+
body = err.read()
|
|
93
|
+
finally:
|
|
94
|
+
err.close()
|
|
95
|
+
headers = dict(err.headers.items()) if err.headers else {}
|
|
96
|
+
return TransportResponse(err.code, headers, body)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[float]:
|
|
100
|
+
"""Parse ``Retry-After`` as integer seconds or an HTTP date."""
|
|
101
|
+
if value is None:
|
|
102
|
+
return None
|
|
103
|
+
value = value.strip()
|
|
104
|
+
if not value:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
return max(0.0, float(int(value)))
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
try:
|
|
111
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
112
|
+
except (TypeError, ValueError, IndexError):
|
|
113
|
+
return None
|
|
114
|
+
if when is None:
|
|
115
|
+
return None
|
|
116
|
+
current = time.time() if now is None else now
|
|
117
|
+
return max(0.0, when.timestamp() - current)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def error_from_response(resp: TransportResponse) -> OpensmsError:
|
|
121
|
+
"""Map a non-2xx response (problem+json or anything else) to an error."""
|
|
122
|
+
text = resp.body.decode("utf-8", "replace") if resp.body else ""
|
|
123
|
+
payload: Any = None
|
|
124
|
+
if text:
|
|
125
|
+
try:
|
|
126
|
+
payload = json.loads(text)
|
|
127
|
+
except ValueError:
|
|
128
|
+
payload = None
|
|
129
|
+
retry_after = parse_retry_after(resp.header("Retry-After"))
|
|
130
|
+
request_id = resp.header("X-Request-ID") or None
|
|
131
|
+
if isinstance(payload, dict):
|
|
132
|
+
detail = payload.get("detail") if isinstance(payload.get("detail"), str) else None
|
|
133
|
+
title = payload.get("title") if isinstance(payload.get("title"), str) else None
|
|
134
|
+
message = detail or title or f"OpenSMS request failed with status {resp.status}"
|
|
135
|
+
errors = payload.get("errors") if isinstance(payload.get("errors"), dict) else None
|
|
136
|
+
return OpensmsError(
|
|
137
|
+
resp.status,
|
|
138
|
+
message,
|
|
139
|
+
type=payload.get("type"),
|
|
140
|
+
title=title,
|
|
141
|
+
detail=detail,
|
|
142
|
+
code=payload.get("code"),
|
|
143
|
+
trace_id=payload.get("trace_id"),
|
|
144
|
+
errors=errors,
|
|
145
|
+
request_id=request_id,
|
|
146
|
+
retry_after=retry_after,
|
|
147
|
+
body=payload,
|
|
148
|
+
)
|
|
149
|
+
return OpensmsError(
|
|
150
|
+
resp.status,
|
|
151
|
+
f"OpenSMS request failed with status {resp.status}",
|
|
152
|
+
request_id=request_id,
|
|
153
|
+
retry_after=retry_after,
|
|
154
|
+
body=payload if payload is not None else text,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class HttpTransport:
|
|
159
|
+
"""Performs authenticated requests with retries. Shared by all resources."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
api_key: str,
|
|
164
|
+
*,
|
|
165
|
+
base_url: Optional[str] = None,
|
|
166
|
+
timeout: float = 30.0,
|
|
167
|
+
max_retries: int = 2,
|
|
168
|
+
transport: Optional[Transport] = None,
|
|
169
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
if max_retries < 0:
|
|
172
|
+
raise ValueError("max_retries must be >= 0")
|
|
173
|
+
self._api_key = api_key
|
|
174
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
175
|
+
self.timeout = timeout
|
|
176
|
+
self.max_retries = max_retries
|
|
177
|
+
self._transport: Transport = transport or urllib_transport
|
|
178
|
+
self._sleep: Callable[[float], None] = sleep or time.sleep
|
|
179
|
+
|
|
180
|
+
def request(
|
|
181
|
+
self,
|
|
182
|
+
method: str,
|
|
183
|
+
path: str,
|
|
184
|
+
*,
|
|
185
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
186
|
+
json_body: Any = None,
|
|
187
|
+
raw_body: Optional[bytes] = None,
|
|
188
|
+
content_type: Optional[str] = None,
|
|
189
|
+
idempotent: bool = False,
|
|
190
|
+
idempotency_key: Optional[str] = None,
|
|
191
|
+
) -> Any:
|
|
192
|
+
"""Send one API call and return the decoded JSON (``None`` for 204).
|
|
193
|
+
|
|
194
|
+
``idempotent=True`` marks a method that supports Idempotency-Key: the
|
|
195
|
+
caller's key (or a fresh UUIDv4) is sent on every attempt of this call.
|
|
196
|
+
"""
|
|
197
|
+
url = f"{self.base_url}{path}{build_query(query)}"
|
|
198
|
+
headers = {
|
|
199
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
200
|
+
"Accept": "application/json",
|
|
201
|
+
"User-Agent": USER_AGENT,
|
|
202
|
+
}
|
|
203
|
+
body: Optional[bytes] = None
|
|
204
|
+
if raw_body is not None:
|
|
205
|
+
body = raw_body
|
|
206
|
+
headers["Content-Type"] = content_type or "application/octet-stream"
|
|
207
|
+
elif json_body is not None:
|
|
208
|
+
body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
|
|
209
|
+
headers["Content-Type"] = "application/json"
|
|
210
|
+
if idempotent or idempotency_key is not None:
|
|
211
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
212
|
+
|
|
213
|
+
method = method.upper()
|
|
214
|
+
can_retry = method in ("GET", "PUT", "PATCH", "DELETE", "HEAD") or (
|
|
215
|
+
method == "POST" and "Idempotency-Key" in headers
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
attempt = 0
|
|
219
|
+
while True:
|
|
220
|
+
attempt += 1
|
|
221
|
+
req = TransportRequest(method, url, dict(headers), body, self.timeout)
|
|
222
|
+
try:
|
|
223
|
+
resp = self._transport(req)
|
|
224
|
+
except OpensmsError:
|
|
225
|
+
raise
|
|
226
|
+
except _NETWORK_ERRORS as exc:
|
|
227
|
+
if can_retry and attempt <= self.max_retries:
|
|
228
|
+
self._sleep(self._backoff(attempt))
|
|
229
|
+
continue
|
|
230
|
+
raise OpensmsError(0, f"OpenSMS request failed: {exc}") from exc
|
|
231
|
+
|
|
232
|
+
if 200 <= resp.status < 300:
|
|
233
|
+
return self._decode(resp)
|
|
234
|
+
|
|
235
|
+
error = error_from_response(resp)
|
|
236
|
+
if not (can_retry and resp.status in RETRYABLE_STATUSES and attempt <= self.max_retries):
|
|
237
|
+
raise error
|
|
238
|
+
if error.retry_after is not None:
|
|
239
|
+
if error.retry_after > MAX_RETRY_AFTER:
|
|
240
|
+
raise error
|
|
241
|
+
delay = error.retry_after
|
|
242
|
+
else:
|
|
243
|
+
delay = self._backoff(attempt)
|
|
244
|
+
self._sleep(delay)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _backoff(retry_number: int) -> float:
|
|
248
|
+
"""Exponential backoff with full jitter for retry ``n`` (1-based)."""
|
|
249
|
+
ceiling = min(_BACKOFF_CAP, _BACKOFF_BASE * (2 ** (retry_number - 1)))
|
|
250
|
+
return random.uniform(0.0, ceiling)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _decode(resp: TransportResponse) -> Any:
|
|
254
|
+
if resp.status == 204 or not resp.body:
|
|
255
|
+
return None
|
|
256
|
+
text = resp.body.decode("utf-8")
|
|
257
|
+
try:
|
|
258
|
+
return json.loads(text)
|
|
259
|
+
except ValueError:
|
|
260
|
+
return text
|
opensms/_version.py
ADDED
opensms/client.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The OpenSMS client: validates the key and wires the transport into every
|
|
2
|
+
resource."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Iterator, Optional, TypeVar
|
|
7
|
+
|
|
8
|
+
from ._transport import DEFAULT_BASE_URL, HttpTransport, Transport
|
|
9
|
+
from .models import Page
|
|
10
|
+
from .pagination import paginate
|
|
11
|
+
from .resources.analytics import Analytics
|
|
12
|
+
from .resources.batches import Batches
|
|
13
|
+
from .resources.compliance import Compliance
|
|
14
|
+
from .resources.contact_groups import ContactGroups
|
|
15
|
+
from .resources.contacts import Contacts
|
|
16
|
+
from .resources.countries import Countries
|
|
17
|
+
from .resources.inbound import Inbound
|
|
18
|
+
from .resources.lookups import Lookups
|
|
19
|
+
from .resources.messages import Messages
|
|
20
|
+
from .resources.numbers import Numbers
|
|
21
|
+
from .resources.otp import Otp
|
|
22
|
+
from .resources.pricing import Pricing
|
|
23
|
+
from .resources.sandbox import Sandbox
|
|
24
|
+
from .resources.sender_ids import SenderIds
|
|
25
|
+
from .resources.suppressions import Suppressions
|
|
26
|
+
from .resources.templates import Templates
|
|
27
|
+
from .resources.wallet import Wallet
|
|
28
|
+
from .resources.webhooks import Webhooks
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
_PREFIXES = {"sk_test_": "sandbox", "sk_live_": "live"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _environment_for(api_key: Any) -> str:
|
|
36
|
+
"""Mirror the server's ``auth.ValidSecret``: a known prefix and more than
|
|
37
|
+
12 characters after it."""
|
|
38
|
+
if not isinstance(api_key, str) or not api_key:
|
|
39
|
+
raise ValueError("api_key is required")
|
|
40
|
+
for prefix, environment in _PREFIXES.items():
|
|
41
|
+
if api_key.startswith(prefix):
|
|
42
|
+
if len(api_key) - len(prefix) > 12:
|
|
43
|
+
return environment
|
|
44
|
+
break
|
|
45
|
+
raise ValueError("api_key must be an OpenSMS secret key (sk_test_... or sk_live_...)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Opensms:
|
|
49
|
+
"""OpenSMS API client.
|
|
50
|
+
|
|
51
|
+
Example::
|
|
52
|
+
|
|
53
|
+
from opensms import Opensms
|
|
54
|
+
|
|
55
|
+
client = Opensms(api_key="sk_test_...")
|
|
56
|
+
message = client.messages.send(to="+254700000012", text="Hello")
|
|
57
|
+
print(message["id"], message["status"])
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
api_key: ``sk_test_...`` (sandbox) or ``sk_live_...`` (live). Checked
|
|
61
|
+
locally; a malformed key raises ``ValueError`` with no request.
|
|
62
|
+
base_url: API origin, default ``https://api.opensms.io``.
|
|
63
|
+
timeout: Seconds per attempt (connect and read), default 30.
|
|
64
|
+
max_retries: Retries after the first attempt, default 2. ``0``
|
|
65
|
+
disables retries.
|
|
66
|
+
transport: Optional callable performing one HTTP attempt (see
|
|
67
|
+
:data:`opensms.Transport`); defaults to the ``urllib`` transport.
|
|
68
|
+
sleep: Optional replacement for ``time.sleep`` between retries.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
api_key: str,
|
|
74
|
+
*,
|
|
75
|
+
base_url: Optional[str] = None,
|
|
76
|
+
timeout: float = 30.0,
|
|
77
|
+
max_retries: int = 2,
|
|
78
|
+
transport: Optional[Transport] = None,
|
|
79
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._environment = _environment_for(api_key)
|
|
82
|
+
self._http = HttpTransport(
|
|
83
|
+
api_key,
|
|
84
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
max_retries=max_retries,
|
|
87
|
+
transport=transport,
|
|
88
|
+
sleep=sleep,
|
|
89
|
+
)
|
|
90
|
+
self.messages = Messages(self._http)
|
|
91
|
+
self.batches = Batches(self._http)
|
|
92
|
+
self.otp = Otp(self._http)
|
|
93
|
+
self.lookups = Lookups(self._http)
|
|
94
|
+
self.contacts = Contacts(self._http)
|
|
95
|
+
self.contact_groups = ContactGroups(self._http)
|
|
96
|
+
self.templates = Templates(self._http)
|
|
97
|
+
self.webhooks = Webhooks(self._http)
|
|
98
|
+
self.inbound = Inbound(self._http)
|
|
99
|
+
self.numbers = Numbers(self._http)
|
|
100
|
+
self.sender_ids = SenderIds(self._http)
|
|
101
|
+
self.suppressions = Suppressions(self._http)
|
|
102
|
+
self.compliance = Compliance(self._http)
|
|
103
|
+
self.wallet = Wallet(self._http)
|
|
104
|
+
self.pricing = Pricing(self._http)
|
|
105
|
+
self.analytics = Analytics(self._http)
|
|
106
|
+
self.sandbox = Sandbox(self._http)
|
|
107
|
+
self.countries = Countries(self._http)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def environment(self) -> str:
|
|
111
|
+
"""``"sandbox"`` for ``sk_test_`` keys, ``"live"`` for ``sk_live_``."""
|
|
112
|
+
return self._environment
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def base_url(self) -> str:
|
|
116
|
+
return self._http.base_url
|
|
117
|
+
|
|
118
|
+
def paginate(self, list_method: Callable[..., "Page[T]"], *args: Any, **params: Any) -> Iterator[T]:
|
|
119
|
+
"""Iterate every item of a cursor list lazily::
|
|
120
|
+
|
|
121
|
+
for message in client.paginate(client.messages.list, limit=50):
|
|
122
|
+
...
|
|
123
|
+
"""
|
|
124
|
+
return paginate(list_method, *args, **params)
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str:
|
|
127
|
+
return f"Opensms(environment={self._environment!r}, base_url={self._http.base_url!r})"
|
opensms/errors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Error type raised by the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpensmsError(Exception):
|
|
9
|
+
"""Raised for every non-2xx API response, for transport failures that
|
|
10
|
+
survive all retries, and for webhook signature failures.
|
|
11
|
+
|
|
12
|
+
The fields mirror the API's RFC 9457 problem+json body. Most API errors
|
|
13
|
+
carry no ``code``, so branch on :attr:`status` first and use
|
|
14
|
+
:attr:`detail` for display. Insufficient scope is ``401`` on messages and
|
|
15
|
+
OTP but ``403`` everywhere else.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
status: int,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
type: Optional[str] = None,
|
|
24
|
+
title: Optional[str] = None,
|
|
25
|
+
detail: Optional[str] = None,
|
|
26
|
+
code: Optional[str] = None,
|
|
27
|
+
trace_id: Optional[str] = None,
|
|
28
|
+
errors: Optional[Dict[str, List[str]]] = None,
|
|
29
|
+
request_id: Optional[str] = None,
|
|
30
|
+
retry_after: Optional[float] = None,
|
|
31
|
+
body: Any = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
#: HTTP status code. ``0`` means no response (network error, timeout,
|
|
35
|
+
#: or a webhook signature failure).
|
|
36
|
+
self.status = status
|
|
37
|
+
#: Human readable message: ``detail``, else ``title``, else a generic text.
|
|
38
|
+
self.message = message
|
|
39
|
+
#: Problem ``type`` URI, usually ``about:blank``.
|
|
40
|
+
self.type = type
|
|
41
|
+
#: Problem ``title`` such as ``Bad Request``.
|
|
42
|
+
self.title = title
|
|
43
|
+
#: Problem ``detail``, the specific human readable reason.
|
|
44
|
+
self.detail = detail
|
|
45
|
+
#: Machine readable ``code`` when the handler sets one (most do not).
|
|
46
|
+
self.code = code
|
|
47
|
+
#: Problem ``trace_id``, when present.
|
|
48
|
+
self.trace_id = trace_id
|
|
49
|
+
#: Field validation errors, ``{field: [messages]}``, when present.
|
|
50
|
+
self.errors = errors
|
|
51
|
+
#: ``X-Request-ID`` response header (message and OTP admission rejections).
|
|
52
|
+
self.request_id = request_id
|
|
53
|
+
#: ``Retry-After`` in seconds, when the server sent it.
|
|
54
|
+
self.retry_after = retry_after
|
|
55
|
+
#: Raw decoded body (dict) or raw text when the body was not JSON.
|
|
56
|
+
self.body = body
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return (
|
|
60
|
+
f"OpensmsError(status={self.status!r}, message={self.message!r}, "
|
|
61
|
+
f"code={self.code!r})"
|
|
62
|
+
)
|