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.
@@ -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
+ ]
@@ -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"]
@@ -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})"