beaconbox 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.
- beaconbox/__init__.py +125 -0
- beaconbox/_core.py +248 -0
- beaconbox/_logging.py +81 -0
- beaconbox/_ops.py +196 -0
- beaconbox/_transport.py +290 -0
- beaconbox/_version.py +3 -0
- beaconbox/async_resources.py +257 -0
- beaconbox/client.py +294 -0
- beaconbox/enums.py +186 -0
- beaconbox/errors.py +145 -0
- beaconbox/models.py +737 -0
- beaconbox/py.typed +0 -0
- beaconbox/resources.py +408 -0
- beaconbox/retry.py +98 -0
- beaconbox/webhooks.py +132 -0
- beaconbox-0.1.0.dist-info/METADATA +336 -0
- beaconbox-0.1.0.dist-info/RECORD +19 -0
- beaconbox-0.1.0.dist-info/WHEEL +4 -0
- beaconbox-0.1.0.dist-info/licenses/LICENSE +21 -0
beaconbox/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Official Python SDK for BeaconBox.
|
|
2
|
+
|
|
3
|
+
BeaconBox is a private inbox where only businesses a customer has bought from can reach them, so
|
|
4
|
+
shipping, tracking, payment and firmware updates never get lost to spam.
|
|
5
|
+
|
|
6
|
+
.. code-block:: python
|
|
7
|
+
|
|
8
|
+
from beaconbox import BeaconBox
|
|
9
|
+
|
|
10
|
+
client = BeaconBox() # reads BEACONBOX_API_KEY
|
|
11
|
+
|
|
12
|
+
result = client.messages.push(
|
|
13
|
+
recipient_email="buyer@example.com",
|
|
14
|
+
subject="Your order has shipped",
|
|
15
|
+
body="Tracking XY123456789EE. Estimated delivery Thursday.",
|
|
16
|
+
)
|
|
17
|
+
print(result.id)
|
|
18
|
+
|
|
19
|
+
Everything is also available awaited, from :class:`AsyncBeaconBox`, with identical signatures.
|
|
20
|
+
|
|
21
|
+
The two conventions this SDK exists to keep you from having to remember are on
|
|
22
|
+
:class:`BeaconBox`: it supplies the idempotency key every write requires, and it never raises on
|
|
23
|
+
a channel that was skipped.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from ._logging import install_null_handler
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .client import AsyncBeaconBox, BeaconBox
|
|
29
|
+
from .enums import (
|
|
30
|
+
Channel,
|
|
31
|
+
ChannelSendStatus,
|
|
32
|
+
MessageKind,
|
|
33
|
+
MessageStatus,
|
|
34
|
+
RecipientStatus,
|
|
35
|
+
SkipReason,
|
|
36
|
+
WebhookEventType,
|
|
37
|
+
WhatsAppReplyForward,
|
|
38
|
+
)
|
|
39
|
+
from .errors import (
|
|
40
|
+
APIConnectionError,
|
|
41
|
+
APIError,
|
|
42
|
+
AuthenticationError,
|
|
43
|
+
BeaconBoxError,
|
|
44
|
+
ConflictError,
|
|
45
|
+
InvalidRequestError,
|
|
46
|
+
PermissionDeniedError,
|
|
47
|
+
RateLimitError,
|
|
48
|
+
ResourceMissingError,
|
|
49
|
+
ServerError,
|
|
50
|
+
WebhookVerificationError,
|
|
51
|
+
)
|
|
52
|
+
from .models import (
|
|
53
|
+
ApiKey,
|
|
54
|
+
BatchItemResult,
|
|
55
|
+
BatchResult,
|
|
56
|
+
CreditBalance,
|
|
57
|
+
DeliveryStatus,
|
|
58
|
+
Message,
|
|
59
|
+
MessagePage,
|
|
60
|
+
MessagePush,
|
|
61
|
+
MessagePushResult,
|
|
62
|
+
NewApiKey,
|
|
63
|
+
NewWebhookEndpoint,
|
|
64
|
+
RecipientSms,
|
|
65
|
+
RetractResult,
|
|
66
|
+
SmsDelivery,
|
|
67
|
+
SmsOutcome,
|
|
68
|
+
WebhookEndpoint,
|
|
69
|
+
WebhookEvent,
|
|
70
|
+
WhatsAppErasureReceipt,
|
|
71
|
+
WhatsAppOptInOutcome,
|
|
72
|
+
WhatsAppOutcome,
|
|
73
|
+
)
|
|
74
|
+
from .retry import RetryPolicy
|
|
75
|
+
|
|
76
|
+
# A library must be silent until the application asks otherwise. See beaconbox._logging: this
|
|
77
|
+
# attaches a NullHandler to the `beaconbox` logger and configures nothing else, so importing this
|
|
78
|
+
# SDK never prints anything and never touches the root logger.
|
|
79
|
+
install_null_handler()
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"APIConnectionError",
|
|
83
|
+
"APIError",
|
|
84
|
+
"ApiKey",
|
|
85
|
+
"AsyncBeaconBox",
|
|
86
|
+
"AuthenticationError",
|
|
87
|
+
"BatchItemResult",
|
|
88
|
+
"BatchResult",
|
|
89
|
+
"BeaconBox",
|
|
90
|
+
"BeaconBoxError",
|
|
91
|
+
"Channel",
|
|
92
|
+
"ChannelSendStatus",
|
|
93
|
+
"ConflictError",
|
|
94
|
+
"CreditBalance",
|
|
95
|
+
"DeliveryStatus",
|
|
96
|
+
"InvalidRequestError",
|
|
97
|
+
"Message",
|
|
98
|
+
"MessageKind",
|
|
99
|
+
"MessagePage",
|
|
100
|
+
"MessagePush",
|
|
101
|
+
"MessagePushResult",
|
|
102
|
+
"MessageStatus",
|
|
103
|
+
"NewApiKey",
|
|
104
|
+
"NewWebhookEndpoint",
|
|
105
|
+
"PermissionDeniedError",
|
|
106
|
+
"RateLimitError",
|
|
107
|
+
"RecipientSms",
|
|
108
|
+
"RecipientStatus",
|
|
109
|
+
"ResourceMissingError",
|
|
110
|
+
"RetractResult",
|
|
111
|
+
"RetryPolicy",
|
|
112
|
+
"ServerError",
|
|
113
|
+
"SkipReason",
|
|
114
|
+
"SmsDelivery",
|
|
115
|
+
"SmsOutcome",
|
|
116
|
+
"WebhookEndpoint",
|
|
117
|
+
"WebhookEvent",
|
|
118
|
+
"WebhookEventType",
|
|
119
|
+
"WebhookVerificationError",
|
|
120
|
+
"WhatsAppErasureReceipt",
|
|
121
|
+
"WhatsAppOptInOutcome",
|
|
122
|
+
"WhatsAppOutcome",
|
|
123
|
+
"WhatsAppReplyForward",
|
|
124
|
+
"__version__",
|
|
125
|
+
]
|
beaconbox/_core.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The part of this SDK that has no idea HTTP exists.
|
|
2
|
+
|
|
3
|
+
Everything here is a pure function of its inputs: turn a :class:`Call` into the bytes and headers
|
|
4
|
+
that should go on the wire, and turn a status code plus a body back into a model or an exception.
|
|
5
|
+
No sockets, no sleeping, no httpx import anywhere in this module.
|
|
6
|
+
|
|
7
|
+
That line is what lets the sync client and the async client be the same client. Authentication,
|
|
8
|
+
the idempotency key, URL construction, JSON encoding, error mapping and response parsing are
|
|
9
|
+
written once here; :mod:`beaconbox._transport` adds only the two things that genuinely differ
|
|
10
|
+
between them, which are how you send a request and how you wait.
|
|
11
|
+
|
|
12
|
+
It is also what makes the SDK testable without a network: a test can build a ``Call``, prepare it
|
|
13
|
+
and assert on the exact headers, or hand :func:`Core.interpret` a canned 429 and assert on the
|
|
14
|
+
exception, with no server and no mocking library involved.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Generic, TypeVar
|
|
24
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .errors import APIError, error_class_for
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
API_PREFIX = "/api/v1"
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.beaconbox.eu"
|
|
33
|
+
|
|
34
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def new_idempotency_key() -> str:
|
|
38
|
+
"""A fresh v4 UUID for the ``Idempotency-Key`` header.
|
|
39
|
+
|
|
40
|
+
Minted once per logical call and reused for every retry of it. See
|
|
41
|
+
:meth:`Core.prepare`, which is where that pairing is enforced.
|
|
42
|
+
"""
|
|
43
|
+
return str(uuid.uuid4())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Call(Generic[T]):
|
|
48
|
+
"""One API operation, described without reference to how it will be sent.
|
|
49
|
+
|
|
50
|
+
``parse`` is carried alongside the request rather than looked up afterwards, so a resource
|
|
51
|
+
method's return type is fixed at the point the call is described and a transport cannot get
|
|
52
|
+
it wrong.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
method: str
|
|
56
|
+
path: str
|
|
57
|
+
parse: Callable[[Any], T]
|
|
58
|
+
body: dict[str, Any] | None = None
|
|
59
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
route: str | None = None
|
|
62
|
+
"""A templated, **log-safe** name for this operation, for example
|
|
63
|
+
``/recipients/{email}/sms``.
|
|
64
|
+
|
|
65
|
+
Set it on every call whose path interpolates a value. ``path`` cannot be logged: a recipient's
|
|
66
|
+
email address is substituted straight into it, so logging the path would write personal data
|
|
67
|
+
into a merchant's log aggregator on every request. Query strings are never logged for the same
|
|
68
|
+
reason (``?recipient_email=``), and neither are bodies or headers.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def log_name(self) -> str:
|
|
73
|
+
"""What :mod:`beaconbox._transport` logs. Never the interpolated path."""
|
|
74
|
+
return f"{self.method} {self.route or self.path}"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def needs_idempotency(self) -> bool:
|
|
78
|
+
"""Every ``POST`` in this API requires an ``Idempotency-Key``, and nothing else does.
|
|
79
|
+
|
|
80
|
+
``PUT`` and ``DELETE`` on a recipient's phone number are idempotent by construction:
|
|
81
|
+
setting a number twice leaves one number. Sending a message twice sends two messages and
|
|
82
|
+
charges two credits, which is the whole reason the header is mandatory on ``POST``.
|
|
83
|
+
"""
|
|
84
|
+
return self.method == "POST"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class PreparedRequest:
|
|
89
|
+
"""Exactly what should go on the wire. Built once, sent as many times as it takes."""
|
|
90
|
+
|
|
91
|
+
method: str
|
|
92
|
+
url: str
|
|
93
|
+
headers: dict[str, str]
|
|
94
|
+
content: bytes | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Core:
|
|
98
|
+
"""Request building and response interpretation, shared by both clients."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
api_key: str,
|
|
103
|
+
*,
|
|
104
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
105
|
+
user_agent_suffix: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
if not api_key or not api_key.strip():
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"BeaconBox: an API key is required. Pass api_key= or set BEACONBOX_API_KEY."
|
|
110
|
+
)
|
|
111
|
+
self._api_key = api_key.strip()
|
|
112
|
+
self.base_url = _validated_base_url(base_url)
|
|
113
|
+
suffix = f" {user_agent_suffix}" if user_agent_suffix else ""
|
|
114
|
+
self.user_agent = f"beaconbox-python/{__version__}{suffix}"
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
"""No key, ever. This object ends up inside client reprs, tracebacks and log lines."""
|
|
118
|
+
return f"Core(base_url={self.base_url!r})"
|
|
119
|
+
|
|
120
|
+
def prepare(self, call: Call[Any], idempotency_key: str | None = None) -> PreparedRequest:
|
|
121
|
+
"""Build the request for ``call``.
|
|
122
|
+
|
|
123
|
+
**Call this once per logical operation, outside any retry loop.** The idempotency key is
|
|
124
|
+
minted here, so preparing again per attempt would mint a second key, and a second key is
|
|
125
|
+
what turns a retry into a duplicate message and a duplicate charge.
|
|
126
|
+
"""
|
|
127
|
+
url = self.base_url + API_PREFIX + call.path
|
|
128
|
+
query = {k: _query_value(v) for k, v in call.params.items() if v is not None}
|
|
129
|
+
if query:
|
|
130
|
+
url += "?" + urlencode(query)
|
|
131
|
+
|
|
132
|
+
headers = {
|
|
133
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
134
|
+
"Accept": "application/json",
|
|
135
|
+
"User-Agent": self.user_agent,
|
|
136
|
+
}
|
|
137
|
+
content: bytes | None = None
|
|
138
|
+
if call.body is not None:
|
|
139
|
+
headers["Content-Type"] = "application/json"
|
|
140
|
+
content = json.dumps(call.body, separators=(",", ":")).encode()
|
|
141
|
+
if call.needs_idempotency:
|
|
142
|
+
headers["Idempotency-Key"] = idempotency_key or new_idempotency_key()
|
|
143
|
+
|
|
144
|
+
return PreparedRequest(method=call.method, url=url, headers=headers, content=content)
|
|
145
|
+
|
|
146
|
+
def interpret(
|
|
147
|
+
self,
|
|
148
|
+
call: Call[T],
|
|
149
|
+
status_code: int,
|
|
150
|
+
headers: Mapping[str, str],
|
|
151
|
+
content: bytes,
|
|
152
|
+
) -> T:
|
|
153
|
+
"""Turn a response into a model, or raise the matching :class:`~beaconbox.errors.APIError`.
|
|
154
|
+
|
|
155
|
+
Only called for a response the transport has decided not to retry, so anything reaching
|
|
156
|
+
here with a 4xx or 5xx is final.
|
|
157
|
+
"""
|
|
158
|
+
if status_code >= 400:
|
|
159
|
+
raise self._to_error(status_code, headers, content)
|
|
160
|
+
return call.parse(_decode(content))
|
|
161
|
+
|
|
162
|
+
def _to_error(self, status_code: int, headers: Mapping[str, str], content: bytes) -> APIError:
|
|
163
|
+
body = _decode(content)
|
|
164
|
+
body = body if isinstance(body, dict) else {"raw": body}
|
|
165
|
+
error_code = body.get("error_code") if isinstance(body.get("error_code"), str) else None
|
|
166
|
+
detail = body.get("detail")
|
|
167
|
+
summary = detail if isinstance(detail, str) else error_code or "request failed"
|
|
168
|
+
request_id = _header(headers, "x-request-id")
|
|
169
|
+
return error_class_for(status_code)(
|
|
170
|
+
f"BeaconBox: {summary} (HTTP {status_code})",
|
|
171
|
+
status_code=status_code,
|
|
172
|
+
error_code=error_code,
|
|
173
|
+
body=body,
|
|
174
|
+
request_id=request_id,
|
|
175
|
+
retry_after=retry_after_seconds(headers),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def retry_after_seconds(headers: Mapping[str, str]) -> float | None:
|
|
180
|
+
"""``Retry-After`` in seconds, when the server sent a sane one.
|
|
181
|
+
|
|
182
|
+
Only the delta-seconds form is honoured. The HTTP-date form is legal and essentially never
|
|
183
|
+
used by an API, and parsing it would mean trusting the caller's clock to agree with the
|
|
184
|
+
server's, which is the assumption that makes it worse than the SDK's own backoff.
|
|
185
|
+
"""
|
|
186
|
+
value = _header(headers, "retry-after")
|
|
187
|
+
if value is None:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
seconds = float(value.strip())
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
return seconds if seconds >= 0 else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
197
|
+
"""Case-insensitive lookup, because a raw mapping in a test is not httpx's ``Headers``."""
|
|
198
|
+
for key, value in headers.items():
|
|
199
|
+
if key.lower() == name:
|
|
200
|
+
return value
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _decode(content: bytes) -> Any:
|
|
205
|
+
"""Body to Python. A 204 has none, and a non-JSON body is preserved rather than swallowed."""
|
|
206
|
+
if not content or not content.strip():
|
|
207
|
+
return {}
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(content)
|
|
210
|
+
except ValueError:
|
|
211
|
+
return {"raw": content.decode("utf-8", "replace")}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _query_value(value: Any) -> str:
|
|
215
|
+
if isinstance(value, bool):
|
|
216
|
+
return "true" if value else "false"
|
|
217
|
+
return str(value)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validated_base_url(base_url: str) -> str:
|
|
221
|
+
"""Reject a base URL that would put an API key on the wire in clear.
|
|
222
|
+
|
|
223
|
+
``http`` is allowed only for a loopback host, which is what the local development stack and
|
|
224
|
+
the SDK's own live tests run against. Anywhere else it means the bearer token, the recipient's
|
|
225
|
+
email address and the body of the message are readable by anything on the path, and an SDK
|
|
226
|
+
that shrugs at that is the reason it happens in production.
|
|
227
|
+
"""
|
|
228
|
+
cleaned = base_url.strip().rstrip("/")
|
|
229
|
+
parts = urlsplit(cleaned)
|
|
230
|
+
if parts.scheme not in {"http", "https"}:
|
|
231
|
+
raise ValueError(f"BeaconBox: base_url must be http or https, got {base_url!r}")
|
|
232
|
+
if not parts.netloc:
|
|
233
|
+
raise ValueError(f"BeaconBox: base_url must include a host, got {base_url!r}")
|
|
234
|
+
if parts.scheme == "http" and (parts.hostname or "") not in _LOCAL_HOSTS:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"BeaconBox: refusing to send an API key over plain http to {parts.hostname!r}. "
|
|
237
|
+
"Use https (http is permitted for localhost only)."
|
|
238
|
+
)
|
|
239
|
+
return cleaned
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def path_segment(value: str) -> str:
|
|
243
|
+
"""Percent-encode one path segment.
|
|
244
|
+
|
|
245
|
+
``safe=""`` so that an email address's ``@``, and above all a ``/``, cannot walk out of the
|
|
246
|
+
segment they were substituted into and address a different endpoint.
|
|
247
|
+
"""
|
|
248
|
+
return quote(value, safe="")
|
beaconbox/_logging.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""How this library logs, and the rules it will not break.
|
|
2
|
+
|
|
3
|
+
A library's logging is not an application's logging. The rules below are the standard ones, and
|
|
4
|
+
each is here because breaking it steals a decision from the person integrating this SDK:
|
|
5
|
+
|
|
6
|
+
* **One logger per module, named after the module.** Everything lands under the ``beaconbox``
|
|
7
|
+
hierarchy, so an application silences or raises the whole SDK with one line:
|
|
8
|
+
|
|
9
|
+
.. code-block:: python
|
|
10
|
+
|
|
11
|
+
logging.getLogger("beaconbox").setLevel(logging.DEBUG)
|
|
12
|
+
|
|
13
|
+
* **A** :class:`~logging.NullHandler` **on the root of that hierarchy, and nothing else.** Added in
|
|
14
|
+
:mod:`beaconbox.__init__`. It means importing this SDK produces no output, ever, until the
|
|
15
|
+
application asks for some.
|
|
16
|
+
* **Never configure logging.** No ``basicConfig``, no handlers, no formatters, no level set on the
|
|
17
|
+
root logger. A library that calls ``basicConfig()`` at import silently hijacks the logging of
|
|
18
|
+
every application that imports it.
|
|
19
|
+
* **Never log anything sensitive.** See :func:`safe_extra`. This is the rule with teeth: BeaconBox
|
|
20
|
+
requests carry an API key, a recipient's email address and the text of a message to a real
|
|
21
|
+
person. A log line is copied to an aggregator, retained for months and read by people who were
|
|
22
|
+
never meant to see any of that.
|
|
23
|
+
|
|
24
|
+
What actually gets logged, all of it on the ``beaconbox._transport`` logger:
|
|
25
|
+
|
|
26
|
+
=========== ===========================================================================
|
|
27
|
+
``DEBUG`` every request, and every response, with its status and elapsed time
|
|
28
|
+
``WARNING`` a retry, with the reason and the backoff
|
|
29
|
+
=========== ===========================================================================
|
|
30
|
+
|
|
31
|
+
Nothing is logged at ``INFO`` or above in normal operation. An SDK that chatters at ``INFO`` is an
|
|
32
|
+
SDK whose users filter it out, which is how the ``WARNING`` that mattered gets filtered out too.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
LOGGER_NAME = "beaconbox"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_logger(name: str) -> logging.Logger:
|
|
44
|
+
"""The logger for a module inside this package. Always use this rather than a bare
|
|
45
|
+
``getLogger``, so nothing accidentally logs outside the ``beaconbox`` hierarchy."""
|
|
46
|
+
return logging.getLogger(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_null_handler() -> None:
|
|
50
|
+
"""Attach a :class:`~logging.NullHandler` to ``beaconbox``, once.
|
|
51
|
+
|
|
52
|
+
Without it, a warning emitted before the application configures logging would either print
|
|
53
|
+
``No handlers could be found`` or fall through to the root logger's ``lastResort`` handler and
|
|
54
|
+
appear on stderr, uninvited.
|
|
55
|
+
"""
|
|
56
|
+
root = logging.getLogger(LOGGER_NAME)
|
|
57
|
+
if not any(isinstance(handler, logging.NullHandler) for handler in root.handlers):
|
|
58
|
+
root.addHandler(logging.NullHandler())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def safe_extra(**fields: Any) -> dict[str, Any]:
|
|
62
|
+
"""The allow-list for structured log fields.
|
|
63
|
+
|
|
64
|
+
Deliberately an allow-list rather than a redaction pass. Redaction is a list of things somebody
|
|
65
|
+
remembered to hide, and the field that gets added next year is not on it. This way a value has
|
|
66
|
+
to be named here to ever reach a log line.
|
|
67
|
+
|
|
68
|
+
**What is never logged, and why:**
|
|
69
|
+
|
|
70
|
+
* the API key, or any header (``Authorization`` is a header);
|
|
71
|
+
* the request or response body (a message body is text written to a named customer);
|
|
72
|
+
* the query string (``?recipient_email=buyer@example.com``);
|
|
73
|
+
* the interpolated URL path (``/recipients/buyer@example.com/sms``). The templated
|
|
74
|
+
:attr:`~beaconbox._core.Call.route` is logged instead;
|
|
75
|
+
* webhook secrets, and the payload of a webhook.
|
|
76
|
+
|
|
77
|
+
The idempotency key is logged, because it is a random value of the caller's choosing that
|
|
78
|
+
identifies one attempt, and correlating retries without it is guesswork. If a caller passes
|
|
79
|
+
something identifying as their own key, that is their value in their logs.
|
|
80
|
+
"""
|
|
81
|
+
return {"beaconbox": fields}
|
beaconbox/_ops.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Every API operation, described once.
|
|
2
|
+
|
|
3
|
+
A resource method on the sync client and its twin on the async client build the *same*
|
|
4
|
+
:class:`~beaconbox._core.Call` from this module, so the URL, the body shape and the response type
|
|
5
|
+
of an operation have exactly one definition. What differs between the two clients is the keyword
|
|
6
|
+
``await``, and nothing else.
|
|
7
|
+
|
|
8
|
+
The prose that teaches an operation lives on the resource methods, where an editor will show it.
|
|
9
|
+
What lives here is the wire contract.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ._core import Call, path_segment
|
|
18
|
+
from .models import (
|
|
19
|
+
ApiKey,
|
|
20
|
+
BatchResult,
|
|
21
|
+
CreditBalance,
|
|
22
|
+
Message,
|
|
23
|
+
MessagePage,
|
|
24
|
+
MessagePushResult,
|
|
25
|
+
NewApiKey,
|
|
26
|
+
NewWebhookEndpoint,
|
|
27
|
+
RecipientSms,
|
|
28
|
+
RetractResult,
|
|
29
|
+
SmsOutcome,
|
|
30
|
+
WebhookEndpoint,
|
|
31
|
+
WhatsAppErasureReceipt,
|
|
32
|
+
WhatsAppOutcome,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
Payload = dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _nothing(_: Any) -> None:
|
|
39
|
+
"""For a 204. The call succeeded and the body is empty by design."""
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _keys(body: Any) -> tuple[ApiKey, ...]:
|
|
44
|
+
items = body.get("items") if isinstance(body, dict) else None
|
|
45
|
+
return tuple(ApiKey.from_api(i) for i in (items or []) if isinstance(i, dict))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _endpoints(body: Any) -> tuple[WebhookEndpoint, ...]:
|
|
49
|
+
items = body.get("items") if isinstance(body, dict) else None
|
|
50
|
+
return tuple(WebhookEndpoint.from_api(i) for i in (items or []) if isinstance(i, dict))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- Messages ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def push(body: Payload) -> Call[MessagePushResult]:
|
|
57
|
+
return Call("POST", "/messages", MessagePushResult.from_api, body=body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def push_batch(bodies: Sequence[Payload]) -> Call[BatchResult]:
|
|
61
|
+
# `items`, which is what the API's MessageBatchPush declares. The name matters: a batch sent
|
|
62
|
+
# under any other key is a 422 that only shows up against a real server.
|
|
63
|
+
return Call("POST", "/messages/batch", BatchResult.from_api, body={"items": list(bodies)})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_message(public_id: str) -> Call[Message]:
|
|
67
|
+
return Call(
|
|
68
|
+
"GET", f"/messages/{path_segment(public_id)}", Message.from_api, route="/messages/{id}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_messages(
|
|
73
|
+
recipient_email: str | None, limit: int | None, cursor: str | None
|
|
74
|
+
) -> Call[MessagePage]:
|
|
75
|
+
return Call(
|
|
76
|
+
"GET",
|
|
77
|
+
"/messages",
|
|
78
|
+
MessagePage.from_api,
|
|
79
|
+
params={"recipient_email": recipient_email, "limit": limit, "cursor": cursor},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resend_sms(public_id: str) -> Call[SmsOutcome]:
|
|
84
|
+
return Call(
|
|
85
|
+
"POST",
|
|
86
|
+
f"/messages/{path_segment(public_id)}/sms",
|
|
87
|
+
SmsOutcome.from_api,
|
|
88
|
+
route="/messages/{id}/sms",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resend_whatsapp(public_id: str) -> Call[WhatsAppOutcome]:
|
|
93
|
+
return Call(
|
|
94
|
+
"POST",
|
|
95
|
+
f"/messages/{path_segment(public_id)}/whatsapp",
|
|
96
|
+
WhatsAppOutcome.from_api,
|
|
97
|
+
route="/messages/{id}/whatsapp",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def retract(public_id: str) -> Call[RetractResult]:
|
|
102
|
+
return Call(
|
|
103
|
+
"POST",
|
|
104
|
+
f"/messages/{path_segment(public_id)}/retract",
|
|
105
|
+
RetractResult.from_api,
|
|
106
|
+
route="/messages/{id}/retract",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- Credits ----------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def credit_balance() -> Call[CreditBalance]:
|
|
114
|
+
return Call("GET", "/credits", CreditBalance.from_api)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- Recipients -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def recipient_sms(email: str) -> Call[RecipientSms]:
|
|
121
|
+
return Call(
|
|
122
|
+
"GET",
|
|
123
|
+
f"/recipients/{path_segment(email)}/sms",
|
|
124
|
+
RecipientSms.from_api,
|
|
125
|
+
route="/recipients/{email}/sms",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def set_recipient_phone(email: str, phone: str) -> Call[RecipientSms]:
|
|
130
|
+
return Call(
|
|
131
|
+
"PUT",
|
|
132
|
+
f"/recipients/{path_segment(email)}/phone",
|
|
133
|
+
RecipientSms.from_api,
|
|
134
|
+
body={"phone": phone},
|
|
135
|
+
route="/recipients/{email}/phone",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def clear_recipient_phone(email: str) -> Call[RecipientSms]:
|
|
140
|
+
return Call(
|
|
141
|
+
"DELETE",
|
|
142
|
+
f"/recipients/{path_segment(email)}/phone",
|
|
143
|
+
RecipientSms.from_api,
|
|
144
|
+
route="/recipients/{email}/phone",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def erase_whatsapp(email: str) -> Call[WhatsAppErasureReceipt]:
|
|
149
|
+
return Call(
|
|
150
|
+
"POST",
|
|
151
|
+
f"/recipients/{path_segment(email)}/whatsapp/erase",
|
|
152
|
+
WhatsAppErasureReceipt.from_api,
|
|
153
|
+
route="/recipients/{email}/whatsapp/erase",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- Keys -------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def list_keys() -> Call[tuple[ApiKey, ...]]:
|
|
161
|
+
return Call("GET", "/keys", _keys)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_key(name: str | None) -> Call[NewApiKey]:
|
|
165
|
+
return Call("POST", "/keys", NewApiKey.from_api, body={"name": name})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def revoke_key(key_id: str) -> Call[None]:
|
|
169
|
+
return Call("DELETE", f"/keys/{path_segment(key_id)}", _nothing, route="/keys/{id}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- Webhook endpoints ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_webhook_endpoints() -> Call[tuple[WebhookEndpoint, ...]]:
|
|
176
|
+
return Call("GET", "/webhook-endpoints", _endpoints)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def create_webhook_endpoint(
|
|
180
|
+
url: str, event_types: Sequence[str], description: str
|
|
181
|
+
) -> Call[NewWebhookEndpoint]:
|
|
182
|
+
return Call(
|
|
183
|
+
"POST",
|
|
184
|
+
"/webhook-endpoints",
|
|
185
|
+
NewWebhookEndpoint.from_api,
|
|
186
|
+
body={"url": url, "event_types": list(event_types), "description": description},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def delete_webhook_endpoint(public_id: str) -> Call[None]:
|
|
191
|
+
return Call(
|
|
192
|
+
"DELETE",
|
|
193
|
+
f"/webhook-endpoints/{path_segment(public_id)}",
|
|
194
|
+
_nothing,
|
|
195
|
+
route="/webhook-endpoints/{id}",
|
|
196
|
+
)
|