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 +53 -0
- honkio/_client.py +139 -0
- honkio/_core.py +280 -0
- honkio/_endpoints.py +93 -0
- honkio/_errors.py +101 -0
- honkio/_transport.py +114 -0
- honkio/_types.py +561 -0
- honkio/_version.py +1 -0
- honkio/_webhooks.py +138 -0
- honkio/py.typed +0 -0
- honkio/resources/__init__.py +53 -0
- honkio/resources/auto_topup.py +35 -0
- honkio/resources/batch.py +109 -0
- honkio/resources/business_verification.py +117 -0
- honkio/resources/consent_batches.py +59 -0
- honkio/resources/consents.py +217 -0
- honkio/resources/domains.py +158 -0
- honkio/resources/emails.py +260 -0
- honkio/resources/messages.py +166 -0
- honkio/resources/opt_in_confirmations.py +87 -0
- honkio/resources/phone_numbers.py +133 -0
- honkio/resources/received_emails.py +252 -0
- honkio/resources/suppressions.py +85 -0
- honkio/resources/templates.py +280 -0
- honkio/resources/toll_free_verifications.py +173 -0
- honkio/resources/topup_cap_requests.py +81 -0
- honkio/resources/verify.py +142 -0
- honkio/resources/webhooks.py +271 -0
- honkio/types.py +12 -0
- honkio-0.1.0.dist-info/METADATA +199 -0
- honkio-0.1.0.dist-info/RECORD +33 -0
- honkio-0.1.0.dist-info/WHEEL +4 -0
- honkio-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|