anypost 1.0.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.
- anypost/__init__.py +75 -0
- anypost/_async/__init__.py +7 -0
- anypost/_async/client.py +109 -0
- anypost/_async/resources.py +346 -0
- anypost/_async_http.py +96 -0
- anypost/_base64.py +10 -0
- anypost/_client.py +110 -0
- anypost/_exceptions.py +234 -0
- anypost/_http.py +176 -0
- anypost/_pagination.py +92 -0
- anypost/_version.py +3 -0
- anypost/_webhook_signature.py +171 -0
- anypost/py.typed +0 -0
- anypost/resources/__init__.py +23 -0
- anypost/resources/api_keys.py +62 -0
- anypost/resources/domains.py +62 -0
- anypost/resources/email.py +76 -0
- anypost/resources/events.py +53 -0
- anypost/resources/identity.py +17 -0
- anypost/resources/suppressions.py +86 -0
- anypost/resources/templates.py +86 -0
- anypost/resources/webhooks.py +82 -0
- anypost/types/__init__.py +117 -0
- anypost/types/api_key.py +56 -0
- anypost/types/common.py +55 -0
- anypost/types/domain.py +87 -0
- anypost/types/email.py +135 -0
- anypost/types/event.py +99 -0
- anypost/types/identity.py +27 -0
- anypost/types/suppression.py +62 -0
- anypost/types/template.py +88 -0
- anypost/types/webhook.py +82 -0
- anypost-1.0.0.dist-info/METADATA +422 -0
- anypost-1.0.0.dist-info/RECORD +36 -0
- anypost-1.0.0.dist-info/WHEEL +4 -0
- anypost-1.0.0.dist-info/licenses/LICENSE +21 -0
anypost/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Official Python SDK for the Anypost email API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._async import AsyncAnypost
|
|
6
|
+
from ._client import Anypost
|
|
7
|
+
from ._exceptions import (
|
|
8
|
+
AnypostError,
|
|
9
|
+
APIConnectionError,
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
IdempotencyMismatchError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PayloadTooLargeError,
|
|
16
|
+
PermissionError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from ._pagination import AsyncPage, Page
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from ._webhook_signature import (
|
|
23
|
+
WebhookDelivery,
|
|
24
|
+
WebhookDeliveryEvent,
|
|
25
|
+
WebhookVerificationError,
|
|
26
|
+
WebhookVerificationFailure,
|
|
27
|
+
unwrap_webhook_event,
|
|
28
|
+
verify_webhook_signature,
|
|
29
|
+
)
|
|
30
|
+
from .resources import (
|
|
31
|
+
ApiKeys,
|
|
32
|
+
Domains,
|
|
33
|
+
Email,
|
|
34
|
+
Events,
|
|
35
|
+
Identity,
|
|
36
|
+
Suppressions,
|
|
37
|
+
Templates,
|
|
38
|
+
Webhooks,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Anypost",
|
|
43
|
+
"AsyncAnypost",
|
|
44
|
+
"Page",
|
|
45
|
+
"AsyncPage",
|
|
46
|
+
"__version__",
|
|
47
|
+
# Resources
|
|
48
|
+
"ApiKeys",
|
|
49
|
+
"Domains",
|
|
50
|
+
"Email",
|
|
51
|
+
"Events",
|
|
52
|
+
"Identity",
|
|
53
|
+
"Suppressions",
|
|
54
|
+
"Templates",
|
|
55
|
+
"Webhooks",
|
|
56
|
+
# Errors
|
|
57
|
+
"AnypostError",
|
|
58
|
+
"APIConnectionError",
|
|
59
|
+
"APIError",
|
|
60
|
+
"AuthenticationError",
|
|
61
|
+
"ConflictError",
|
|
62
|
+
"IdempotencyMismatchError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"PayloadTooLargeError",
|
|
65
|
+
"PermissionError",
|
|
66
|
+
"RateLimitError",
|
|
67
|
+
"ValidationError",
|
|
68
|
+
# Webhooks
|
|
69
|
+
"WebhookDelivery",
|
|
70
|
+
"WebhookDeliveryEvent",
|
|
71
|
+
"WebhookVerificationError",
|
|
72
|
+
"WebhookVerificationFailure",
|
|
73
|
+
"unwrap_webhook_event",
|
|
74
|
+
"verify_webhook_signature",
|
|
75
|
+
]
|
anypost/_async/client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The asynchronous :class:`AsyncAnypost` client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Mapping, Optional, Type
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .._async_http import AsyncHttpClient
|
|
12
|
+
from ..types.identity import WhoamiResponse
|
|
13
|
+
from .resources import (
|
|
14
|
+
AsyncApiKeys,
|
|
15
|
+
AsyncDomains,
|
|
16
|
+
AsyncEmail,
|
|
17
|
+
AsyncEvents,
|
|
18
|
+
AsyncIdentity,
|
|
19
|
+
AsyncSuppressions,
|
|
20
|
+
AsyncTemplates,
|
|
21
|
+
AsyncWebhooks,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.anypost.com/v1"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncAnypost:
|
|
30
|
+
"""Async client for the Anypost email API.
|
|
31
|
+
|
|
32
|
+
::
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
from anypost import AsyncAnypost
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
async with AsyncAnypost("ap_your_api_key") as client:
|
|
39
|
+
result = await client.email.send({
|
|
40
|
+
"from": "Acme <you@yourdomain.com>",
|
|
41
|
+
"to": ["someone@example.com"],
|
|
42
|
+
"subject": "Hello",
|
|
43
|
+
"html": "<p>It worked.</p>",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
email: AsyncEmail
|
|
50
|
+
domains: AsyncDomains
|
|
51
|
+
api_keys: AsyncApiKeys
|
|
52
|
+
templates: AsyncTemplates
|
|
53
|
+
suppressions: AsyncSuppressions
|
|
54
|
+
webhooks: AsyncWebhooks
|
|
55
|
+
events: AsyncEvents
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
api_key: Optional[str] = None,
|
|
60
|
+
*,
|
|
61
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
62
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
63
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
64
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
65
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
key = api_key or os.environ.get("ANYPOST_API_KEY")
|
|
68
|
+
if not key:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"An Anypost API key is required. Pass it to the constructor "
|
|
71
|
+
"or set ANYPOST_API_KEY."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self._http = AsyncHttpClient(
|
|
75
|
+
api_key=key,
|
|
76
|
+
base_url=base_url.rstrip("/"),
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
max_retries=max_retries,
|
|
79
|
+
default_headers=default_headers or {},
|
|
80
|
+
client=http_client,
|
|
81
|
+
)
|
|
82
|
+
self._identity = AsyncIdentity(self._http)
|
|
83
|
+
|
|
84
|
+
self.email = AsyncEmail(self._http)
|
|
85
|
+
self.domains = AsyncDomains(self._http)
|
|
86
|
+
self.api_keys = AsyncApiKeys(self._http)
|
|
87
|
+
self.templates = AsyncTemplates(self._http)
|
|
88
|
+
self.suppressions = AsyncSuppressions(self._http)
|
|
89
|
+
self.webhooks = AsyncWebhooks(self._http)
|
|
90
|
+
self.events = AsyncEvents(self._http)
|
|
91
|
+
|
|
92
|
+
async def whoami(self) -> WhoamiResponse:
|
|
93
|
+
"""Identify the team and permission level behind the current API key."""
|
|
94
|
+
return await self._identity.whoami()
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
"""Close the underlying HTTP connection pool."""
|
|
98
|
+
await self._http.aclose()
|
|
99
|
+
|
|
100
|
+
async def __aenter__(self) -> "AsyncAnypost":
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
async def __aexit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: Optional[Type[BaseException]],
|
|
106
|
+
exc: Optional[BaseException],
|
|
107
|
+
tb: Optional[TracebackType],
|
|
108
|
+
) -> None:
|
|
109
|
+
await self.aclose()
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Async resource clients. Mirror the sync resources in :mod:`anypost.resources`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .._async_http import AsyncHttpClient
|
|
9
|
+
from .._pagination import AsyncPage
|
|
10
|
+
from ..resources.email import _encode_attachments
|
|
11
|
+
from ..types.api_key import (
|
|
12
|
+
ApiKey,
|
|
13
|
+
ApiKeyCreateParams,
|
|
14
|
+
ApiKeyUpdateParams,
|
|
15
|
+
ApiKeyWithSecret,
|
|
16
|
+
)
|
|
17
|
+
from ..types.common import ListParams
|
|
18
|
+
from ..types.domain import Domain, DomainCreateParams, DomainUpdateParams
|
|
19
|
+
from ..types.email import BatchResponse, EmailBatchParams, EmailSendParams, SendResponse
|
|
20
|
+
from ..types.event import Event, EventListParams
|
|
21
|
+
from ..types.identity import WhoamiResponse
|
|
22
|
+
from ..types.suppression import (
|
|
23
|
+
Suppression,
|
|
24
|
+
SuppressionCreateParams,
|
|
25
|
+
SuppressionListParams,
|
|
26
|
+
)
|
|
27
|
+
from ..types.template import (
|
|
28
|
+
Template,
|
|
29
|
+
TemplateCreateParams,
|
|
30
|
+
TemplateDraft,
|
|
31
|
+
TemplateDraftParams,
|
|
32
|
+
TemplateDuplicateParams,
|
|
33
|
+
TemplateUpdateParams,
|
|
34
|
+
)
|
|
35
|
+
from ..types.webhook import (
|
|
36
|
+
Webhook,
|
|
37
|
+
WebhookCreateParams,
|
|
38
|
+
WebhookTestResult,
|
|
39
|
+
WebhookUpdateParams,
|
|
40
|
+
WebhookWithSecret,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AsyncEmail:
|
|
45
|
+
"""Operations on the ``/email`` endpoints."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
48
|
+
self._http = http
|
|
49
|
+
|
|
50
|
+
async def send(
|
|
51
|
+
self, email: EmailSendParams, *, idempotency_key: Optional[str] = None
|
|
52
|
+
) -> SendResponse:
|
|
53
|
+
"""Send a single message. See :meth:`anypost.resources.email.Email.send`."""
|
|
54
|
+
return await self._http.request(
|
|
55
|
+
"POST",
|
|
56
|
+
"/email",
|
|
57
|
+
body=_encode_attachments(email),
|
|
58
|
+
idempotent=True,
|
|
59
|
+
idempotency_key=idempotency_key,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def send_batch(
|
|
63
|
+
self, batch: EmailBatchParams, *, idempotency_key: Optional[str] = None
|
|
64
|
+
) -> BatchResponse:
|
|
65
|
+
"""Send 1-100 messages. A ``207`` returns normally; it does not raise."""
|
|
66
|
+
body: dict[str, Any] = dict(batch)
|
|
67
|
+
if batch.get("defaults"):
|
|
68
|
+
body["defaults"] = _encode_attachments(batch["defaults"])
|
|
69
|
+
body["emails"] = [_encode_attachments(e) for e in batch["emails"]]
|
|
70
|
+
return await self._http.request(
|
|
71
|
+
"POST",
|
|
72
|
+
"/email/batch",
|
|
73
|
+
body=body,
|
|
74
|
+
idempotent=True,
|
|
75
|
+
idempotency_key=idempotency_key,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncDomains:
|
|
80
|
+
"""Operations on the ``/domains`` endpoints."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
83
|
+
self._http = http
|
|
84
|
+
|
|
85
|
+
async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Domain]:
|
|
86
|
+
return await self._fetch_page(params)
|
|
87
|
+
|
|
88
|
+
async def create(self, params: DomainCreateParams) -> Domain:
|
|
89
|
+
return await self._http.request("POST", "/domains", body=params)
|
|
90
|
+
|
|
91
|
+
async def get(self, id: str) -> Domain:
|
|
92
|
+
return await self._http.request("GET", f"/domains/{quote(id, safe='')}")
|
|
93
|
+
|
|
94
|
+
async def update(self, id: str, params: DomainUpdateParams) -> Domain:
|
|
95
|
+
return await self._http.request(
|
|
96
|
+
"PATCH", f"/domains/{quote(id, safe='')}", body=params
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def delete(self, id: str) -> None:
|
|
100
|
+
await self._http.request("DELETE", f"/domains/{quote(id, safe='')}")
|
|
101
|
+
|
|
102
|
+
async def verify(self, id: str) -> Domain:
|
|
103
|
+
return await self._http.request("POST", f"/domains/{quote(id, safe='')}/verify")
|
|
104
|
+
|
|
105
|
+
async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Domain]:
|
|
106
|
+
params = params or {}
|
|
107
|
+
response = await self._http.request(
|
|
108
|
+
"GET",
|
|
109
|
+
"/domains",
|
|
110
|
+
query={"limit": params.get("limit"), "after": params.get("after")},
|
|
111
|
+
)
|
|
112
|
+
return AsyncPage(
|
|
113
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AsyncApiKeys:
|
|
118
|
+
"""Operations on the ``/api-keys`` endpoints."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
121
|
+
self._http = http
|
|
122
|
+
|
|
123
|
+
async def list(self, params: Optional[ListParams] = None) -> AsyncPage[ApiKey]:
|
|
124
|
+
return await self._fetch_page(params)
|
|
125
|
+
|
|
126
|
+
async def create(self, params: ApiKeyCreateParams) -> ApiKeyWithSecret:
|
|
127
|
+
return await self._http.request("POST", "/api-keys", body=params)
|
|
128
|
+
|
|
129
|
+
async def get(self, id: str) -> ApiKey:
|
|
130
|
+
return await self._http.request("GET", f"/api-keys/{quote(id, safe='')}")
|
|
131
|
+
|
|
132
|
+
async def update(self, id: str, params: ApiKeyUpdateParams) -> ApiKey:
|
|
133
|
+
return await self._http.request(
|
|
134
|
+
"PATCH", f"/api-keys/{quote(id, safe='')}", body=params
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def delete(self, id: str) -> None:
|
|
138
|
+
await self._http.request("DELETE", f"/api-keys/{quote(id, safe='')}")
|
|
139
|
+
|
|
140
|
+
async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[ApiKey]:
|
|
141
|
+
params = params or {}
|
|
142
|
+
response = await self._http.request(
|
|
143
|
+
"GET",
|
|
144
|
+
"/api-keys",
|
|
145
|
+
query={"limit": params.get("limit"), "after": params.get("after")},
|
|
146
|
+
)
|
|
147
|
+
return AsyncPage(
|
|
148
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncTemplates:
|
|
153
|
+
"""Operations on the ``/templates`` endpoints."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
156
|
+
self._http = http
|
|
157
|
+
|
|
158
|
+
async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Template]:
|
|
159
|
+
return await self._fetch_page(params)
|
|
160
|
+
|
|
161
|
+
async def create(self, params: TemplateCreateParams) -> Template:
|
|
162
|
+
return await self._http.request("POST", "/templates", body=params)
|
|
163
|
+
|
|
164
|
+
async def get(self, id: str) -> Template:
|
|
165
|
+
return await self._http.request("GET", f"/templates/{quote(id, safe='')}")
|
|
166
|
+
|
|
167
|
+
async def update(self, id: str, params: TemplateUpdateParams) -> Template:
|
|
168
|
+
return await self._http.request(
|
|
169
|
+
"PATCH", f"/templates/{quote(id, safe='')}", body=params
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def delete(self, id: str) -> None:
|
|
173
|
+
await self._http.request("DELETE", f"/templates/{quote(id, safe='')}")
|
|
174
|
+
|
|
175
|
+
async def duplicate(
|
|
176
|
+
self, id: str, params: Optional[TemplateDuplicateParams] = None
|
|
177
|
+
) -> Template:
|
|
178
|
+
return await self._http.request(
|
|
179
|
+
"POST", f"/templates/{quote(id, safe='')}/duplicate", body=params or None
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def get_draft(self, id: str) -> TemplateDraft:
|
|
183
|
+
return await self._http.request("GET", f"/templates/{quote(id, safe='')}/draft")
|
|
184
|
+
|
|
185
|
+
async def update_draft(self, id: str, params: TemplateDraftParams) -> TemplateDraft:
|
|
186
|
+
return await self._http.request(
|
|
187
|
+
"PATCH", f"/templates/{quote(id, safe='')}/draft", body=params
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def delete_draft(self, id: str) -> None:
|
|
191
|
+
await self._http.request("DELETE", f"/templates/{quote(id, safe='')}/draft")
|
|
192
|
+
|
|
193
|
+
async def publish(self, id: str) -> Template:
|
|
194
|
+
return await self._http.request("POST", f"/templates/{quote(id, safe='')}/publish")
|
|
195
|
+
|
|
196
|
+
async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Template]:
|
|
197
|
+
params = params or {}
|
|
198
|
+
response = await self._http.request(
|
|
199
|
+
"GET",
|
|
200
|
+
"/templates",
|
|
201
|
+
query={"limit": params.get("limit"), "after": params.get("after")},
|
|
202
|
+
)
|
|
203
|
+
return AsyncPage(
|
|
204
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class AsyncSuppressions:
|
|
209
|
+
"""Operations on the ``/suppressions`` endpoints."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
212
|
+
self._http = http
|
|
213
|
+
|
|
214
|
+
async def list(
|
|
215
|
+
self, params: Optional[SuppressionListParams] = None
|
|
216
|
+
) -> AsyncPage[Suppression]:
|
|
217
|
+
return await self._fetch_page(params)
|
|
218
|
+
|
|
219
|
+
async def create(self, params: SuppressionCreateParams) -> Suppression:
|
|
220
|
+
return await self._http.request("POST", "/suppressions", body=params)
|
|
221
|
+
|
|
222
|
+
async def get(self, email: str, topic: str) -> Suppression:
|
|
223
|
+
return await self._http.request(
|
|
224
|
+
"GET", f"/suppressions/{quote(email, safe='')}/{quote(topic, safe='')}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def delete(self, email: str, topic: str) -> None:
|
|
228
|
+
await self._http.request(
|
|
229
|
+
"DELETE", f"/suppressions/{quote(email, safe='')}/{quote(topic, safe='')}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def list_for_email(self, email: str) -> List[Suppression]:
|
|
233
|
+
response = await self._http.request("GET", f"/suppressions/{quote(email, safe='')}")
|
|
234
|
+
return response["data"]
|
|
235
|
+
|
|
236
|
+
async def delete_for_email(self, email: str) -> None:
|
|
237
|
+
await self._http.request("DELETE", f"/suppressions/{quote(email, safe='')}")
|
|
238
|
+
|
|
239
|
+
async def _fetch_page(
|
|
240
|
+
self, params: Optional[SuppressionListParams]
|
|
241
|
+
) -> AsyncPage[Suppression]:
|
|
242
|
+
params = params or {}
|
|
243
|
+
response = await self._http.request(
|
|
244
|
+
"GET",
|
|
245
|
+
"/suppressions",
|
|
246
|
+
query={
|
|
247
|
+
"limit": params.get("limit"),
|
|
248
|
+
"after": params.get("after"),
|
|
249
|
+
"email_contains": params.get("email_contains"),
|
|
250
|
+
"topic": params.get("topic"),
|
|
251
|
+
"reason": params.get("reason"),
|
|
252
|
+
"origin": params.get("origin"),
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
return AsyncPage(
|
|
256
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AsyncWebhooks:
|
|
261
|
+
"""Operations on the ``/webhooks`` endpoints."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
264
|
+
self._http = http
|
|
265
|
+
|
|
266
|
+
async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Webhook]:
|
|
267
|
+
return await self._fetch_page(params)
|
|
268
|
+
|
|
269
|
+
async def create(self, params: WebhookCreateParams) -> WebhookWithSecret:
|
|
270
|
+
return await self._http.request("POST", "/webhooks", body=params)
|
|
271
|
+
|
|
272
|
+
async def get(self, id: str) -> Webhook:
|
|
273
|
+
return await self._http.request("GET", f"/webhooks/{quote(id, safe='')}")
|
|
274
|
+
|
|
275
|
+
async def update(self, id: str, params: WebhookUpdateParams) -> Webhook:
|
|
276
|
+
return await self._http.request(
|
|
277
|
+
"PATCH", f"/webhooks/{quote(id, safe='')}", body=params
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
async def delete(self, id: str) -> None:
|
|
281
|
+
await self._http.request("DELETE", f"/webhooks/{quote(id, safe='')}")
|
|
282
|
+
|
|
283
|
+
async def test(self, id: str) -> WebhookTestResult:
|
|
284
|
+
return await self._http.request("POST", f"/webhooks/{quote(id, safe='')}/test")
|
|
285
|
+
|
|
286
|
+
async def rotate_secret(self, id: str) -> WebhookWithSecret:
|
|
287
|
+
return await self._http.request(
|
|
288
|
+
"POST", f"/webhooks/{quote(id, safe='')}/rotate-secret"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Webhook]:
|
|
292
|
+
params = params or {}
|
|
293
|
+
response = await self._http.request(
|
|
294
|
+
"GET",
|
|
295
|
+
"/webhooks",
|
|
296
|
+
query={"limit": params.get("limit"), "after": params.get("after")},
|
|
297
|
+
)
|
|
298
|
+
return AsyncPage(
|
|
299
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class AsyncEvents:
|
|
304
|
+
"""Read access to the ``/events`` stream."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
307
|
+
self._http = http
|
|
308
|
+
|
|
309
|
+
async def list(self, params: Optional[EventListParams] = None) -> AsyncPage[Event]:
|
|
310
|
+
return await self._fetch_page(params)
|
|
311
|
+
|
|
312
|
+
async def _fetch_page(self, params: Optional[EventListParams]) -> AsyncPage[Event]:
|
|
313
|
+
params = params or {}
|
|
314
|
+
tags = params.get("tags")
|
|
315
|
+
response = await self._http.request(
|
|
316
|
+
"GET",
|
|
317
|
+
"/events",
|
|
318
|
+
query={
|
|
319
|
+
"limit": params.get("limit"),
|
|
320
|
+
"after": params.get("after"),
|
|
321
|
+
"start": params.get("start"),
|
|
322
|
+
"end": params.get("end"),
|
|
323
|
+
"event_type": params.get("event_type"),
|
|
324
|
+
"recipient": params.get("recipient"),
|
|
325
|
+
"email_id": params.get("email_id"),
|
|
326
|
+
"message_id": params.get("message_id"),
|
|
327
|
+
"domain": params.get("domain"),
|
|
328
|
+
"topic": params.get("topic"),
|
|
329
|
+
"campaign": params.get("campaign"),
|
|
330
|
+
"template_id": params.get("template_id"),
|
|
331
|
+
"tags": ",".join(tags) if tags else None,
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
return AsyncPage(
|
|
335
|
+
response, lambda after: self._fetch_page({**params, "after": after})
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class AsyncIdentity:
|
|
340
|
+
"""Identity operations."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
343
|
+
self._http = http
|
|
344
|
+
|
|
345
|
+
async def whoami(self) -> WhoamiResponse:
|
|
346
|
+
return await self._http.request("GET", "/whoami")
|
anypost/_async_http.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Asynchronous HTTP transport. Mirrors :mod:`anypost._http`, sharing its
|
|
2
|
+
pure helpers (header building, backoff, decoding)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._exceptions import APIConnectionError, parse_error
|
|
12
|
+
from ._http import (
|
|
13
|
+
RETRYABLE_STATUS,
|
|
14
|
+
QueryValue,
|
|
15
|
+
backoff_seconds,
|
|
16
|
+
build_headers,
|
|
17
|
+
clean_query,
|
|
18
|
+
connection_message,
|
|
19
|
+
decode_body,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncHttpClient:
|
|
24
|
+
"""Owns an :class:`httpx.AsyncClient` and implements the async request loop."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
api_key: str,
|
|
30
|
+
base_url: str,
|
|
31
|
+
timeout: float,
|
|
32
|
+
max_retries: int,
|
|
33
|
+
default_headers: Mapping[str, str],
|
|
34
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._api_key = api_key
|
|
37
|
+
self._max_retries = max_retries
|
|
38
|
+
self._default_headers = dict(default_headers)
|
|
39
|
+
self._owns_client = client is None
|
|
40
|
+
self._client = client or httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
|
41
|
+
|
|
42
|
+
async def request(
|
|
43
|
+
self,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
body: Any = None,
|
|
48
|
+
query: Optional[Mapping[str, QueryValue]] = None,
|
|
49
|
+
idempotent: bool = False,
|
|
50
|
+
idempotency_key: Optional[str] = None,
|
|
51
|
+
timeout: Optional[float] = None,
|
|
52
|
+
max_retries: Optional[int] = None,
|
|
53
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
54
|
+
) -> Any:
|
|
55
|
+
retries = self._max_retries if max_retries is None else max_retries
|
|
56
|
+
headers = build_headers(
|
|
57
|
+
self._api_key,
|
|
58
|
+
self._default_headers,
|
|
59
|
+
has_body=body is not None,
|
|
60
|
+
idempotent=idempotent,
|
|
61
|
+
idempotency_key=idempotency_key,
|
|
62
|
+
max_retries=retries,
|
|
63
|
+
extra_headers=extra_headers,
|
|
64
|
+
)
|
|
65
|
+
kwargs: dict[str, Any] = {"headers": headers, "params": clean_query(query)}
|
|
66
|
+
if body is not None:
|
|
67
|
+
kwargs["json"] = body
|
|
68
|
+
if timeout is not None:
|
|
69
|
+
kwargs["timeout"] = timeout
|
|
70
|
+
|
|
71
|
+
attempt = 0
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
response = await self._client.request(method, path, **kwargs)
|
|
75
|
+
except httpx.RequestError as exc:
|
|
76
|
+
if attempt < retries:
|
|
77
|
+
await asyncio.sleep(backoff_seconds(attempt, None))
|
|
78
|
+
attempt += 1
|
|
79
|
+
continue
|
|
80
|
+
raise APIConnectionError(connection_message(exc), exc) from exc
|
|
81
|
+
|
|
82
|
+
if response.is_success:
|
|
83
|
+
return decode_body(response)
|
|
84
|
+
|
|
85
|
+
if response.status_code in RETRYABLE_STATUS and attempt < retries:
|
|
86
|
+
await asyncio.sleep(backoff_seconds(attempt, response.headers))
|
|
87
|
+
attempt += 1
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
raise parse_error(
|
|
91
|
+
response.status_code, decode_body(response), response.headers
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
if self._owns_client:
|
|
96
|
+
await self._client.aclose()
|
anypost/_base64.py
ADDED