honkio 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.
honkio/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Official Python SDK for HonkIO: Canadian SMS and email, CASL built in."""
2
+
3
+ from ._client import AsyncHonkio, Honkio
4
+ from ._core import NOT_GIVEN, NotGiven
5
+ from ._endpoints import ENDPOINTS, Endpoint
6
+ from ._errors import (
7
+ APIConnectionError,
8
+ ApiError,
9
+ APITimeoutError,
10
+ AuthenticationError,
11
+ BadRequestError,
12
+ ConflictError,
13
+ HonkioError,
14
+ InternalServerError,
15
+ InvalidArgumentError,
16
+ InvalidSignatureError,
17
+ NotFoundError,
18
+ PaymentRequiredError,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ UnavailableForLegalReasonsError,
22
+ UnprocessableEntityError,
23
+ )
24
+ from ._version import __version__
25
+ from ._webhooks import WEBHOOK_EVENTS, verify_webhook
26
+
27
+ __all__ = [
28
+ "ENDPOINTS",
29
+ "NOT_GIVEN",
30
+ "WEBHOOK_EVENTS",
31
+ "APIConnectionError",
32
+ "APITimeoutError",
33
+ "ApiError",
34
+ "AsyncHonkio",
35
+ "AuthenticationError",
36
+ "BadRequestError",
37
+ "ConflictError",
38
+ "Endpoint",
39
+ "Honkio",
40
+ "HonkioError",
41
+ "InternalServerError",
42
+ "InvalidArgumentError",
43
+ "InvalidSignatureError",
44
+ "NotGiven",
45
+ "NotFoundError",
46
+ "PaymentRequiredError",
47
+ "PermissionDeniedError",
48
+ "RateLimitError",
49
+ "UnavailableForLegalReasonsError",
50
+ "UnprocessableEntityError",
51
+ "__version__",
52
+ "verify_webhook",
53
+ ]
honkio/_client.py ADDED
@@ -0,0 +1,139 @@
1
+ """The sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ import httpx
8
+
9
+ from ._core import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
10
+ from ._transport import AsyncTransport, SyncTransport
11
+ from .resources import (
12
+ AsyncAutoTopupResource,
13
+ AsyncBatch,
14
+ AsyncBusinessVerificationResource,
15
+ AsyncConsentBatches,
16
+ AsyncConsents,
17
+ AsyncDomains,
18
+ AsyncEmails,
19
+ AsyncMessages,
20
+ AsyncOptInConfirmations,
21
+ AsyncPhoneNumbers,
22
+ AsyncSuppressions,
23
+ AsyncTemplates,
24
+ AsyncTollFreeVerifications,
25
+ AsyncTopupCapRequests,
26
+ AsyncVerify,
27
+ AsyncWebhooks,
28
+ AutoTopupResource,
29
+ Batch,
30
+ BusinessVerificationResource,
31
+ ConsentBatches,
32
+ Consents,
33
+ Domains,
34
+ Emails,
35
+ Messages,
36
+ OptInConfirmations,
37
+ PhoneNumbers,
38
+ Suppressions,
39
+ Templates,
40
+ TollFreeVerifications,
41
+ TopupCapRequests,
42
+ Verify,
43
+ Webhooks,
44
+ )
45
+
46
+
47
+ class Honkio:
48
+ """The HonkIO client.
49
+
50
+ The key comes from `api_key` or the HONKIO_API_KEY environment variable. A client built
51
+ without `http_client` owns its httpx client and closes it in `close()` (or on leaving a
52
+ `with` block); one you pass in stays yours to close.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: str | None = None,
58
+ *,
59
+ base_url: str = DEFAULT_BASE_URL,
60
+ timeout: float = DEFAULT_TIMEOUT,
61
+ http_client: httpx.Client | None = None,
62
+ ) -> None:
63
+ self._transport = SyncTransport(api_key, base_url, timeout, http_client)
64
+ send = self._transport.send
65
+ self.messages = Messages(send)
66
+ self.phone_numbers = PhoneNumbers(send)
67
+ self.verify = Verify(send)
68
+ self.consents = Consents(send)
69
+ self.consent_batches = ConsentBatches(send)
70
+ self.opt_in_confirmations = OptInConfirmations(send)
71
+ self.webhooks = Webhooks(send)
72
+ self.emails = Emails(send)
73
+ self.batch = Batch(send)
74
+ self.domains = Domains(send)
75
+ self.suppressions = Suppressions(send)
76
+ self.templates = Templates(send)
77
+ self.toll_free_verifications = TollFreeVerifications(send)
78
+ self.business_verification = BusinessVerificationResource(send)
79
+ self.auto_topup = AutoTopupResource(send)
80
+ self.topup_cap_requests = TopupCapRequests(send)
81
+
82
+ def close(self) -> None:
83
+ self._transport.close()
84
+
85
+ def __enter__(self) -> Honkio:
86
+ return self
87
+
88
+ def __exit__(
89
+ self,
90
+ exc_type: type[BaseException] | None,
91
+ exc: BaseException | None,
92
+ traceback: TracebackType | None,
93
+ ) -> None:
94
+ self.close()
95
+
96
+
97
+ class AsyncHonkio:
98
+ """The asynchronous HonkIO client. Same resources and arguments as `Honkio`, every call awaited."""
99
+
100
+ def __init__(
101
+ self,
102
+ api_key: str | None = None,
103
+ *,
104
+ base_url: str = DEFAULT_BASE_URL,
105
+ timeout: float = DEFAULT_TIMEOUT,
106
+ http_client: httpx.AsyncClient | None = None,
107
+ ) -> None:
108
+ self._transport = AsyncTransport(api_key, base_url, timeout, http_client)
109
+ send = self._transport.send
110
+ self.messages = AsyncMessages(send)
111
+ self.phone_numbers = AsyncPhoneNumbers(send)
112
+ self.verify = AsyncVerify(send)
113
+ self.consents = AsyncConsents(send)
114
+ self.consent_batches = AsyncConsentBatches(send)
115
+ self.opt_in_confirmations = AsyncOptInConfirmations(send)
116
+ self.webhooks = AsyncWebhooks(send)
117
+ self.emails = AsyncEmails(send)
118
+ self.batch = AsyncBatch(send)
119
+ self.domains = AsyncDomains(send)
120
+ self.suppressions = AsyncSuppressions(send)
121
+ self.templates = AsyncTemplates(send)
122
+ self.toll_free_verifications = AsyncTollFreeVerifications(send)
123
+ self.business_verification = AsyncBusinessVerificationResource(send)
124
+ self.auto_topup = AsyncAutoTopupResource(send)
125
+ self.topup_cap_requests = AsyncTopupCapRequests(send)
126
+
127
+ async def aclose(self) -> None:
128
+ await self._transport.aclose()
129
+
130
+ async def __aenter__(self) -> AsyncHonkio:
131
+ return self
132
+
133
+ async def __aexit__(
134
+ self,
135
+ exc_type: type[BaseException] | None,
136
+ exc: BaseException | None,
137
+ traceback: TracebackType | None,
138
+ ) -> None:
139
+ await self.aclose()
honkio/_core.py ADDED
@@ -0,0 +1,280 @@
1
+ """Request description, path filling and response parsing, shared by the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, datetime, timezone
10
+ from typing import Any, NoReturn
11
+ from urllib.parse import quote
12
+
13
+ from ._errors import ApiError, InvalidArgumentError, error_for_status
14
+ from ._version import __version__
15
+
16
+ DEFAULT_BASE_URL = "https://api.honkio.ca"
17
+ DEFAULT_TIMEOUT = 30.0
18
+ USER_AGENT = f"honkio-python/{__version__}"
19
+
20
+ # The error envelope every API error carries. Anything else beside it is route-specific data.
21
+ ENVELOPE = {"code", "message", "messageEn", "messageFr", "statusCode", "details"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Request:
26
+ """One API call, described once and executed by either transport."""
27
+
28
+ method: str
29
+ path: str # a template such as "/v1/email-domains/{id}"
30
+ path_params: Mapping[str, str] = field(default_factory=dict)
31
+ query: list[tuple[str, str]] = field(default_factory=list)
32
+ body: dict[str, Any] | list[Any] | None = None
33
+ idempotency_key: str | None = None
34
+ binary: bool = False
35
+
36
+
37
+ def fill_path(template: str, params: Mapping[str, str]) -> str:
38
+ """Fill `{name}` placeholders, one encoded segment each.
39
+
40
+ An empty, "." or ".." value is refused: an HTTP client would collapse
41
+ `/v1/email-domains/..` into `/v1/`, so the request would reach a different resource.
42
+ """
43
+
44
+ def fill(match: re.Match[str]) -> str:
45
+ name = match.group(1)
46
+ value = params.get(name)
47
+ if not isinstance(value, str) or value in ("", ".", ".."):
48
+ raise InvalidArgumentError(
49
+ f'The identifier "{name}" for {template} must be a non-empty string other than "." or ".."; '
50
+ "nothing was sent."
51
+ )
52
+ return quote(value, safe="")
53
+
54
+ return re.sub(r"\{(\w+)\}", fill, template)
55
+
56
+
57
+ def api_name(key: str) -> str:
58
+ """`from_` is a Python keyword workaround; the API's field is `from`."""
59
+ return "from" if key == "from_" else key
60
+
61
+
62
+ class NotGiven:
63
+ """The type of NOT_GIVEN: an optional argument the caller left out (so it is not sent).
64
+
65
+ `None` is a value, sent as JSON null in a body; this is how an argument is told apart from
66
+ "set to null". Falsy, so `if not value` treats it as absent.
67
+ """
68
+
69
+ _instance: NotGiven | None = None
70
+
71
+ def __new__(cls) -> NotGiven:
72
+ if cls._instance is None:
73
+ cls._instance = super().__new__(cls)
74
+ return cls._instance
75
+
76
+ def __bool__(self) -> bool:
77
+ return False
78
+
79
+ def __repr__(self) -> str:
80
+ return "NOT_GIVEN"
81
+
82
+
83
+ NOT_GIVEN = NotGiven()
84
+
85
+
86
+ # Fields the API declares as `format: date` (YYYY-MM-DD); every other datetime is a date-time.
87
+ DATE_FIELDS = frozenset({"date_from", "date_to", "last_transaction_date"})
88
+
89
+
90
+ def format_datetime(value: datetime) -> str:
91
+ """An aware datetime in UTC with a `Z` suffix, as the API's date-time fields require.
92
+
93
+ A naive datetime is refused: guessing a time zone would schedule a send at the wrong hour.
94
+ """
95
+ if value.tzinfo is None or value.utcoffset() is None:
96
+ raise InvalidArgumentError(
97
+ "A datetime must be timezone-aware (for example datetime.now(timezone.utc)); nothing was sent."
98
+ )
99
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
100
+
101
+
102
+ def format_date(value: date) -> str:
103
+ """YYYY-MM-DD. A datetime is converted to its UTC date; a naive one is refused."""
104
+ if isinstance(value, datetime):
105
+ if value.tzinfo is None or value.utcoffset() is None:
106
+ raise InvalidArgumentError(
107
+ "A datetime passed for a date field must be timezone-aware; pass a date or a "
108
+ "YYYY-MM-DD string instead. Nothing was sent."
109
+ )
110
+ return value.astimezone(timezone.utc).date().isoformat()
111
+ return value.isoformat()
112
+
113
+
114
+ def _convert(key: str, value: Any) -> Any:
115
+ if key in DATE_FIELDS and isinstance(value, date):
116
+ return format_date(value)
117
+ if isinstance(value, datetime):
118
+ return format_datetime(value)
119
+ if isinstance(value, date):
120
+ return value.isoformat()
121
+ return value
122
+
123
+
124
+ def build_body(fields: Mapping[str, Any]) -> dict[str, Any]:
125
+ """Omits NOT_GIVEN; an explicit None is kept and sent as null."""
126
+ return {
127
+ api_name(key): _convert(key, value)
128
+ for key, value in fields.items()
129
+ if not isinstance(value, NotGiven)
130
+ }
131
+
132
+
133
+ def _format_query_value(key: str, value: Any) -> str:
134
+ if isinstance(value, bool):
135
+ return "true" if value else "false"
136
+ value = _convert(key, value)
137
+ return value if isinstance(value, str) else str(value)
138
+
139
+
140
+ def build_query(fields: Mapping[str, Any]) -> list[tuple[str, str]]:
141
+ pairs: list[tuple[str, str]] = []
142
+ for key, value in fields.items():
143
+ if value is None or isinstance(value, NotGiven):
144
+ continue
145
+ name = api_name(key)
146
+ if isinstance(value, (list, tuple)):
147
+ pairs.extend((name, _format_query_value(key, item)) for item in value)
148
+ else:
149
+ pairs.append((name, _format_query_value(key, value)))
150
+ return pairs
151
+
152
+
153
+ def build_headers(api_key: str, request: Request) -> dict[str, str]:
154
+ headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
155
+ if not request.binary:
156
+ headers["Accept"] = "application/json"
157
+ if request.body is not None:
158
+ headers["Content-Type"] = "application/json"
159
+ if request.idempotency_key:
160
+ headers["Idempotency-Key"] = request.idempotency_key
161
+ return headers
162
+
163
+
164
+ def encode_body(request: Request) -> bytes | None:
165
+ if request.body is None:
166
+ return None
167
+ return json.dumps(request.body, separators=(",", ":"), default=_json_default).encode("utf-8")
168
+
169
+
170
+ def _json_default(value: Any) -> Any:
171
+ if isinstance(value, datetime):
172
+ return format_datetime(value)
173
+ if isinstance(value, date):
174
+ return value.isoformat()
175
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
176
+
177
+
178
+ def parse_response(status: int, text: str, location: str | None = None) -> Any:
179
+ """A port of the Node SDK's parseResponse: 2xx returns the decoded body, anything else raises."""
180
+ body: Any = None
181
+ is_json = text == ""
182
+ if text:
183
+ try:
184
+ body, is_json = json.loads(text), True
185
+ except ValueError:
186
+ is_json = False
187
+ if 200 <= status < 300:
188
+ if not is_json:
189
+ raise ApiError("application_error", "The API answered with a body that is not JSON.", status)
190
+ return body
191
+ raise_for_error(status, text, body, is_json, location)
192
+
193
+
194
+ def raise_for_error(
195
+ status: int, text: str, body: Any, is_json: bool, location: str | None = None
196
+ ) -> NoReturn:
197
+ if 300 <= status < 400 and location:
198
+ # httpx does not follow redirects by default; say where the API sent us.
199
+ raise error_for_status(
200
+ "application_error",
201
+ f"HTTP {status}: redirected to {location}. Check base_url; the SDK does not follow redirects.",
202
+ status,
203
+ {"location": location},
204
+ )
205
+ if not is_json or not isinstance(body, dict):
206
+ raise error_for_status("application_error", text[:200] or f"HTTP {status}", status, None)
207
+ code = body["code"] if isinstance(body.get("code"), str) else "application_error"
208
+ message = body["message"] if isinstance(body.get("message"), str) else f"HTTP {status}"
209
+ details = body.get("details")
210
+ if "details" not in body and isinstance(body.get("code"), str):
211
+ # A real API error (it has a code) may carry data beside the envelope, such as
212
+ # attempts_remaining on VERIFICATION_INVALID_CODE. Without this it would be lost.
213
+ extra = {k: v for k, v in body.items() if k not in ENVELOPE}
214
+ details = extra or None
215
+ raise error_for_status(code, message, status, details)
216
+
217
+
218
+ def parse_binary_response(status: int, content: bytes, location: str | None = None) -> bytes:
219
+ """2xx returns the bytes as they are; an error status is the API's usual JSON envelope."""
220
+ if 200 <= status < 300:
221
+ return content
222
+ text = content.decode("utf-8", errors="replace")
223
+ body: Any = None
224
+ is_json = False
225
+ try:
226
+ body, is_json = json.loads(text), True
227
+ except ValueError:
228
+ pass
229
+ raise_for_error(status, text, body, is_json, location)
230
+
231
+
232
+ def resolve_url(base_url: str, request: Request) -> str:
233
+ return base_url.rstrip("/") + fill_path(request.path, request.path_params)
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class Subject:
238
+ kind: str # "phone" or "email"
239
+ value: str
240
+
241
+ def as_fields(self) -> dict[str, str]:
242
+ return {"phone_number" if self.kind == "phone" else "email_address": self.value}
243
+
244
+
245
+ def one_subject(phone_number: str | None, email_address: str | None) -> Subject:
246
+ """Exactly one of phone_number or email_address, as the API requires. An empty string counts as not given."""
247
+ phone = bool(phone_number)
248
+ email = bool(email_address)
249
+ if phone == email:
250
+ raise InvalidArgumentError("Pass exactly one of phone_number or email_address; nothing was sent.")
251
+ if phone:
252
+ return Subject("phone", str(phone_number))
253
+ return Subject("email", str(email_address))
254
+
255
+
256
+ def join_area_codes(area_codes: str | list[str] | NotGiven | None) -> str | NotGiven | None:
257
+ """The API takes one comma-separated value; a repeated key would not be read as a list."""
258
+ if isinstance(area_codes, list):
259
+ return ",".join(area_codes) or None
260
+ return area_codes
261
+
262
+
263
+ def batch_body(
264
+ emails: list[dict[str, Any]] | None,
265
+ recipients: list[dict[str, Any]] | None,
266
+ shared: Mapping[str, Any],
267
+ ) -> dict[str, Any] | list[Any]:
268
+ """The batch endpoint takes a list of emails, or one message with a `recipients` list. Never both.
269
+
270
+ The caller's own email dicts go out exactly as written (write `"from"`, not `from_`).
271
+ """
272
+ if (emails is None) == (recipients is None):
273
+ raise InvalidArgumentError("Pass exactly one of emails or recipients; nothing was sent.")
274
+ if emails is not None:
275
+ if any(not isinstance(value, NotGiven) for value in shared.values()):
276
+ raise InvalidArgumentError(
277
+ "Fields beside `emails` are ignored by the API. Put them on each email, or use `recipients`."
278
+ )
279
+ return list(emails)
280
+ return {**build_body(shared), "recipients": recipients}
honkio/_endpoints.py ADDED
@@ -0,0 +1,93 @@
1
+ """Every route the SDK calls. tests/test_spec_contract.py checks each against the OpenAPI snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NamedTuple
6
+
7
+
8
+ class Endpoint(NamedTuple):
9
+ resource: str
10
+ method: str
11
+ http_method: str
12
+ path: str
13
+
14
+
15
+ ENDPOINTS: tuple[Endpoint, ...] = (
16
+ Endpoint("messages", "send", "POST", "/v1/messages"),
17
+ Endpoint("messages", "get", "GET", "/v1/messages/{id}"),
18
+ Endpoint("messages", "list", "GET", "/v1/messages"),
19
+ Endpoint("phone_numbers", "area_codes", "GET", "/v1/phone-numbers/area-codes"),
20
+ Endpoint("phone_numbers", "search", "GET", "/v1/phone-numbers/search"),
21
+ Endpoint("phone_numbers", "provision", "POST", "/v1/phone-numbers"),
22
+ Endpoint("phone_numbers", "list", "GET", "/v1/phone-numbers"),
23
+ Endpoint("phone_numbers", "get", "GET", "/v1/phone-numbers/{id}"),
24
+ Endpoint("phone_numbers", "release", "DELETE", "/v1/phone-numbers/{id}"),
25
+ Endpoint("verify", "start", "POST", "/v1/verify"),
26
+ Endpoint("verify", "check", "POST", "/v1/verify/{id}/check"),
27
+ Endpoint("verify", "get", "GET", "/v1/verify/{id}"),
28
+ Endpoint("verify", "list", "GET", "/v1/verify"),
29
+ Endpoint("consents", "create", "POST", "/v1/compliance/consents"),
30
+ Endpoint("consents", "list", "GET", "/v1/compliance/consents"),
31
+ Endpoint("consents", "record_batch", "POST", "/v1/compliance/consents/batch"),
32
+ Endpoint("consents", "check", "GET", "/v1/compliance/consents/check"),
33
+ Endpoint("consents", "revoke", "DELETE", "/v1/compliance/consents/{phone}"),
34
+ Endpoint("consents", "revoke", "DELETE", "/v1/compliance/consents/email/{address}"),
35
+ Endpoint("consent_batches", "list", "GET", "/v1/compliance/consent-batches"),
36
+ Endpoint("consent_batches", "get", "GET", "/v1/compliance/consent-batches/{id}"),
37
+ Endpoint("opt_in_confirmations", "send", "POST", "/v1/compliance/opt-in-confirmations"),
38
+ Endpoint("opt_in_confirmations", "list", "GET", "/v1/compliance/opt-in-confirmations"),
39
+ Endpoint("webhooks", "create", "POST", "/v1/webhooks"),
40
+ Endpoint("webhooks", "list", "GET", "/v1/webhooks"),
41
+ Endpoint("webhooks", "get", "GET", "/v1/webhooks/{id}"),
42
+ Endpoint("webhooks", "update", "PATCH", "/v1/webhooks/{id}"),
43
+ Endpoint("webhooks", "remove", "DELETE", "/v1/webhooks/{id}"),
44
+ Endpoint("webhooks", "deliveries", "GET", "/v1/webhooks/{id}/deliveries"),
45
+ Endpoint("webhooks", "dead_letters", "GET", "/v1/webhooks/{id}/dead-letters"),
46
+ Endpoint("webhooks", "replay", "POST", "/v1/webhooks/dead-letters/{dlid}/replay"),
47
+ Endpoint("webhooks", "discard", "DELETE", "/v1/webhooks/dead-letters/{dlid}"),
48
+ Endpoint("webhooks", "reactivate", "POST", "/v1/webhooks/{id}/reactivate"),
49
+ Endpoint("webhooks", "rotate_secret", "POST", "/v1/webhooks/{id}/rotate-secret"),
50
+ Endpoint("emails", "send", "POST", "/v1/emails"),
51
+ Endpoint("emails", "get", "GET", "/v1/emails/{id}"),
52
+ Endpoint("emails", "list", "GET", "/v1/emails"),
53
+ Endpoint("emails", "update", "PATCH", "/v1/emails/{id}"),
54
+ Endpoint("emails", "cancel", "DELETE", "/v1/emails/{id}"),
55
+ Endpoint("emails.received", "list", "GET", "/v1/emails/received"),
56
+ Endpoint("emails.received", "address", "GET", "/v1/emails/received/address"),
57
+ Endpoint("emails.received", "set_address_enabled", "PATCH", "/v1/emails/received/address"),
58
+ Endpoint("emails.received", "get", "GET", "/v1/emails/received/{id}"),
59
+ Endpoint("emails.received", "raw", "GET", "/v1/emails/received/{id}/raw"),
60
+ Endpoint("emails.received", "attachment", "GET", "/v1/emails/received/{id}/attachments/{attachmentId}"),
61
+ Endpoint("emails.received", "simulate", "POST", "/v1/emails/received/simulate"),
62
+ Endpoint("batch", "send", "POST", "/v1/emails/batch"),
63
+ Endpoint("domains", "create", "POST", "/v1/email-domains"),
64
+ Endpoint("domains", "get", "GET", "/v1/email-domains/{id}"),
65
+ Endpoint("domains", "list", "GET", "/v1/email-domains"),
66
+ Endpoint("domains", "update", "PATCH", "/v1/email-domains/{id}"),
67
+ Endpoint("domains", "verify", "POST", "/v1/email-domains/{id}/verify"),
68
+ Endpoint("domains", "remove", "DELETE", "/v1/email-domains/{id}"),
69
+ Endpoint("suppressions", "create", "POST", "/v1/email-suppressions"),
70
+ Endpoint("suppressions", "list", "GET", "/v1/email-suppressions"),
71
+ Endpoint("suppressions", "remove", "DELETE", "/v1/email-suppressions/{address}"),
72
+ Endpoint("templates", "create", "POST", "/v1/email-templates"),
73
+ Endpoint("templates", "get", "GET", "/v1/email-templates/{idOrAlias}"),
74
+ Endpoint("templates", "list", "GET", "/v1/email-templates"),
75
+ Endpoint("templates", "update", "PATCH", "/v1/email-templates/{idOrAlias}"),
76
+ Endpoint("templates", "publish", "POST", "/v1/email-templates/{idOrAlias}/publish"),
77
+ Endpoint("templates", "rollback", "POST", "/v1/email-templates/{idOrAlias}/rollback"),
78
+ Endpoint("templates", "versions", "GET", "/v1/email-templates/{idOrAlias}/versions"),
79
+ Endpoint("templates", "remove", "DELETE", "/v1/email-templates/{idOrAlias}"),
80
+ Endpoint("toll_free_verifications", "options", "GET", "/v1/toll-free-verifications/options"),
81
+ Endpoint("toll_free_verifications", "create", "POST", "/v1/toll-free-verifications"),
82
+ Endpoint("toll_free_verifications", "update", "PATCH", "/v1/toll-free-verifications/{id}"),
83
+ Endpoint("toll_free_verifications", "submit", "POST", "/v1/toll-free-verifications/{id}/submit"),
84
+ Endpoint("toll_free_verifications", "cancel", "DELETE", "/v1/toll-free-verifications/{id}"),
85
+ Endpoint("toll_free_verifications", "get", "GET", "/v1/toll-free-verifications/{id}"),
86
+ Endpoint("toll_free_verifications", "list", "GET", "/v1/toll-free-verifications"),
87
+ Endpoint("business_verification", "get", "GET", "/v1/business-verification"),
88
+ Endpoint("business_verification", "save", "PUT", "/v1/business-verification"),
89
+ Endpoint("business_verification", "submit", "POST", "/v1/business-verification/submit"),
90
+ Endpoint("auto_topup", "get", "GET", "/v1/auto-topup"),
91
+ Endpoint("topup_cap_requests", "create", "POST", "/v1/account/topup-cap-requests"),
92
+ Endpoint("topup_cap_requests", "list", "GET", "/v1/account/topup-cap-requests"),
93
+ )
honkio/_errors.py ADDED
@@ -0,0 +1,101 @@
1
+ """Every exception the SDK raises. Errors are exceptions, not result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class HonkioError(Exception):
9
+ """Base class for everything this SDK raises."""
10
+
11
+
12
+ class ApiError(HonkioError):
13
+ """The API answered with an error status (or with a body the SDK cannot read)."""
14
+
15
+ def __init__(self, code: str, message: str, status_code: int, details: Any | None = None) -> None:
16
+ super().__init__(f"{code}: {message} (HTTP {status_code})")
17
+ self.code = code
18
+ self.message = message
19
+ self.status_code = status_code
20
+ self.details = details
21
+
22
+ def __reduce__(self) -> tuple[Any, ...]:
23
+ # Exception pickles by re-calling the class with `self.args`, which is the one formatted
24
+ # message here; rebuild from the real constructor arguments instead.
25
+ return (type(self), (self.code, self.message, self.status_code, self.details))
26
+
27
+
28
+ class BadRequestError(ApiError):
29
+ """HTTP 400."""
30
+
31
+
32
+ class AuthenticationError(ApiError):
33
+ """HTTP 401."""
34
+
35
+
36
+ class PaymentRequiredError(ApiError):
37
+ """HTTP 402."""
38
+
39
+
40
+ class PermissionDeniedError(ApiError):
41
+ """HTTP 403."""
42
+
43
+
44
+ class NotFoundError(ApiError):
45
+ """HTTP 404."""
46
+
47
+
48
+ class ConflictError(ApiError):
49
+ """HTTP 409."""
50
+
51
+
52
+ class UnprocessableEntityError(ApiError):
53
+ """HTTP 422."""
54
+
55
+
56
+ class RateLimitError(ApiError):
57
+ """HTTP 429."""
58
+
59
+
60
+ class UnavailableForLegalReasonsError(ApiError):
61
+ """HTTP 451."""
62
+
63
+
64
+ class InternalServerError(ApiError):
65
+ """HTTP 500 and above."""
66
+
67
+
68
+ class APIConnectionError(HonkioError):
69
+ """The request never produced a response (DNS, refused connection, reset)."""
70
+
71
+
72
+ class APITimeoutError(APIConnectionError):
73
+ """The request timed out. A send may or may not have gone through: retry with an idempotency key."""
74
+
75
+
76
+ class InvalidArgumentError(HonkioError, ValueError):
77
+ """A call was refused before anything was sent."""
78
+
79
+
80
+ class InvalidSignatureError(HonkioError):
81
+ """A webhook failed signature verification."""
82
+
83
+
84
+ _BY_STATUS: dict[int, type[ApiError]] = {
85
+ 400: BadRequestError,
86
+ 401: AuthenticationError,
87
+ 402: PaymentRequiredError,
88
+ 403: PermissionDeniedError,
89
+ 404: NotFoundError,
90
+ 409: ConflictError,
91
+ 422: UnprocessableEntityError,
92
+ 429: RateLimitError,
93
+ 451: UnavailableForLegalReasonsError,
94
+ }
95
+
96
+
97
+ def error_for_status(code: str, message: str, status: int, details: Any | None) -> ApiError:
98
+ cls = _BY_STATUS.get(status)
99
+ if cls is None:
100
+ cls = InternalServerError if status >= 500 else ApiError
101
+ return cls(code, message, status, details)