joinsimplesms 2.8.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.
- joinsimplesms/__init__.py +131 -0
- joinsimplesms/_client.py +201 -0
- joinsimplesms/_errors.py +66 -0
- joinsimplesms/_resources.py +860 -0
- joinsimplesms/_version.py +1 -0
- joinsimplesms/py.typed +0 -0
- joinsimplesms/types.py +464 -0
- joinsimplesms/webhooks.py +119 -0
- joinsimplesms-2.8.0.dist-info/METADATA +314 -0
- joinsimplesms-2.8.0.dist-info/RECORD +11 -0
- joinsimplesms-2.8.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""joinsimplesms - the official Python client for SimpleSMS (joinsimplesms.com).
|
|
2
|
+
|
|
3
|
+
SMS, phone verification, and phone numbers. Standard library only.
|
|
4
|
+
|
|
5
|
+
from joinsimplesms import SimpleSMS
|
|
6
|
+
|
|
7
|
+
client = SimpleSMS() # reads SIMPLESMS_API_KEY
|
|
8
|
+
client.messages.send(to="+15005550006", text="Hello from SimpleSMS")
|
|
9
|
+
|
|
10
|
+
SimpleSMS was called Delivered until 2026-10. ``Delivered`` and
|
|
11
|
+
``DeliveredError`` are kept as deprecated aliases of ``SimpleSMS`` and
|
|
12
|
+
``SimpleSMSError``, and ``DELIVERED_API_KEY`` / ``DELIVERED_BASE_URL`` /
|
|
13
|
+
``DELIVERED_FROM`` are still read when the ``SIMPLESMS_*`` names are unset.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
21
|
+
|
|
22
|
+
from ._client import BaseClient, Response, Transport, _env, urllib_transport
|
|
23
|
+
from ._errors import SimpleSMSError
|
|
24
|
+
from ._resources import (
|
|
25
|
+
Automations,
|
|
26
|
+
BalanceResource,
|
|
27
|
+
Consent,
|
|
28
|
+
Contacts,
|
|
29
|
+
Customers,
|
|
30
|
+
Events,
|
|
31
|
+
LookupResource,
|
|
32
|
+
Messages,
|
|
33
|
+
Numbers,
|
|
34
|
+
Registrations,
|
|
35
|
+
Schedules,
|
|
36
|
+
Verify,
|
|
37
|
+
Webhooks,
|
|
38
|
+
)
|
|
39
|
+
from ._version import VERSION
|
|
40
|
+
from .webhooks import EVENT_ID_HEADERS, SIGNATURE_HEADERS, event_id_from_headers, signature_from_headers, verify_webhook
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SimpleSMS(BaseClient):
|
|
44
|
+
"""The SimpleSMS client.
|
|
45
|
+
|
|
46
|
+
:param api_key: defaults to the ``SIMPLESMS_API_KEY`` environment variable.
|
|
47
|
+
:param default_from: the number ``messages.send`` uses when you omit
|
|
48
|
+
``from_``. Defaults to ``SIMPLESMS_FROM``, else your account's only
|
|
49
|
+
active number (looked up once; an error if you have several).
|
|
50
|
+
:param base_url: defaults to ``https://api.joinsimplesms.com``.
|
|
51
|
+
:param max_retries: retries for transient failures (429, 5xx, network).
|
|
52
|
+
:param timeout: per-request timeout, seconds.
|
|
53
|
+
:param transport: swap the HTTP layer (tests, tracing, proxies).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
#: ``SimpleSMS.webhooks.verify(...)`` works without a client; an instance
|
|
57
|
+
#: gets its own ``webhooks`` with the endpoint methods too.
|
|
58
|
+
webhooks: Webhooks = Webhooks()
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: Optional[str] = None,
|
|
63
|
+
*,
|
|
64
|
+
default_from: Optional[str] = None,
|
|
65
|
+
base_url: Optional[str] = None,
|
|
66
|
+
max_retries: int = 2,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
transport: Optional[Transport] = None,
|
|
69
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(
|
|
72
|
+
api_key, base_url=base_url, max_retries=max_retries, timeout=timeout, transport=transport, sleep=sleep
|
|
73
|
+
)
|
|
74
|
+
self._default_from = default_from or _env("SIMPLESMS_FROM", "DELIVERED_FROM")
|
|
75
|
+
self._from_lock = threading.Lock()
|
|
76
|
+
self.messages = Messages(self)
|
|
77
|
+
self.verify = Verify(self)
|
|
78
|
+
self.numbers = Numbers(self)
|
|
79
|
+
self.lookup = LookupResource(self)
|
|
80
|
+
self.balance = BalanceResource(self)
|
|
81
|
+
self.events = Events(self)
|
|
82
|
+
self.consent = Consent(self)
|
|
83
|
+
self.customers = Customers(self)
|
|
84
|
+
self.contacts = Contacts(self)
|
|
85
|
+
self.automations = Automations(self)
|
|
86
|
+
self.schedules = Schedules(self)
|
|
87
|
+
self.registrations = Registrations(self)
|
|
88
|
+
self.webhooks = Webhooks(self)
|
|
89
|
+
|
|
90
|
+
def _resolve_from(self) -> str:
|
|
91
|
+
if self._default_from:
|
|
92
|
+
return self._default_from
|
|
93
|
+
with self._from_lock:
|
|
94
|
+
if not self._default_from:
|
|
95
|
+
active = [n["phone_number"] for n in self.numbers.list()["data"] if n.get("status") == "active"]
|
|
96
|
+
if len(active) != 1:
|
|
97
|
+
raise SimpleSMSError(
|
|
98
|
+
"Pass from_=... (or set SIMPLESMS_FROM): your account has "
|
|
99
|
+
+ (f"{len(active)} numbers." if active else "no numbers yet."),
|
|
100
|
+
code="invalid_request",
|
|
101
|
+
status=0,
|
|
102
|
+
param="from",
|
|
103
|
+
)
|
|
104
|
+
self._default_from = active[0]
|
|
105
|
+
return self._default_from
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
#: Deprecated alias of :class:`SimpleSMS` (the product's name until 2026-10).
|
|
109
|
+
#: The same class, so existing ``from ... import Delivered`` code keeps working.
|
|
110
|
+
Delivered = SimpleSMS
|
|
111
|
+
#: Deprecated alias of :class:`SimpleSMSError`; ``except DeliveredError`` still catches.
|
|
112
|
+
DeliveredError = SimpleSMSError
|
|
113
|
+
|
|
114
|
+
__version__ = VERSION
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"SimpleSMS",
|
|
118
|
+
"SimpleSMSError",
|
|
119
|
+
"Delivered",
|
|
120
|
+
"DeliveredError",
|
|
121
|
+
"verify_webhook",
|
|
122
|
+
"signature_from_headers",
|
|
123
|
+
"event_id_from_headers",
|
|
124
|
+
"SIGNATURE_HEADERS",
|
|
125
|
+
"EVENT_ID_HEADERS",
|
|
126
|
+
"Response",
|
|
127
|
+
"Transport",
|
|
128
|
+
"urllib_transport",
|
|
129
|
+
"VERSION",
|
|
130
|
+
"__version__",
|
|
131
|
+
]
|
joinsimplesms/_client.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Callable, Dict, Mapping, NamedTuple, Optional
|
|
13
|
+
|
|
14
|
+
from ._errors import SimpleSMSError
|
|
15
|
+
from ._version import VERSION
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.joinsimplesms.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Response(NamedTuple):
|
|
21
|
+
status: int
|
|
22
|
+
#: Header names lowercased.
|
|
23
|
+
headers: Mapping[str, str]
|
|
24
|
+
body: bytes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: (method, url, headers, body, timeout) -> Response. Raise on network failure.
|
|
28
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Response]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def urllib_transport(
|
|
32
|
+
method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float
|
|
33
|
+
) -> Response:
|
|
34
|
+
"""The default transport: the standard library, nothing to install."""
|
|
35
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (https by default)
|
|
38
|
+
return Response(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read())
|
|
39
|
+
except urllib.error.HTTPError as err:
|
|
40
|
+
# 4xx/5xx still carry the API's JSON error body.
|
|
41
|
+
return Response(err.code, {k.lower(): v for k, v in err.headers.items()}, err.read())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _query(params: Mapping[str, Any]) -> str:
|
|
45
|
+
pairs = {k: v for k, v in params.items() if v is not None}
|
|
46
|
+
if not pairs:
|
|
47
|
+
return ""
|
|
48
|
+
return "?" + urllib.parse.urlencode({k: str(v).lower() if isinstance(v, bool) else v for k, v in pairs.items()})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _quote(segment: str) -> str:
|
|
52
|
+
return urllib.parse.quote(segment, safe="")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _env(*names: str) -> Optional[str]:
|
|
56
|
+
"""The first of these environment variables that is set and non-empty.
|
|
57
|
+
|
|
58
|
+
The first name is the current one; the rest are deprecated names kept so
|
|
59
|
+
that deployments configured before a rename keep working.
|
|
60
|
+
"""
|
|
61
|
+
for name in names:
|
|
62
|
+
value = os.environ.get(name)
|
|
63
|
+
if value:
|
|
64
|
+
return value
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BaseClient:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: Optional[str] = None,
|
|
72
|
+
*,
|
|
73
|
+
base_url: Optional[str] = None,
|
|
74
|
+
max_retries: int = 2,
|
|
75
|
+
timeout: float = 30.0,
|
|
76
|
+
transport: Optional[Transport] = None,
|
|
77
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
78
|
+
) -> None:
|
|
79
|
+
# SIMPLESMS_API_KEY first; names from before the rename still work.
|
|
80
|
+
key = api_key or _env("SIMPLESMS_API_KEY", "DELIVERED_API_KEY", "RESMS_API_KEY")
|
|
81
|
+
if not key:
|
|
82
|
+
raise SimpleSMSError(
|
|
83
|
+
"No SimpleSMS key. Pass one to SimpleSMS(...) or set SIMPLESMS_API_KEY. "
|
|
84
|
+
"Get one free at https://joinsimplesms.com/console",
|
|
85
|
+
code="invalid_api_key",
|
|
86
|
+
status=0,
|
|
87
|
+
)
|
|
88
|
+
self._api_key = key
|
|
89
|
+
self.base_url = (
|
|
90
|
+
base_url or _env("SIMPLESMS_BASE_URL", "DELIVERED_BASE_URL", "RESMS_BASE_URL") or DEFAULT_BASE_URL
|
|
91
|
+
).rstrip("/")
|
|
92
|
+
self.max_retries = max_retries
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self._transport: Transport = transport or urllib_transport
|
|
95
|
+
self._sleep = sleep
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_test_mode(self) -> bool:
|
|
99
|
+
"""True when this client is using a sandbox key."""
|
|
100
|
+
return self._api_key.startswith((
|
|
101
|
+
("ssms_sk_test_", "dsms_sk_test_", "resms_sk_test_")
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
def _request(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
path: str,
|
|
108
|
+
*,
|
|
109
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
110
|
+
idempotency_key: Optional[str] = None,
|
|
111
|
+
retryable: bool = False,
|
|
112
|
+
raw: bool = False,
|
|
113
|
+
) -> Any:
|
|
114
|
+
headers = {
|
|
115
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
116
|
+
"User-Agent": f"joinsimplesms-python/{VERSION}",
|
|
117
|
+
"Accept": "application/json",
|
|
118
|
+
}
|
|
119
|
+
data: Optional[bytes] = None
|
|
120
|
+
if body is not None:
|
|
121
|
+
headers["Content-Type"] = "application/json"
|
|
122
|
+
# Sent as given: resources omit unset fields themselves, so an
|
|
123
|
+
# explicit None (e.g. unassigning a number) reaches the API as null.
|
|
124
|
+
data = json.dumps(dict(body)).encode("utf-8")
|
|
125
|
+
if idempotency_key:
|
|
126
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
127
|
+
url = f"{self.base_url}/v1{path}"
|
|
128
|
+
|
|
129
|
+
last: Optional[SimpleSMSError] = None
|
|
130
|
+
for attempt in range(self.max_retries + 1):
|
|
131
|
+
if attempt > 0 and last is not None:
|
|
132
|
+
self._sleep(self._backoff(attempt, last))
|
|
133
|
+
try:
|
|
134
|
+
res = self._transport(method, url, dict(headers), data, self.timeout)
|
|
135
|
+
except (socket.timeout, TimeoutError) as exc:
|
|
136
|
+
last = SimpleSMSError(f"Request timed out after {self.timeout}s", code="connection_error", status=0)
|
|
137
|
+
last.__cause__ = exc
|
|
138
|
+
except (urllib.error.URLError, OSError, ConnectionError) as exc:
|
|
139
|
+
last = SimpleSMSError(f"Could not reach the SimpleSMS API: {exc}", code="connection_error", status=0)
|
|
140
|
+
last.__cause__ = exc
|
|
141
|
+
else:
|
|
142
|
+
if 200 <= res.status < 300:
|
|
143
|
+
if raw:
|
|
144
|
+
return res.body.decode("utf-8")
|
|
145
|
+
return json.loads(res.body.decode("utf-8")) if res.body else {}
|
|
146
|
+
last = self._error_from(res)
|
|
147
|
+
|
|
148
|
+
# Retry only what is safe: a POST without an idempotency key is
|
|
149
|
+
# never resent, and a verification cooldown is policy, not a blip.
|
|
150
|
+
if not retryable or not last.retryable:
|
|
151
|
+
break
|
|
152
|
+
if last.code == "rate_limited" and path.startswith("/verify"):
|
|
153
|
+
break
|
|
154
|
+
assert last is not None
|
|
155
|
+
raise last
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _backoff(attempt: int, err: SimpleSMSError) -> float:
|
|
159
|
+
if err.retry_after is not None and err.retry_after >= 0:
|
|
160
|
+
return float(err.retry_after)
|
|
161
|
+
base: float = min(0.25 * float(2**attempt), 4.0)
|
|
162
|
+
return base + random.uniform(0, base / 4)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _error_from(res: Response) -> SimpleSMSError:
|
|
166
|
+
try:
|
|
167
|
+
payload = json.loads(res.body.decode("utf-8")) if res.body else {}
|
|
168
|
+
except ValueError:
|
|
169
|
+
payload = {}
|
|
170
|
+
err = payload.get("error") if isinstance(payload, dict) else None
|
|
171
|
+
err = err if isinstance(err, dict) else {}
|
|
172
|
+
retry_after: Optional[float] = None
|
|
173
|
+
if err.get("retry_after") is not None:
|
|
174
|
+
retry_after = float(err["retry_after"])
|
|
175
|
+
elif res.headers.get("retry-after"):
|
|
176
|
+
try:
|
|
177
|
+
retry_after = float(res.headers["retry-after"])
|
|
178
|
+
except ValueError:
|
|
179
|
+
retry_after = None
|
|
180
|
+
return SimpleSMSError(
|
|
181
|
+
err.get("message") or f"Request failed with status {res.status}",
|
|
182
|
+
code=err.get("code") or ("internal_error" if res.status >= 500 else "invalid_request"),
|
|
183
|
+
status=res.status,
|
|
184
|
+
param=err.get("param"),
|
|
185
|
+
retry_after=retry_after,
|
|
186
|
+
reason=err.get("reason"),
|
|
187
|
+
balance_micro_usd=err.get("balance_micro_usd"),
|
|
188
|
+
required_micro_usd=err.get("required_micro_usd"),
|
|
189
|
+
top_up_url=err.get("top_up_url"),
|
|
190
|
+
send_after=err.get("send_after"),
|
|
191
|
+
recipient_local_time=err.get("recipient_local_time"),
|
|
192
|
+
timezone=err.get("timezone"),
|
|
193
|
+
message_id=err.get("message_id"),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def new_idempotency_key() -> str:
|
|
198
|
+
return f"sdk_{uuid.uuid4().hex}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["BaseClient", "Response", "Transport", "urllib_transport", "new_idempotency_key", "_query", "_quote"]
|
joinsimplesms/_errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SimpleSMSError(Exception):
|
|
7
|
+
"""Every failure the client raises.
|
|
8
|
+
|
|
9
|
+
``code`` is the API's machine-readable error code (``"rate_limited"``,
|
|
10
|
+
``"invalid_request"``, ...), plus two the client adds itself:
|
|
11
|
+
``"connection_error"`` (the API was unreachable or timed out) and
|
|
12
|
+
``"invalid_signature"`` (a webhook failed verification).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
code: str,
|
|
20
|
+
status: int,
|
|
21
|
+
param: Optional[str] = None,
|
|
22
|
+
retry_after: Optional[float] = None,
|
|
23
|
+
reason: Optional[str] = None,
|
|
24
|
+
balance_micro_usd: Optional[int] = None,
|
|
25
|
+
required_micro_usd: Optional[int] = None,
|
|
26
|
+
top_up_url: Optional[str] = None,
|
|
27
|
+
send_after: Optional[str] = None,
|
|
28
|
+
recipient_local_time: Optional[str] = None,
|
|
29
|
+
timezone: Optional[str] = None,
|
|
30
|
+
message_id: Optional[str] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
self.code = code
|
|
35
|
+
self.status = status
|
|
36
|
+
#: Which request field was wrong, when the API could tell.
|
|
37
|
+
self.param = param
|
|
38
|
+
#: Seconds to wait, on rate limits.
|
|
39
|
+
self.retry_after = retry_after
|
|
40
|
+
#: Why a verification was blocked.
|
|
41
|
+
self.reason = reason
|
|
42
|
+
#: ``insufficient_credits`` only: your balance, in millionths of a dollar.
|
|
43
|
+
self.balance_micro_usd = balance_micro_usd
|
|
44
|
+
#: ``insufficient_credits`` only: what the call costs, in millionths of a dollar.
|
|
45
|
+
self.required_micro_usd = required_micro_usd
|
|
46
|
+
#: ``insufficient_credits`` only: where to add credits.
|
|
47
|
+
self.top_up_url = top_up_url
|
|
48
|
+
#: ``quiet_hours`` only: when the message may be sent (ISO timestamp).
|
|
49
|
+
self.send_after = send_after
|
|
50
|
+
#: ``quiet_hours`` only: the recipient's local time, 24-hour "HH:MM".
|
|
51
|
+
self.recipient_local_time = recipient_local_time
|
|
52
|
+
#: ``quiet_hours`` only: the IANA time zone read from the number's area code.
|
|
53
|
+
self.timezone = timezone
|
|
54
|
+
#: The blocked message stored in your log for this refusal, when there is one.
|
|
55
|
+
self.message_id = message_id
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def retryable(self) -> bool:
|
|
59
|
+
"""True when retrying the same call later could plausibly succeed."""
|
|
60
|
+
return (
|
|
61
|
+
self.status >= 500
|
|
62
|
+
or self.code in ("rate_limited", "idempotency_in_progress", "carrier_error", "connection_error")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"SimpleSMSError(code={self.code!r}, status={self.status}, message={self.message!r})"
|