textbee 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.
- textbee/__init__.py +67 -0
- textbee/_client.py +456 -0
- textbee/_errors.py +31 -0
- textbee/_sms_utils.py +363 -0
- textbee/_types.py +244 -0
- textbee/_version.py +1 -0
- textbee/_webhooks.py +55 -0
- textbee/py.typed +0 -0
- textbee-0.1.0.dist-info/METADATA +307 -0
- textbee-0.1.0.dist-info/RECORD +12 -0
- textbee-0.1.0.dist-info/WHEEL +4 -0
- textbee-0.1.0.dist-info/licenses/LICENSE +21 -0
textbee/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Official Python SDK for textbee.dev, the open source SMS gateway."""
|
|
2
|
+
|
|
3
|
+
from ._client import Textbee
|
|
4
|
+
from ._errors import TextbeeError
|
|
5
|
+
from ._sms_utils import (
|
|
6
|
+
SmsEncoding,
|
|
7
|
+
SmsSegmentInfo,
|
|
8
|
+
count_sms_segments,
|
|
9
|
+
find_non_gsm7_characters,
|
|
10
|
+
get_sms_encoding,
|
|
11
|
+
is_valid_e164,
|
|
12
|
+
normalize_phone_number,
|
|
13
|
+
sanitize_for_gsm7,
|
|
14
|
+
)
|
|
15
|
+
from ._types import (
|
|
16
|
+
Device,
|
|
17
|
+
Direction,
|
|
18
|
+
Message,
|
|
19
|
+
MessageDevice,
|
|
20
|
+
MessageList,
|
|
21
|
+
MessageListMeta,
|
|
22
|
+
MessageStatus,
|
|
23
|
+
Order,
|
|
24
|
+
SendSmsImmediateResponse,
|
|
25
|
+
SendSmsQueuedResponse,
|
|
26
|
+
SendSmsResponse,
|
|
27
|
+
SendSmsWarning,
|
|
28
|
+
Sms,
|
|
29
|
+
SmsBatch,
|
|
30
|
+
SmsBatchResult,
|
|
31
|
+
SmsBatchStatus,
|
|
32
|
+
WebhookEvent,
|
|
33
|
+
)
|
|
34
|
+
from ._version import __version__
|
|
35
|
+
from ._webhooks import verify_webhook_signature
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Device",
|
|
39
|
+
"Direction",
|
|
40
|
+
"Message",
|
|
41
|
+
"MessageDevice",
|
|
42
|
+
"MessageList",
|
|
43
|
+
"MessageListMeta",
|
|
44
|
+
"MessageStatus",
|
|
45
|
+
"Order",
|
|
46
|
+
"SendSmsImmediateResponse",
|
|
47
|
+
"SendSmsQueuedResponse",
|
|
48
|
+
"SendSmsResponse",
|
|
49
|
+
"SendSmsWarning",
|
|
50
|
+
"Sms",
|
|
51
|
+
"SmsBatch",
|
|
52
|
+
"SmsBatchResult",
|
|
53
|
+
"SmsBatchStatus",
|
|
54
|
+
"SmsEncoding",
|
|
55
|
+
"SmsSegmentInfo",
|
|
56
|
+
"Textbee",
|
|
57
|
+
"TextbeeError",
|
|
58
|
+
"WebhookEvent",
|
|
59
|
+
"__version__",
|
|
60
|
+
"count_sms_segments",
|
|
61
|
+
"find_non_gsm7_characters",
|
|
62
|
+
"get_sms_encoding",
|
|
63
|
+
"is_valid_e164",
|
|
64
|
+
"normalize_phone_number",
|
|
65
|
+
"sanitize_for_gsm7",
|
|
66
|
+
"verify_webhook_signature",
|
|
67
|
+
]
|
textbee/_client.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Union, cast
|
|
8
|
+
from urllib.error import HTTPError
|
|
9
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
10
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
11
|
+
|
|
12
|
+
from ._errors import TextbeeError
|
|
13
|
+
from ._types import (
|
|
14
|
+
Device,
|
|
15
|
+
Direction,
|
|
16
|
+
Message,
|
|
17
|
+
MessageList,
|
|
18
|
+
MessageStatus,
|
|
19
|
+
Order,
|
|
20
|
+
SendSmsResponse,
|
|
21
|
+
Sms,
|
|
22
|
+
SmsBatchResult,
|
|
23
|
+
)
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1"
|
|
27
|
+
DEFAULT_TIMEOUT = 30.0
|
|
28
|
+
CLIENT_ID = f"textbee-python/{__version__}"
|
|
29
|
+
|
|
30
|
+
DateInput = Union[str, datetime]
|
|
31
|
+
QueryValue = Union[str, int, None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Textbee:
|
|
35
|
+
"""Client for the textbee REST API.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])
|
|
39
|
+
textbee.send_sms(recipients=["+12025550123"], message="Hello from textbee!")
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Create a client.
|
|
51
|
+
|
|
52
|
+
``api_key`` comes from the textbee dashboard and is sent as the
|
|
53
|
+
``x-api-key`` header.
|
|
54
|
+
|
|
55
|
+
``base_url`` points the client at a self-hosted instance instead of
|
|
56
|
+
``https://api.textbee.dev/api/v1``. A bare origin such as
|
|
57
|
+
``https://sms.example.com`` gets ``/api/v1`` added. A URL that contains
|
|
58
|
+
``/api/v1`` is cut there, so a pasted endpoint URL still works. Any
|
|
59
|
+
other path is used as given, for proxies that map a prefix onto the
|
|
60
|
+
API. A value with no scheme gets ``https://``. An invalid URL raises
|
|
61
|
+
``ValueError`` rather than falling back, so a typo never sends your key
|
|
62
|
+
to the public API.
|
|
63
|
+
|
|
64
|
+
``timeout`` is in seconds and applies to each connect and read, not to
|
|
65
|
+
the request as a whole.
|
|
66
|
+
"""
|
|
67
|
+
api_key = (api_key or "").strip()
|
|
68
|
+
if not api_key:
|
|
69
|
+
raise ValueError("Textbee requires an api_key")
|
|
70
|
+
# Never echo the key: this error ends up in logs and error trackers.
|
|
71
|
+
if not api_key.isascii() or not api_key.isprintable():
|
|
72
|
+
raise ValueError("api_key contains invalid characters")
|
|
73
|
+
|
|
74
|
+
self._api_key = api_key
|
|
75
|
+
self._base_url = _normalize_base_url(base_url)
|
|
76
|
+
self._timeout = timeout
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return f"Textbee(base_url={self._base_url!r})"
|
|
80
|
+
|
|
81
|
+
def send_sms(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
message: str,
|
|
85
|
+
recipients: Sequence[str],
|
|
86
|
+
device_id: str | None = None,
|
|
87
|
+
sim_subscription_id: int | None = None,
|
|
88
|
+
scheduled_at: DateInput | None = None,
|
|
89
|
+
) -> SendSmsResponse:
|
|
90
|
+
"""Send an SMS to one or more recipients.
|
|
91
|
+
|
|
92
|
+
``recipients`` are phone numbers in international format.
|
|
93
|
+
|
|
94
|
+
``device_id`` picks the sending phone. Get ids from ``get_devices()``.
|
|
95
|
+
Omit it and textbee picks the sender: your default device first,
|
|
96
|
+
otherwise the enabled device with the most recent heartbeat. A malformed
|
|
97
|
+
id fails with 400 rather than falling back, so a typo cannot send from
|
|
98
|
+
the wrong phone.
|
|
99
|
+
|
|
100
|
+
``sim_subscription_id`` picks the SIM on a multi-SIM phone. It is not
|
|
101
|
+
validated: an id that does not match a SIM in the phone is ignored and
|
|
102
|
+
the message goes out from the SIM configured in the app, or the system
|
|
103
|
+
default. Find the id in the Android app under Dashboard, "SIM Cards".
|
|
104
|
+
|
|
105
|
+
``scheduled_at`` sends later instead of now, as an ISO 8601 string or a
|
|
106
|
+
timezone-aware ``datetime`` in the future.
|
|
107
|
+
"""
|
|
108
|
+
if isinstance(recipients, str):
|
|
109
|
+
raise TypeError("recipients must be a list of phone numbers, not a string")
|
|
110
|
+
|
|
111
|
+
body: dict[str, Any] = {
|
|
112
|
+
"message": message,
|
|
113
|
+
"recipients": list(recipients),
|
|
114
|
+
}
|
|
115
|
+
if device_id is not None:
|
|
116
|
+
body["deviceId"] = device_id
|
|
117
|
+
if sim_subscription_id is not None:
|
|
118
|
+
body["simSubscriptionId"] = sim_subscription_id
|
|
119
|
+
if scheduled_at is not None:
|
|
120
|
+
body["scheduledAt"] = _to_iso_string(scheduled_at, "scheduled_at")
|
|
121
|
+
|
|
122
|
+
payload = self._request("POST", "/gateway/send-sms", body=body)
|
|
123
|
+
return cast(SendSmsResponse, _data(payload))
|
|
124
|
+
|
|
125
|
+
def get_devices(self) -> list[Device]:
|
|
126
|
+
"""List the devices registered to this account."""
|
|
127
|
+
payload = self._request("GET", "/gateway/devices")
|
|
128
|
+
return cast(list[Device], _data(payload))
|
|
129
|
+
|
|
130
|
+
def get_device(self, device_id: str) -> Device:
|
|
131
|
+
"""Fetch a single device by id."""
|
|
132
|
+
payload = self._request("GET", f"/gateway/devices/{_segment(device_id)}")
|
|
133
|
+
return cast(Device, _data(payload))
|
|
134
|
+
|
|
135
|
+
def set_default_device(self, device_id: str) -> Device:
|
|
136
|
+
"""Make a device the default sender for requests that omit ``device_id``."""
|
|
137
|
+
payload = self._request("POST", f"/gateway/devices/{_segment(device_id)}/set-default")
|
|
138
|
+
return cast(Device, _data(payload))
|
|
139
|
+
|
|
140
|
+
def get_messages(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
device_ids: Sequence[str] | None = None,
|
|
144
|
+
sms_batch_id: str | None = None,
|
|
145
|
+
direction: Direction | None = None,
|
|
146
|
+
status: MessageStatus | None = None,
|
|
147
|
+
search: str | None = None,
|
|
148
|
+
from_: DateInput | None = None,
|
|
149
|
+
to: DateInput | None = None,
|
|
150
|
+
order: Order | None = None,
|
|
151
|
+
page: int | None = None,
|
|
152
|
+
limit: int | None = None,
|
|
153
|
+
cursor: str | None = None,
|
|
154
|
+
) -> MessageList:
|
|
155
|
+
"""Page through the account's sent and received messages.
|
|
156
|
+
|
|
157
|
+
``device_ids`` narrows to some devices; omit it for every device.
|
|
158
|
+
``sms_batch_id`` narrows to one send; combine it with
|
|
159
|
+
``status="failed"`` to list the recipients that failed. ``search`` is a
|
|
160
|
+
free text match across body, recipient and sender.
|
|
161
|
+
|
|
162
|
+
``from_`` is an inclusive lower bound on when the platform stored the
|
|
163
|
+
message, ``to`` an exclusive upper bound. Pass a timezone-aware
|
|
164
|
+
``datetime`` or a string with an explicit timezone
|
|
165
|
+
(``2026-08-01T00:00:00Z``); a bare date like ``2026-08-01`` is read as
|
|
166
|
+
UTC midnight.
|
|
167
|
+
|
|
168
|
+
``page`` is 1-based and ``limit`` defaults to 50, capped at 100 by the
|
|
169
|
+
API. ``cursor`` is ``meta["nextCursor"]`` from a previous response and
|
|
170
|
+
is mutually exclusive with ``page``; in cursor mode ``meta`` omits the
|
|
171
|
+
total count.
|
|
172
|
+
"""
|
|
173
|
+
if isinstance(device_ids, str):
|
|
174
|
+
raise TypeError("device_ids must be a list of device ids, not a string")
|
|
175
|
+
|
|
176
|
+
# The endpoint returns {data, meta} with no outer wrapper, so the whole
|
|
177
|
+
# payload is the result.
|
|
178
|
+
payload = self._request(
|
|
179
|
+
"GET",
|
|
180
|
+
"/gateway/messages",
|
|
181
|
+
query={
|
|
182
|
+
"deviceIds": ",".join(device_ids) if device_ids else None,
|
|
183
|
+
"smsBatchId": sms_batch_id,
|
|
184
|
+
"direction": direction,
|
|
185
|
+
"status": status,
|
|
186
|
+
"search": search,
|
|
187
|
+
"from": _to_iso_string(from_, "from_"),
|
|
188
|
+
"to": _to_iso_string(to, "to"),
|
|
189
|
+
"order": order,
|
|
190
|
+
"page": page,
|
|
191
|
+
"limit": limit,
|
|
192
|
+
"cursor": cursor,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
_data(payload)
|
|
196
|
+
return cast(MessageList, payload)
|
|
197
|
+
|
|
198
|
+
def iterate_messages(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
device_ids: Sequence[str] | None = None,
|
|
202
|
+
sms_batch_id: str | None = None,
|
|
203
|
+
direction: Direction | None = None,
|
|
204
|
+
status: MessageStatus | None = None,
|
|
205
|
+
search: str | None = None,
|
|
206
|
+
from_: DateInput | None = None,
|
|
207
|
+
to: DateInput | None = None,
|
|
208
|
+
order: Order | None = None,
|
|
209
|
+
page_size: int | None = None,
|
|
210
|
+
) -> Iterator[Message]:
|
|
211
|
+
"""Iterate every message matching the filters, following the cursor to the end.
|
|
212
|
+
|
|
213
|
+
Takes the ``get_messages`` filters minus the paging knobs it drives
|
|
214
|
+
itself. ``page_size`` is how many messages each request fetches (the
|
|
215
|
+
API's ``limit``), not a cap on the total. Use ``order="asc"`` with a
|
|
216
|
+
``from_`` bound to drain forward when polling. ``from_`` is inclusive,
|
|
217
|
+
so a poll that resumes from the last ``createdAt`` sees that message
|
|
218
|
+
again; skip ids you have already handled.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
for message in textbee.iterate_messages(direction="received"):
|
|
222
|
+
handle(message)
|
|
223
|
+
```
|
|
224
|
+
"""
|
|
225
|
+
filters: dict[str, Any] = {
|
|
226
|
+
"device_ids": device_ids,
|
|
227
|
+
"sms_batch_id": sms_batch_id,
|
|
228
|
+
"direction": direction,
|
|
229
|
+
"status": status,
|
|
230
|
+
"search": search,
|
|
231
|
+
"from_": from_,
|
|
232
|
+
"to": to,
|
|
233
|
+
"order": order,
|
|
234
|
+
"limit": page_size,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
page = self.get_messages(**filters)
|
|
238
|
+
yield from page["data"]
|
|
239
|
+
|
|
240
|
+
while page["meta"].get("nextCursor"):
|
|
241
|
+
page = self.get_messages(**filters, cursor=page["meta"].get("nextCursor"))
|
|
242
|
+
yield from page["data"]
|
|
243
|
+
|
|
244
|
+
def get_sms(self, device_id: str, sms_id: str) -> Sms:
|
|
245
|
+
"""Fetch a single message, including its delivery status."""
|
|
246
|
+
payload = self._request(
|
|
247
|
+
"GET",
|
|
248
|
+
f"/gateway/devices/{_segment(device_id)}/sms/{_segment(sms_id)}",
|
|
249
|
+
)
|
|
250
|
+
return cast(Sms, _data(payload))
|
|
251
|
+
|
|
252
|
+
def get_sms_batch(self, device_id: str, sms_batch_id: str) -> SmsBatchResult:
|
|
253
|
+
"""Fetch a batch and every message in it, by the ``smsBatchId`` a send returns.
|
|
254
|
+
|
|
255
|
+
Needs the id of the device that sent the batch. When textbee picked the
|
|
256
|
+
device, use ``get_messages(sms_batch_id=...)`` instead.
|
|
257
|
+
"""
|
|
258
|
+
payload = self._request(
|
|
259
|
+
"GET",
|
|
260
|
+
f"/gateway/devices/{_segment(device_id)}/sms-batch/{_segment(sms_batch_id)}",
|
|
261
|
+
)
|
|
262
|
+
return cast(SmsBatchResult, _data(payload))
|
|
263
|
+
|
|
264
|
+
def _request(
|
|
265
|
+
self,
|
|
266
|
+
method: str,
|
|
267
|
+
path: str,
|
|
268
|
+
*,
|
|
269
|
+
query: Mapping[str, QueryValue] | None = None,
|
|
270
|
+
body: Mapping[str, Any] | None = None,
|
|
271
|
+
) -> Any:
|
|
272
|
+
url = f"{self._base_url}{path}"
|
|
273
|
+
params = {key: str(value) for key, value in (query or {}).items() if value is not None}
|
|
274
|
+
if params:
|
|
275
|
+
url = f"{url}?{urlencode(params)}"
|
|
276
|
+
|
|
277
|
+
headers = {
|
|
278
|
+
"x-api-key": self._api_key,
|
|
279
|
+
"user-agent": CLIENT_ID,
|
|
280
|
+
"x-sdk-client": CLIENT_ID,
|
|
281
|
+
"accept": "application/json",
|
|
282
|
+
}
|
|
283
|
+
data = None
|
|
284
|
+
if body is not None:
|
|
285
|
+
headers["content-type"] = "application/json"
|
|
286
|
+
data = json.dumps(body).encode("utf-8")
|
|
287
|
+
|
|
288
|
+
request = Request(url, data=data, headers=headers, method=method)
|
|
289
|
+
location = None
|
|
290
|
+
try:
|
|
291
|
+
with _OPENER.open(request, timeout=self._timeout) as response:
|
|
292
|
+
status: int = response.status
|
|
293
|
+
raw: bytes = response.read()
|
|
294
|
+
except HTTPError as error:
|
|
295
|
+
status = error.code
|
|
296
|
+
location = error.headers.get("location") if error.headers else None
|
|
297
|
+
raw = error.read()
|
|
298
|
+
error.close()
|
|
299
|
+
|
|
300
|
+
payload = _parse_body(raw)
|
|
301
|
+
|
|
302
|
+
if 300 <= status < 400:
|
|
303
|
+
# Redirects are refused so the key never follows one to another
|
|
304
|
+
# host or to plain http.
|
|
305
|
+
target = f" to {location}" if location else ""
|
|
306
|
+
raise TextbeeError(
|
|
307
|
+
status, f"base_url redirects{target}. Set base_url to the final URL.", payload
|
|
308
|
+
)
|
|
309
|
+
if not 200 <= status < 300:
|
|
310
|
+
raise TextbeeError(status, _error_message(payload, status), payload)
|
|
311
|
+
if not isinstance(payload, dict):
|
|
312
|
+
raise TextbeeError(
|
|
313
|
+
status,
|
|
314
|
+
"The response was not a JSON object. Check that base_url points at the "
|
|
315
|
+
"textbee API.",
|
|
316
|
+
payload,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return payload
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _RefuseRedirects(HTTPRedirectHandler):
|
|
323
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_OPENER = build_opener(_RefuseRedirects)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _data(payload: dict[str, Any]) -> Any:
|
|
331
|
+
if "data" not in payload:
|
|
332
|
+
raise TextbeeError(
|
|
333
|
+
200,
|
|
334
|
+
"The response had no data field. Check that base_url points at the textbee API.",
|
|
335
|
+
payload,
|
|
336
|
+
)
|
|
337
|
+
return payload["data"]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _normalize_base_url(raw: str | None) -> str:
|
|
341
|
+
"""Resolve the API root from a base URL. Same rule as textbee-js.
|
|
342
|
+
|
|
343
|
+
A bare origin gets /api/v1, a path containing /api/v1 is cut there, and
|
|
344
|
+
any other path is kept as given for proxies that map a prefix onto the API.
|
|
345
|
+
"""
|
|
346
|
+
value = (raw or "").strip()
|
|
347
|
+
if not value:
|
|
348
|
+
return DEFAULT_BASE_URL
|
|
349
|
+
|
|
350
|
+
with_scheme = value if _HAS_SCHEME.match(value) else f"https://{value}"
|
|
351
|
+
parts = urlsplit(with_scheme)
|
|
352
|
+
scheme = parts.scheme.lower()
|
|
353
|
+
|
|
354
|
+
if scheme not in ("http", "https"):
|
|
355
|
+
raise ValueError(f"base_url must be http or https, got {scheme!r}")
|
|
356
|
+
if "@" in parts.netloc:
|
|
357
|
+
# textbee never uses basic auth, and https://api.textbee.dev@evil.com
|
|
358
|
+
# reads like textbee while sending the key to evil.com.
|
|
359
|
+
raise ValueError("base_url must not contain a username or password")
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
port = parts.port
|
|
363
|
+
except ValueError:
|
|
364
|
+
port = -1
|
|
365
|
+
host = _ascii_host(parts.hostname)
|
|
366
|
+
if port == -1 or not host or not _VALID_HOST.fullmatch(host):
|
|
367
|
+
raise ValueError(
|
|
368
|
+
"base_url is not a valid URL. Use your textbee API, for example "
|
|
369
|
+
"https://sms.example.com."
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if ":" in host:
|
|
373
|
+
host = f"[{host}]"
|
|
374
|
+
origin = f"{scheme}://{host}"
|
|
375
|
+
if port is not None and port != _DEFAULT_PORTS[scheme]:
|
|
376
|
+
origin = f"{origin}:{port}"
|
|
377
|
+
|
|
378
|
+
segments = [segment for segment in parts.path.split("/") if segment]
|
|
379
|
+
lower = [segment.lower() for segment in segments]
|
|
380
|
+
cut = -1
|
|
381
|
+
for index in range(len(lower) - 2, -1, -1):
|
|
382
|
+
if lower[index] == "api" and lower[index + 1] == "v1":
|
|
383
|
+
cut = index + 2
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
if not segments:
|
|
387
|
+
path = ["api", "v1"]
|
|
388
|
+
elif cut == -1:
|
|
389
|
+
path = segments
|
|
390
|
+
else:
|
|
391
|
+
# Anything after /api/v1 is a route, not part of the root, so it is dropped.
|
|
392
|
+
path = [*segments[: cut - 2], "api", "v1"]
|
|
393
|
+
|
|
394
|
+
return "/".join([origin, *(quote(segment, safe=_PATH_SAFE) for segment in path)])
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _ascii_host(host: str | None) -> str | None:
|
|
398
|
+
"""Punycode an internationalized hostname, as browsers and fetch do."""
|
|
399
|
+
if not host or host.isascii():
|
|
400
|
+
return host
|
|
401
|
+
try:
|
|
402
|
+
return host.encode("idna").decode("ascii")
|
|
403
|
+
except UnicodeError:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
_HAS_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
|
408
|
+
_VALID_HOST = re.compile(r"[A-Za-z0-9._~-]+|[0-9A-Fa-f:.]+")
|
|
409
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
410
|
+
_PATH_SAFE = "!$&'()*+,;=:@~%"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _segment(value: str) -> str:
|
|
414
|
+
return quote(value, safe="")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _to_iso_string(value: DateInput | None, name: str) -> str | None:
|
|
418
|
+
if value is None or isinstance(value, str):
|
|
419
|
+
return value
|
|
420
|
+
if not isinstance(value, datetime):
|
|
421
|
+
raise TypeError(
|
|
422
|
+
f"{name} must be an ISO 8601 string or a timezone-aware datetime, "
|
|
423
|
+
f"not {type(value).__name__}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
427
|
+
raise ValueError(f"{name} must be a timezone-aware datetime")
|
|
428
|
+
|
|
429
|
+
utc = value.astimezone(timezone.utc)
|
|
430
|
+
return utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _parse_body(raw: bytes) -> Any:
|
|
434
|
+
if not raw:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
text = raw.decode("utf-8", errors="replace")
|
|
438
|
+
try:
|
|
439
|
+
return json.loads(text)
|
|
440
|
+
except ValueError:
|
|
441
|
+
return text
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _error_message(payload: Any, status: int) -> str:
|
|
445
|
+
if isinstance(payload, dict):
|
|
446
|
+
# Framework errors ({statusCode, message, error}) carry the detail in
|
|
447
|
+
# message, and error is only the reason phrase such as "Not Found".
|
|
448
|
+
keys = ("message", "error") if "statusCode" in payload else ("error", "message")
|
|
449
|
+
for key in keys:
|
|
450
|
+
value = payload.get(key)
|
|
451
|
+
if isinstance(value, str) and value:
|
|
452
|
+
return value
|
|
453
|
+
if isinstance(value, list) and value and all(isinstance(v, str) for v in value):
|
|
454
|
+
return "; ".join(value)
|
|
455
|
+
|
|
456
|
+
return f"HTTP {status}"
|
textbee/_errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TextbeeError(Exception):
|
|
7
|
+
"""Raised when the textbee API answers with an error, a redirect, or a response
|
|
8
|
+
that is not the API's JSON (usually a wrong ``base_url``).
|
|
9
|
+
|
|
10
|
+
Network failures are not wrapped: those raise whatever ``urllib`` raised,
|
|
11
|
+
an ``OSError`` such as ``URLError`` or a socket timeout.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
status: int
|
|
15
|
+
"""HTTP status code returned by the API."""
|
|
16
|
+
|
|
17
|
+
message: str
|
|
18
|
+
"""Error text from the response body, or ``HTTP <status>``."""
|
|
19
|
+
|
|
20
|
+
body: Any
|
|
21
|
+
"""Parsed response body, or the raw text when the body was not JSON."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status: int, message: str, body: Any = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status = status
|
|
26
|
+
self.message = message
|
|
27
|
+
self.body = body
|
|
28
|
+
|
|
29
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
30
|
+
# Keeps the error intact across pickling (multiprocessing, Celery).
|
|
31
|
+
return (type(self), (self.status, self.message, self.body))
|