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 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
@@ -0,0 +1,3 @@
1
+ """Package version, shared by the User-Agent header and packaging metadata."""
2
+
3
+ __version__ = "0.1.0"
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
+ )