sendrelay 1.7.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.
- sendrelay/__init__.py +24 -0
- sendrelay/_client.py +333 -0
- sendrelay/_errors.py +44 -0
- sendrelay/_http.py +153 -0
- sendrelay/_operations.py +101 -0
- sendrelay/_version.py +2 -0
- sendrelay/_webhooks.py +54 -0
- sendrelay/py.typed +0 -0
- sendrelay/types.py +203 -0
- sendrelay-1.7.0.dist-info/METADATA +112 -0
- sendrelay-1.7.0.dist-info/RECORD +13 -0
- sendrelay-1.7.0.dist-info/WHEEL +4 -0
- sendrelay-1.7.0.dist-info/licenses/LICENSE +21 -0
sendrelay/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Official Relay server SDK for Python.
|
|
2
|
+
|
|
3
|
+
Quote and create deliveries, read them back with the rider's live ETA, manage webhooks and mint
|
|
4
|
+
tracking tokens for your apps. Use ``Relay`` in regular code and ``AsyncRelay`` with asyncio.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._client import AsyncRelay, Relay
|
|
8
|
+
from ._errors import ApiError, NetworkError, RelayError, ValidationError
|
|
9
|
+
from ._http import RetryConfig
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from ._webhooks import WebhookSignatureVerification, verify_webhook_signature
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ApiError",
|
|
15
|
+
"AsyncRelay",
|
|
16
|
+
"NetworkError",
|
|
17
|
+
"Relay",
|
|
18
|
+
"RelayError",
|
|
19
|
+
"RetryConfig",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"WebhookSignatureVerification",
|
|
22
|
+
"__version__",
|
|
23
|
+
"verify_webhook_signature",
|
|
24
|
+
]
|
sendrelay/_client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional, cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import _operations as ops
|
|
8
|
+
from ._errors import ValidationError
|
|
9
|
+
from ._http import DEFAULT_API_VERSION, DEFAULT_BASE_URL, Config, Request, RetryConfig, send, send_async
|
|
10
|
+
from ._webhooks import WebhookSignatureVerification, verify_webhook_signature
|
|
11
|
+
from .types import (
|
|
12
|
+
CancelTaskResponse,
|
|
13
|
+
CreateTaskResponse,
|
|
14
|
+
CreateWebhookResponse,
|
|
15
|
+
GetTaskResponse,
|
|
16
|
+
ListTasksResponse,
|
|
17
|
+
ListWebhooksResponse,
|
|
18
|
+
PresenceResponse,
|
|
19
|
+
TaskListItem,
|
|
20
|
+
TaskQuoteResponse,
|
|
21
|
+
Webhook,
|
|
22
|
+
WebSocketToken,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Body = Mapping[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config(
|
|
29
|
+
api_key: str,
|
|
30
|
+
base_url: Optional[str],
|
|
31
|
+
api_version: Optional[str],
|
|
32
|
+
timeout: float,
|
|
33
|
+
max_retries: Optional[int],
|
|
34
|
+
retry: Optional[RetryConfig],
|
|
35
|
+
auto_idempotency: bool,
|
|
36
|
+
headers: Optional[Dict[str, str]],
|
|
37
|
+
) -> Config:
|
|
38
|
+
if not api_key:
|
|
39
|
+
raise ValidationError("API key is required", "api_key")
|
|
40
|
+
if not (api_key.startswith("sk_live_") or api_key.startswith("sk_test_")):
|
|
41
|
+
raise ValidationError("Invalid API key format. Must start with sk_live_ or sk_test_", "api_key")
|
|
42
|
+
retry = retry or RetryConfig()
|
|
43
|
+
if max_retries is not None:
|
|
44
|
+
retry.max_retries = max_retries
|
|
45
|
+
return Config(
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
48
|
+
api_version=api_version or DEFAULT_API_VERSION,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
retry=retry,
|
|
51
|
+
auto_idempotency=auto_idempotency,
|
|
52
|
+
headers=dict(headers or {}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Base:
|
|
57
|
+
_config: Config
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def environment(self) -> str:
|
|
61
|
+
"""``"live"`` for a live key, ``"test"`` for a test key."""
|
|
62
|
+
return "live" if self._config.api_key.startswith("sk_live_") else "test"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─── Sync ────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Tasks:
|
|
69
|
+
def __init__(self, run: Any) -> None:
|
|
70
|
+
self._run = run
|
|
71
|
+
|
|
72
|
+
def quote(self, body: Body) -> TaskQuoteResponse:
|
|
73
|
+
"""Price a delivery without creating it. Pass the ``quoteId`` to ``create`` to lock the price."""
|
|
74
|
+
return cast(TaskQuoteResponse, self._run(ops.tasks_quote(body)))
|
|
75
|
+
|
|
76
|
+
def create(self, body: Body, *, idempotency_key: Optional[str] = None) -> CreateTaskResponse:
|
|
77
|
+
"""Create a delivery. Pass your own ``idempotency_key`` (for example your order ID) so retries
|
|
78
|
+
never create a second delivery; otherwise one is generated per call."""
|
|
79
|
+
return cast(CreateTaskResponse, self._run(ops.tasks_create(body, idempotency_key)))
|
|
80
|
+
|
|
81
|
+
def list(self, **query: Any) -> ListTasksResponse:
|
|
82
|
+
"""One page of tasks, newest first by default. Filters: ``limit``, ``order``, ``cursor``."""
|
|
83
|
+
return cast(ListTasksResponse, self._run(ops.tasks_list(query)))
|
|
84
|
+
|
|
85
|
+
def list_all(self, **query: Any) -> Iterator[TaskListItem]:
|
|
86
|
+
"""Every task, following the cursor page by page."""
|
|
87
|
+
cursor: Optional[str] = None
|
|
88
|
+
while True:
|
|
89
|
+
page = self.list(**{**query, **({"cursor": cursor} if cursor else {})})
|
|
90
|
+
yield from page["data"]
|
|
91
|
+
cursor = page.get("cursor") if page.get("hasMore") else None
|
|
92
|
+
if not cursor:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
def get(self, task_id: str) -> GetTaskResponse:
|
|
96
|
+
"""A task, with your ``rating`` of it and the rider's ``liveEta`` while they are on the way."""
|
|
97
|
+
return cast(GetTaskResponse, self._run(ops.tasks_get(task_id)))
|
|
98
|
+
|
|
99
|
+
def cancel(self, task_id: str, body: Optional[Body] = None) -> CancelTaskResponse:
|
|
100
|
+
return cast(CancelTaskResponse, self._run(ops.tasks_cancel(task_id, body)))
|
|
101
|
+
|
|
102
|
+
def assign(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
103
|
+
"""Offer a manual (``autoAssign: false``) live task to riders from ``available_riders``."""
|
|
104
|
+
return cast(Dict[str, Any], self._run(ops.tasks_assign(task_id, body)))
|
|
105
|
+
|
|
106
|
+
def available_riders(self, task_id: str, **query: Any) -> Dict[str, Any]:
|
|
107
|
+
return cast(Dict[str, Any], self._run(ops.tasks_available_riders(task_id, query)))
|
|
108
|
+
|
|
109
|
+
def rate(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
110
|
+
"""Rate the rider of a completed task, once. Check ``get(...)["rating"]`` first."""
|
|
111
|
+
return cast(Dict[str, Any], self._run(ops.tasks_rate(task_id, body)))
|
|
112
|
+
|
|
113
|
+
def dispute(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
114
|
+
return cast(Dict[str, Any], self._run(ops.tasks_dispute(task_id, body)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Webhooks:
|
|
118
|
+
def __init__(self, run: Any) -> None:
|
|
119
|
+
self._run = run
|
|
120
|
+
|
|
121
|
+
def create(self, body: Body) -> CreateWebhookResponse:
|
|
122
|
+
"""Register an endpoint. Store the returned ``secret``: it is shown only once."""
|
|
123
|
+
return cast(CreateWebhookResponse, self._run(ops.webhooks_create(body)))
|
|
124
|
+
|
|
125
|
+
def list(self) -> ListWebhooksResponse:
|
|
126
|
+
return cast(ListWebhooksResponse, self._run(ops.webhooks_list()))
|
|
127
|
+
|
|
128
|
+
def get(self, webhook_id: str) -> Webhook:
|
|
129
|
+
return cast(Webhook, self._run(ops.webhooks_get(webhook_id)))
|
|
130
|
+
|
|
131
|
+
def update(self, webhook_id: str, body: Body) -> Webhook:
|
|
132
|
+
return cast(Webhook, self._run(ops.webhooks_update(webhook_id, body)))
|
|
133
|
+
|
|
134
|
+
def delete(self, webhook_id: str) -> Webhook:
|
|
135
|
+
return cast(Webhook, self._run(ops.webhooks_delete(webhook_id)))
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def verify_signature(
|
|
139
|
+
payload: str | bytes, signature_header: str, secret: str, tolerance: int = 300
|
|
140
|
+
) -> WebhookSignatureVerification:
|
|
141
|
+
"""Check a delivery's ``X-Relay-Signature`` header against the raw body."""
|
|
142
|
+
return verify_webhook_signature(payload, signature_header, secret, tolerance)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Auth:
|
|
146
|
+
def __init__(self, run: Any) -> None:
|
|
147
|
+
self._run = run
|
|
148
|
+
|
|
149
|
+
def create_websocket_token(self, body: Body) -> WebSocketToken:
|
|
150
|
+
"""A short-lived token for an app to track tasks live. ``scope``: ``["task:<id>", ...]``."""
|
|
151
|
+
return cast(WebSocketToken, self._run(ops.auth_create_websocket_token(body)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Presence:
|
|
155
|
+
def __init__(self, run: Any) -> None:
|
|
156
|
+
self._run = run
|
|
157
|
+
|
|
158
|
+
def get(self, **query: Any) -> PresenceResponse:
|
|
159
|
+
"""Anonymous rider availability around a point: ``latitude``, ``longitude``, ``resolution``."""
|
|
160
|
+
return cast(PresenceResponse, self._run(ops.presence_get(query)))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Relay(_Base):
|
|
164
|
+
"""The Relay server SDK. Keep your API key on your server.
|
|
165
|
+
|
|
166
|
+
Example::
|
|
167
|
+
|
|
168
|
+
from sendrelay import Relay
|
|
169
|
+
|
|
170
|
+
relay = Relay(api_key=os.environ["RELAY_API_KEY"])
|
|
171
|
+
quote = relay.tasks.quote({"taskType": "PACKAGE_DELIVERY", "stages": [...]})
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
api_key: str,
|
|
177
|
+
*,
|
|
178
|
+
base_url: Optional[str] = None,
|
|
179
|
+
api_version: Optional[str] = None,
|
|
180
|
+
timeout: float = 30.0,
|
|
181
|
+
max_retries: Optional[int] = None,
|
|
182
|
+
retry: Optional[RetryConfig] = None,
|
|
183
|
+
auto_idempotency: bool = True,
|
|
184
|
+
headers: Optional[Dict[str, str]] = None,
|
|
185
|
+
http_client: Optional[httpx.Client] = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
self._config = _config(api_key, base_url, api_version, timeout, max_retries, retry, auto_idempotency, headers)
|
|
188
|
+
self._owns_client = http_client is None
|
|
189
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
190
|
+
self.tasks = Tasks(self._request)
|
|
191
|
+
self.webhooks = Webhooks(self._request)
|
|
192
|
+
self.auth = Auth(self._request)
|
|
193
|
+
self.presence = Presence(self._request)
|
|
194
|
+
|
|
195
|
+
def _request(self, request: Request) -> Any:
|
|
196
|
+
return send(self._config, self._http, request)
|
|
197
|
+
|
|
198
|
+
def close(self) -> None:
|
|
199
|
+
if self._owns_client:
|
|
200
|
+
self._http.close()
|
|
201
|
+
|
|
202
|
+
def __enter__(self) -> Relay:
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, *_: object) -> None:
|
|
206
|
+
self.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─── Async ───────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AsyncTasks:
|
|
213
|
+
def __init__(self, run: Any) -> None:
|
|
214
|
+
self._run = run
|
|
215
|
+
|
|
216
|
+
async def quote(self, body: Body) -> TaskQuoteResponse:
|
|
217
|
+
return cast(TaskQuoteResponse, await self._run(ops.tasks_quote(body)))
|
|
218
|
+
|
|
219
|
+
async def create(self, body: Body, *, idempotency_key: Optional[str] = None) -> CreateTaskResponse:
|
|
220
|
+
return cast(CreateTaskResponse, await self._run(ops.tasks_create(body, idempotency_key)))
|
|
221
|
+
|
|
222
|
+
async def list(self, **query: Any) -> ListTasksResponse:
|
|
223
|
+
return cast(ListTasksResponse, await self._run(ops.tasks_list(query)))
|
|
224
|
+
|
|
225
|
+
async def list_all(self, **query: Any) -> AsyncIterator[TaskListItem]:
|
|
226
|
+
cursor: Optional[str] = None
|
|
227
|
+
while True:
|
|
228
|
+
page = await self.list(**{**query, **({"cursor": cursor} if cursor else {})})
|
|
229
|
+
for item in page["data"]:
|
|
230
|
+
yield item
|
|
231
|
+
cursor = page.get("cursor") if page.get("hasMore") else None
|
|
232
|
+
if not cursor:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
async def get(self, task_id: str) -> GetTaskResponse:
|
|
236
|
+
return cast(GetTaskResponse, await self._run(ops.tasks_get(task_id)))
|
|
237
|
+
|
|
238
|
+
async def cancel(self, task_id: str, body: Optional[Body] = None) -> CancelTaskResponse:
|
|
239
|
+
return cast(CancelTaskResponse, await self._run(ops.tasks_cancel(task_id, body)))
|
|
240
|
+
|
|
241
|
+
async def assign(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
242
|
+
return cast(Dict[str, Any], await self._run(ops.tasks_assign(task_id, body)))
|
|
243
|
+
|
|
244
|
+
async def available_riders(self, task_id: str, **query: Any) -> Dict[str, Any]:
|
|
245
|
+
return cast(Dict[str, Any], await self._run(ops.tasks_available_riders(task_id, query)))
|
|
246
|
+
|
|
247
|
+
async def rate(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
248
|
+
return cast(Dict[str, Any], await self._run(ops.tasks_rate(task_id, body)))
|
|
249
|
+
|
|
250
|
+
async def dispute(self, task_id: str, body: Body) -> Dict[str, Any]:
|
|
251
|
+
return cast(Dict[str, Any], await self._run(ops.tasks_dispute(task_id, body)))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class AsyncWebhooks:
|
|
255
|
+
def __init__(self, run: Any) -> None:
|
|
256
|
+
self._run = run
|
|
257
|
+
|
|
258
|
+
async def create(self, body: Body) -> CreateWebhookResponse:
|
|
259
|
+
return cast(CreateWebhookResponse, await self._run(ops.webhooks_create(body)))
|
|
260
|
+
|
|
261
|
+
async def list(self) -> ListWebhooksResponse:
|
|
262
|
+
return cast(ListWebhooksResponse, await self._run(ops.webhooks_list()))
|
|
263
|
+
|
|
264
|
+
async def get(self, webhook_id: str) -> Webhook:
|
|
265
|
+
return cast(Webhook, await self._run(ops.webhooks_get(webhook_id)))
|
|
266
|
+
|
|
267
|
+
async def update(self, webhook_id: str, body: Body) -> Webhook:
|
|
268
|
+
return cast(Webhook, await self._run(ops.webhooks_update(webhook_id, body)))
|
|
269
|
+
|
|
270
|
+
async def delete(self, webhook_id: str) -> Webhook:
|
|
271
|
+
return cast(Webhook, await self._run(ops.webhooks_delete(webhook_id)))
|
|
272
|
+
|
|
273
|
+
verify_signature = staticmethod(Webhooks.verify_signature)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class AsyncAuth:
|
|
277
|
+
def __init__(self, run: Any) -> None:
|
|
278
|
+
self._run = run
|
|
279
|
+
|
|
280
|
+
async def create_websocket_token(self, body: Body) -> WebSocketToken:
|
|
281
|
+
return cast(WebSocketToken, await self._run(ops.auth_create_websocket_token(body)))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class AsyncPresence:
|
|
285
|
+
def __init__(self, run: Any) -> None:
|
|
286
|
+
self._run = run
|
|
287
|
+
|
|
288
|
+
async def get(self, **query: Any) -> PresenceResponse:
|
|
289
|
+
return cast(PresenceResponse, await self._run(ops.presence_get(query)))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class AsyncRelay(_Base):
|
|
293
|
+
"""The Relay server SDK for asyncio. Same methods as ``Relay``, awaited.
|
|
294
|
+
|
|
295
|
+
Example::
|
|
296
|
+
|
|
297
|
+
async with AsyncRelay(api_key=os.environ["RELAY_API_KEY"]) as relay:
|
|
298
|
+
task = await relay.tasks.get(task_id)
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
api_key: str,
|
|
304
|
+
*,
|
|
305
|
+
base_url: Optional[str] = None,
|
|
306
|
+
api_version: Optional[str] = None,
|
|
307
|
+
timeout: float = 30.0,
|
|
308
|
+
max_retries: Optional[int] = None,
|
|
309
|
+
retry: Optional[RetryConfig] = None,
|
|
310
|
+
auto_idempotency: bool = True,
|
|
311
|
+
headers: Optional[Dict[str, str]] = None,
|
|
312
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
self._config = _config(api_key, base_url, api_version, timeout, max_retries, retry, auto_idempotency, headers)
|
|
315
|
+
self._owns_client = http_client is None
|
|
316
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
317
|
+
self.tasks = AsyncTasks(self._request)
|
|
318
|
+
self.webhooks = AsyncWebhooks(self._request)
|
|
319
|
+
self.auth = AsyncAuth(self._request)
|
|
320
|
+
self.presence = AsyncPresence(self._request)
|
|
321
|
+
|
|
322
|
+
async def _request(self, request: Request) -> Any:
|
|
323
|
+
return await send_async(self._config, self._http, request)
|
|
324
|
+
|
|
325
|
+
async def close(self) -> None:
|
|
326
|
+
if self._owns_client:
|
|
327
|
+
await self._http.aclose()
|
|
328
|
+
|
|
329
|
+
async def __aenter__(self) -> AsyncRelay:
|
|
330
|
+
return self
|
|
331
|
+
|
|
332
|
+
async def __aexit__(self, *_: object) -> None:
|
|
333
|
+
await self.close()
|
sendrelay/_errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RelayError(Exception):
|
|
7
|
+
"""Base class for every error this SDK raises."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(RelayError):
|
|
15
|
+
"""The API answered with an error status.
|
|
16
|
+
|
|
17
|
+
``code`` is the API's error code (for example ``NOT_FOUND`` or ``CONFLICT``) and ``details``
|
|
18
|
+
carries anything extra the API sent, such as validation problems.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status_code: int, code: str, message: str, details: Any = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.code = code
|
|
25
|
+
self.details = details
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f"ApiError(status_code={self.status_code}, code={self.code!r}, message={self.message!r})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NetworkError(RelayError):
|
|
32
|
+
"""The request never got an answer: the connection failed or timed out."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.__cause__ = cause
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ValidationError(RelayError):
|
|
40
|
+
"""The SDK rejected the input before sending anything."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, field: Optional[str] = None) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.field = field
|
sendrelay/_http.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""HTTP plumbing shared by the sync and async clients: request building, retries and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._errors import ApiError, NetworkError
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.sendrelay.com.ng"
|
|
19
|
+
DEFAULT_API_VERSION = "v1"
|
|
20
|
+
RETRYABLE_STATUS_CODES: Tuple[int, ...] = (408, 429, 500, 502, 503, 504)
|
|
21
|
+
# Test-mode quota 429s last minutes to hours; retrying can't succeed, so surface them at once.
|
|
22
|
+
NON_RETRYABLE_ERROR_CODES = frozenset({"TEST_QUOTA_EXCEEDED", "TEST_CONCURRENCY_EXCEEDED"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RetryConfig:
|
|
27
|
+
"""How failed requests are retried. Delays are in seconds."""
|
|
28
|
+
|
|
29
|
+
enabled: bool = True
|
|
30
|
+
max_retries: int = 3
|
|
31
|
+
initial_delay: float = 0.5
|
|
32
|
+
max_delay: float = 10.0
|
|
33
|
+
backoff_multiplier: float = 2.0
|
|
34
|
+
retryable_status_codes: Tuple[int, ...] = RETRYABLE_STATUS_CODES
|
|
35
|
+
|
|
36
|
+
def delay(self, attempt: int) -> float:
|
|
37
|
+
base = min(self.initial_delay * self.backoff_multiplier**attempt, self.max_delay)
|
|
38
|
+
# ±25% jitter so many clients don't retry in lockstep.
|
|
39
|
+
return max(0.0, base + base * 0.25 * (random.random() * 2 - 1))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Request:
|
|
44
|
+
method: str
|
|
45
|
+
path: str
|
|
46
|
+
body: Any = None
|
|
47
|
+
query: Optional[Mapping[str, Any]] = None
|
|
48
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Config:
|
|
53
|
+
api_key: str
|
|
54
|
+
base_url: str = DEFAULT_BASE_URL
|
|
55
|
+
api_version: str = DEFAULT_API_VERSION
|
|
56
|
+
timeout: float = 30.0
|
|
57
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
58
|
+
auto_idempotency: bool = True
|
|
59
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _query_value(value: Any) -> str:
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return "true" if value else "false"
|
|
65
|
+
return str(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build(config: Config, request: Request) -> httpx.Request:
|
|
69
|
+
url = f"{config.base_url.rstrip('/')}/{config.api_version}{request.path}"
|
|
70
|
+
params = {k: _query_value(v) for k, v in (request.query or {}).items() if v is not None}
|
|
71
|
+
headers = {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"X-Relay-Key": config.api_key,
|
|
74
|
+
"User-Agent": f"sendrelay-python/{__version__}",
|
|
75
|
+
**config.headers,
|
|
76
|
+
**request.headers,
|
|
77
|
+
}
|
|
78
|
+
if request.method == "POST" and config.auto_idempotency and "Idempotency-Key" not in headers:
|
|
79
|
+
headers["Idempotency-Key"] = f"sdk-{int(time.time() * 1000)}-{secrets.token_hex(16)}"
|
|
80
|
+
content = json.dumps(request.body).encode() if request.body is not None else None
|
|
81
|
+
return httpx.Request(request.method, url, params=params, headers=headers, content=content)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def api_error(response: httpx.Response) -> ApiError:
|
|
85
|
+
try:
|
|
86
|
+
body = response.json()
|
|
87
|
+
except ValueError:
|
|
88
|
+
body = {}
|
|
89
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
90
|
+
error = error if isinstance(error, dict) else {}
|
|
91
|
+
return ApiError(
|
|
92
|
+
response.status_code,
|
|
93
|
+
error.get("code") or "UNKNOWN_ERROR",
|
|
94
|
+
error.get("message") or response.reason_phrase or "Request failed",
|
|
95
|
+
error.get("details"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def retryable(config: Config, error: ApiError, attempt: int) -> bool:
|
|
100
|
+
return (
|
|
101
|
+
config.retry.enabled
|
|
102
|
+
and attempt < config.retry.max_retries
|
|
103
|
+
and error.code not in NON_RETRYABLE_ERROR_CODES
|
|
104
|
+
and error.status_code in config.retry.retryable_status_codes
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def network_error(config: Config, cause: httpx.TransportError) -> NetworkError:
|
|
109
|
+
if isinstance(cause, httpx.TimeoutException):
|
|
110
|
+
return NetworkError(f"Request timeout after {config.timeout}s", cause)
|
|
111
|
+
return NetworkError("Network error - connection failed", cause)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def send(config: Config, client: httpx.Client, request: Request) -> Any:
|
|
115
|
+
attempt = 0
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
response = client.send(build(config, request))
|
|
119
|
+
except httpx.TransportError as exc:
|
|
120
|
+
if config.retry.enabled and attempt < config.retry.max_retries:
|
|
121
|
+
time.sleep(config.retry.delay(attempt))
|
|
122
|
+
attempt += 1
|
|
123
|
+
continue
|
|
124
|
+
raise network_error(config, exc) from exc
|
|
125
|
+
if response.is_success:
|
|
126
|
+
return response.json()
|
|
127
|
+
error = api_error(response)
|
|
128
|
+
if retryable(config, error, attempt):
|
|
129
|
+
time.sleep(config.retry.delay(attempt))
|
|
130
|
+
attempt += 1
|
|
131
|
+
continue
|
|
132
|
+
raise error
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def send_async(config: Config, client: httpx.AsyncClient, request: Request) -> Any:
|
|
136
|
+
attempt = 0
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
response = await client.send(build(config, request))
|
|
140
|
+
except httpx.TransportError as exc:
|
|
141
|
+
if config.retry.enabled and attempt < config.retry.max_retries:
|
|
142
|
+
await asyncio.sleep(config.retry.delay(attempt))
|
|
143
|
+
attempt += 1
|
|
144
|
+
continue
|
|
145
|
+
raise network_error(config, exc) from exc
|
|
146
|
+
if response.is_success:
|
|
147
|
+
return response.json()
|
|
148
|
+
error = api_error(response)
|
|
149
|
+
if retryable(config, error, attempt):
|
|
150
|
+
await asyncio.sleep(config.retry.delay(attempt))
|
|
151
|
+
attempt += 1
|
|
152
|
+
continue
|
|
153
|
+
raise error
|
sendrelay/_operations.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Every API operation, defined once as a request. Sync and async resources both use these."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ._errors import ValidationError
|
|
9
|
+
from ._http import Request
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _id(value: str, name: str) -> str:
|
|
13
|
+
if not value:
|
|
14
|
+
raise ValidationError(f"{name} is required", name)
|
|
15
|
+
return quote(value, safe="")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tasks_quote(body: Mapping[str, Any]) -> Request:
|
|
19
|
+
return Request("POST", "/sdk/tasks/quote", body=dict(body))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tasks_create(body: Mapping[str, Any], idempotency_key: Optional[str]) -> Request:
|
|
23
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
24
|
+
return Request("POST", "/sdk/tasks", body=dict(body), headers=headers)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tasks_list(query: Optional[Mapping[str, Any]]) -> Request:
|
|
28
|
+
return Request("GET", "/sdk/tasks", query=query)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tasks_get(task_id: str) -> Request:
|
|
32
|
+
return Request("GET", f"/sdk/tasks/{_id(task_id, 'task_id')}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tasks_cancel(task_id: str, body: Optional[Mapping[str, Any]]) -> Request:
|
|
36
|
+
return Request("DELETE", f"/sdk/tasks/{_id(task_id, 'task_id')}/cancel", body=dict(body) if body else None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tasks_assign(task_id: str, body: Mapping[str, Any]) -> Request:
|
|
40
|
+
return Request("POST", f"/sdk/tasks/{_id(task_id, 'task_id')}/assign", body=dict(body))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tasks_available_riders(task_id: str, query: Optional[Mapping[str, Any]]) -> Request:
|
|
44
|
+
return Request("GET", f"/sdk/tasks/{_id(task_id, 'task_id')}/available-riders", query=query)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tasks_rate(task_id: str, body: Mapping[str, Any]) -> Request:
|
|
48
|
+
return Request("POST", f"/sdk/tasks/{_id(task_id, 'task_id')}/ratings", body=dict(body))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tasks_dispute(task_id: str, body: Mapping[str, Any]) -> Request:
|
|
52
|
+
return Request("POST", f"/sdk/tasks/{_id(task_id, 'task_id')}/dispute", body=dict(body))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def webhooks_create(body: Mapping[str, Any]) -> Request:
|
|
56
|
+
return Request("POST", "/sdk/webhooks", body=dict(body))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def webhooks_list() -> Request:
|
|
60
|
+
return Request("GET", "/sdk/webhooks")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def webhooks_get(webhook_id: str) -> Request:
|
|
64
|
+
return Request("GET", f"/sdk/webhooks/{_id(webhook_id, 'webhook_id')}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def webhooks_update(webhook_id: str, body: Mapping[str, Any]) -> Request:
|
|
68
|
+
return Request("PUT", f"/sdk/webhooks/{_id(webhook_id, 'webhook_id')}", body=dict(body))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def webhooks_delete(webhook_id: str) -> Request:
|
|
72
|
+
return Request("DELETE", f"/sdk/webhooks/{_id(webhook_id, 'webhook_id')}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def auth_create_websocket_token(body: Mapping[str, Any]) -> Request:
|
|
76
|
+
scope = list(body.get("scope") or [])
|
|
77
|
+
if not scope:
|
|
78
|
+
raise ValidationError("scope must contain at least one resource", "scope")
|
|
79
|
+
for resource in scope:
|
|
80
|
+
parts = str(resource).split(":")
|
|
81
|
+
if len(parts) != 2:
|
|
82
|
+
raise ValidationError(
|
|
83
|
+
f'Invalid scope format: "{resource}". Expected "type:id" (e.g., "task:abc123")', "scope"
|
|
84
|
+
)
|
|
85
|
+
kind, ident = parts
|
|
86
|
+
if not kind or not ident:
|
|
87
|
+
raise ValidationError(f'Invalid scope format: "{resource}". Both type and id must be non-empty', "scope")
|
|
88
|
+
if kind == "task" and len(ident.split(",")) > 5:
|
|
89
|
+
raise ValidationError(f'Scoped tasks cannot exceed 5 per scope entry: "{resource}"', "scope")
|
|
90
|
+
expires_in = body.get("expiresIn")
|
|
91
|
+
if expires_in is not None and not 60 <= expires_in <= 7200:
|
|
92
|
+
raise ValidationError("expiresIn must be between 60 and 7200 seconds", "expiresIn")
|
|
93
|
+
return Request(
|
|
94
|
+
"POST",
|
|
95
|
+
"/auth/websocket-token",
|
|
96
|
+
body={"scope": scope, "expiresIn": expires_in if expires_in is not None else 1800},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def presence_get(query: Mapping[str, Any]) -> Request:
|
|
101
|
+
return Request("GET", "/sdk/presence", query=query)
|
sendrelay/_version.py
ADDED
sendrelay/_webhooks.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class WebhookSignatureVerification:
|
|
14
|
+
valid: bool
|
|
15
|
+
error: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
def __bool__(self) -> bool:
|
|
18
|
+
return self.valid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_webhook_signature(
|
|
22
|
+
payload: str | bytes,
|
|
23
|
+
signature_header: str,
|
|
24
|
+
secret: str,
|
|
25
|
+
tolerance: int = 300,
|
|
26
|
+
) -> WebhookSignatureVerification:
|
|
27
|
+
"""Check that a webhook came from Relay and wasn't changed on the way.
|
|
28
|
+
|
|
29
|
+
Pass the raw request body (exactly as received), the ``X-Relay-Signature`` header and the
|
|
30
|
+
``whsec_...`` secret you got when you created the webhook. ``tolerance`` is how old, in seconds,
|
|
31
|
+
a delivery may be.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
parts = signature_header.split(",")
|
|
35
|
+
timestamp_part = next((p for p in parts if p.startswith("t=")), None)
|
|
36
|
+
signature_part = next((p for p in parts if p.startswith("v1=")), None)
|
|
37
|
+
if timestamp_part is None or signature_part is None:
|
|
38
|
+
return WebhookSignatureVerification(False, "Invalid signature format")
|
|
39
|
+
|
|
40
|
+
timestamp = int(timestamp_part.split("=", 1)[1])
|
|
41
|
+
signature = signature_part.split("=", 1)[1]
|
|
42
|
+
if abs(int(time.time()) - timestamp) > tolerance:
|
|
43
|
+
return WebhookSignatureVerification(False, "Timestamp outside tolerance window")
|
|
44
|
+
|
|
45
|
+
# The key is everything after `whsec_` (mode prefix included), base64url-decoded.
|
|
46
|
+
key_text = secret.replace("whsec_", "", 1)
|
|
47
|
+
key = base64.urlsafe_b64decode(key_text + "=" * (-len(key_text) % 4))
|
|
48
|
+
body = payload if isinstance(payload, str) else payload.decode("utf-8")
|
|
49
|
+
expected = hmac.new(key, f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
50
|
+
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
|
51
|
+
return WebhookSignatureVerification(False, "Signature verification failed")
|
|
52
|
+
return WebhookSignatureVerification(True)
|
|
53
|
+
except (ValueError, binascii.Error, UnicodeDecodeError) as exc:
|
|
54
|
+
return WebhookSignatureVerification(False, str(exc))
|
sendrelay/py.typed
ADDED
|
File without changes
|
sendrelay/types.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Request and response shapes. Responses are plain dicts with the API's camelCase keys; these
|
|
2
|
+
TypedDicts describe them for editors and type checkers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from typing_extensions import NotRequired, TypedDict
|
|
9
|
+
|
|
10
|
+
TaskType = Literal["PASSENGER_RIDE", "FOOD_DELIVERY", "PACKAGE_DELIVERY", "BULK_DELIVERY", "ERRAND"]
|
|
11
|
+
TaskStatus = Literal[
|
|
12
|
+
"PENDING", "OFFERED", "ASSIGNED", "IN_PROGRESS", "COMPLETED", "CANCELLED", "FAILED", "PAYMENT_FAILED"
|
|
13
|
+
]
|
|
14
|
+
Priority = Literal["STANDARD", "URGENT"]
|
|
15
|
+
StageType = Literal["PICKUP", "DROPOFF"]
|
|
16
|
+
Weight = Literal["LIGHT", "STANDARD", "HEAVY", "FREIGHT"]
|
|
17
|
+
Size = Literal["ENVELOPE", "BOX", "LARGE_BOX", "FREIGHT"]
|
|
18
|
+
VehicleType = Literal["BICYCLE", "BIKE", "MOTORCYCLE", "TRICYCLE", "CAR", "VAN", "TRUCK"]
|
|
19
|
+
WebhookMode = Literal["test", "live"]
|
|
20
|
+
PresenceResolution = Literal["BLOCK", "NEIGHBOURHOOD", "DISTRICT"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Location(TypedDict):
|
|
24
|
+
latitude: float
|
|
25
|
+
longitude: float
|
|
26
|
+
address: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Item(TypedDict):
|
|
30
|
+
name: str
|
|
31
|
+
estimatedValue: int
|
|
32
|
+
estimatedWeight: Weight
|
|
33
|
+
estimatedSize: Size
|
|
34
|
+
description: NotRequired[str]
|
|
35
|
+
specialInstructions: NotRequired[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Passenger(TypedDict):
|
|
39
|
+
name: str
|
|
40
|
+
contactInfo: NotRequired[str]
|
|
41
|
+
specialNeeds: NotRequired[str]
|
|
42
|
+
luggageCount: NotRequired[int]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Stage(TypedDict):
|
|
46
|
+
type: StageType
|
|
47
|
+
location: Location
|
|
48
|
+
instructions: NotRequired[str]
|
|
49
|
+
items: NotRequired[List[Item]]
|
|
50
|
+
passengers: NotRequired[List[Passenger]]
|
|
51
|
+
status: NotRequired[str]
|
|
52
|
+
completedAt: NotRequired[Optional[str]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Preferences(TypedDict, total=False):
|
|
56
|
+
minRating: float
|
|
57
|
+
hasBox: bool
|
|
58
|
+
hasRefrigeration: bool
|
|
59
|
+
hasAirConditioning: bool
|
|
60
|
+
vehicleType: List[VehicleType]
|
|
61
|
+
allowedOrganisations: List[str]
|
|
62
|
+
blockedOrganisations: List[str]
|
|
63
|
+
allowIndependentRiders: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TaskQuoteRequest(TypedDict):
|
|
67
|
+
taskType: TaskType
|
|
68
|
+
stages: List[Stage]
|
|
69
|
+
priority: NotRequired[Priority]
|
|
70
|
+
preferences: NotRequired[Preferences]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CreateTaskRequest(TaskQuoteRequest):
|
|
74
|
+
autoAssign: NotRequired[bool]
|
|
75
|
+
paymentMethod: NotRequired[Literal["WALLET", "CARD"]]
|
|
76
|
+
notes: NotRequired[str]
|
|
77
|
+
externalId: NotRequired[str]
|
|
78
|
+
quoteId: NotRequired[str]
|
|
79
|
+
completionCode: NotRequired[Dict[str, Any]]
|
|
80
|
+
simulationOutcome: NotRequired[str]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TaskLiveEta(TypedDict):
|
|
84
|
+
"""How far the rider is from the stop they are heading to, from their navigation route."""
|
|
85
|
+
|
|
86
|
+
stageIndex: int
|
|
87
|
+
seconds: int
|
|
88
|
+
meters: int
|
|
89
|
+
arrivesAt: str
|
|
90
|
+
updatedAt: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TaskRating(TypedDict):
|
|
94
|
+
rating: int
|
|
95
|
+
comment: Optional[str]
|
|
96
|
+
createdAt: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Task(TypedDict):
|
|
100
|
+
id: str
|
|
101
|
+
developerId: str
|
|
102
|
+
riderId: Optional[str]
|
|
103
|
+
externalId: Optional[str]
|
|
104
|
+
status: TaskStatus
|
|
105
|
+
taskType: TaskType
|
|
106
|
+
priority: Priority
|
|
107
|
+
autoAssign: bool
|
|
108
|
+
currentStageIndex: Optional[int]
|
|
109
|
+
stages: List[Stage]
|
|
110
|
+
deliveryFee: int
|
|
111
|
+
totalFee: int
|
|
112
|
+
paymentMethod: str
|
|
113
|
+
paymentStatus: Optional[str]
|
|
114
|
+
createdAt: str
|
|
115
|
+
updatedAt: str
|
|
116
|
+
completionCodeRequired: bool
|
|
117
|
+
completionCodeLength: Optional[int]
|
|
118
|
+
rider: NotRequired[Dict[str, Any]]
|
|
119
|
+
completionCode: NotRequired[Optional[Dict[str, Any]]]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class GetTaskResponse(TypedDict):
|
|
123
|
+
task: Task
|
|
124
|
+
rating: Optional[TaskRating]
|
|
125
|
+
liveEta: Optional[TaskLiveEta]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TaskListItem(TypedDict):
|
|
129
|
+
id: str
|
|
130
|
+
status: TaskStatus
|
|
131
|
+
taskType: TaskType
|
|
132
|
+
totalFee: int
|
|
133
|
+
createdAt: str
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ListTasksResponse(TypedDict):
|
|
137
|
+
data: List[TaskListItem]
|
|
138
|
+
hasMore: bool
|
|
139
|
+
cursor: Optional[str]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class CreateTaskResponse(TypedDict):
|
|
143
|
+
message: str
|
|
144
|
+
task: Task
|
|
145
|
+
pricing: Dict[str, Any]
|
|
146
|
+
idempotency: Dict[str, Any]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TaskQuoteResponse(TypedDict):
|
|
150
|
+
total: int
|
|
151
|
+
subtotal: int
|
|
152
|
+
tax: int
|
|
153
|
+
platformFee: int
|
|
154
|
+
quoteId: str
|
|
155
|
+
expiresAt: str
|
|
156
|
+
quoteTtlSeconds: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CancelTaskResponse(TypedDict):
|
|
160
|
+
message: str
|
|
161
|
+
taskId: str
|
|
162
|
+
status: Literal["CANCELLED"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Webhook(TypedDict):
|
|
166
|
+
id: str
|
|
167
|
+
url: str
|
|
168
|
+
mode: WebhookMode
|
|
169
|
+
events: List[str]
|
|
170
|
+
createdAt: str
|
|
171
|
+
usage: Dict[str, Any]
|
|
172
|
+
description: NotRequired[str]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CreateWebhookResponse(TypedDict):
|
|
176
|
+
id: str
|
|
177
|
+
url: str
|
|
178
|
+
mode: WebhookMode
|
|
179
|
+
events: List[str]
|
|
180
|
+
secret: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ListWebhooksResponse(TypedDict):
|
|
184
|
+
data: List[Webhook]
|
|
185
|
+
hasMore: bool
|
|
186
|
+
cursor: Optional[str]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class WebSocketToken(TypedDict):
|
|
190
|
+
token: str
|
|
191
|
+
expiresIn: int
|
|
192
|
+
expiresAt: str
|
|
193
|
+
scope: List[str]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PresenceResponse(TypedDict):
|
|
197
|
+
mode: WebhookMode
|
|
198
|
+
simulated: bool
|
|
199
|
+
resolution: PresenceResolution
|
|
200
|
+
cells: List[Dict[str, Any]]
|
|
201
|
+
totals: Dict[str, Any]
|
|
202
|
+
generatedAt: str
|
|
203
|
+
refreshAfterSeconds: int
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sendrelay
|
|
3
|
+
Version: 1.7.0
|
|
4
|
+
Summary: Official Relay server SDK for Python: quotes, deliveries, webhooks and tracking tokens
|
|
5
|
+
Project-URL: Homepage, https://sendrelay.com.ng
|
|
6
|
+
Project-URL: Documentation, https://docs.sendrelay.com.ng/sdks/python-sdk
|
|
7
|
+
Project-URL: Source, https://github.com/SendRelay/relay-sdk-python
|
|
8
|
+
Author: SendRelay
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,delivery,dispatch,logistics,relay,sdk
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: httpx<1,>=0.25
|
|
20
|
+
Requires-Dist: typing-extensions>=4.7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Relay Python SDK
|
|
31
|
+
|
|
32
|
+
The official server SDK for [Relay](https://sendrelay.com.ng), the delivery API. Quote and create deliveries, read them back with the rider's live ETA, manage webhooks, and mint tracking tokens for your apps.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install sendrelay
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.9 or newer. Use `Relay` in regular code and `AsyncRelay` with asyncio. Both have the same methods.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from sendrelay import Relay
|
|
45
|
+
|
|
46
|
+
relay = Relay(api_key=os.environ["RELAY_API_KEY"]) # sk_test_... or sk_live_..., server only
|
|
47
|
+
|
|
48
|
+
stages = [
|
|
49
|
+
{
|
|
50
|
+
"type": "PICKUP",
|
|
51
|
+
"location": {"latitude": 6.5244, "longitude": 3.3792, "address": "12 Marina Road, Lagos"},
|
|
52
|
+
"items": [{"name": "Phone case", "estimatedValue": 500000, "estimatedWeight": "LIGHT", "estimatedSize": "BOX"}],
|
|
53
|
+
},
|
|
54
|
+
{"type": "DROPOFF", "location": {"latitude": 6.4474, "longitude": 3.4723, "address": "22 Admiralty Way, Lekki"}},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
quote = relay.tasks.quote({"taskType": "PACKAGE_DELIVERY", "stages": stages})
|
|
58
|
+
print(quote["total"]) # kobo
|
|
59
|
+
|
|
60
|
+
created = relay.tasks.create(
|
|
61
|
+
{"taskType": "PACKAGE_DELIVERY", "stages": stages, "quoteId": quote["quoteId"]},
|
|
62
|
+
idempotency_key="order-1042", # retries with the same key never create a second delivery
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
read = relay.tasks.get(created["task"]["id"])
|
|
66
|
+
if read["liveEta"]:
|
|
67
|
+
print(f"Arriving at stop {read['liveEta']['stageIndex'] + 1} at {read['liveEta']['arrivesAt']}")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Responses are plain dicts with the API's field names (`liveEta`, `totalFee`, ...). `sendrelay.types` has TypedDicts for them.
|
|
71
|
+
|
|
72
|
+
## Asyncio
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from sendrelay import AsyncRelay
|
|
76
|
+
|
|
77
|
+
async with AsyncRelay(api_key=os.environ["RELAY_API_KEY"]) as relay:
|
|
78
|
+
async for task in relay.tasks.list_all(limit=50):
|
|
79
|
+
print(task["id"], task["status"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Webhooks
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from sendrelay import verify_webhook_signature
|
|
86
|
+
|
|
87
|
+
result = verify_webhook_signature(raw_body, request.headers["X-Relay-Signature"], os.environ["RELAY_WEBHOOK_SECRET"])
|
|
88
|
+
if not result.valid:
|
|
89
|
+
return 400
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pass the raw request body exactly as received, not re-serialized JSON.
|
|
93
|
+
|
|
94
|
+
## Errors and retries
|
|
95
|
+
|
|
96
|
+
- `ApiError` has `status_code`, `code` and `details` from the API.
|
|
97
|
+
- `NetworkError` means the request never got an answer.
|
|
98
|
+
- `ValidationError` means the SDK rejected the input before sending.
|
|
99
|
+
|
|
100
|
+
Timeouts, 408, 429 and 5xx responses are retried up to 3 times with exponential backoff. Test-mode quota errors are not retried. Change this with `max_retries=` or `retry=RetryConfig(...)`.
|
|
101
|
+
|
|
102
|
+
## Tracking in your app
|
|
103
|
+
|
|
104
|
+
Mint a short-lived token on your server and hand it to your app:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
token = relay.auth.create_websocket_token({"scope": [f"task:{task_id}"]})["token"]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Your app then tracks the task live with one of the UI SDKs.
|
|
111
|
+
|
|
112
|
+
[Full documentation](https://docs.sendrelay.com.ng/sdks/python-sdk)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
sendrelay/__init__.py,sha256=S4zu9CoQxrZdYuEMa1OmHW_Q3ho6cgb9Bj8-ZJSF6Vo,721
|
|
2
|
+
sendrelay/_client.py,sha256=UZnhzdfPQPzu_I46JNslPs290RlwGbGFR92IH_uAKrk,12856
|
|
3
|
+
sendrelay/_errors.py,sha256=qB4QicE84LEwi5jNXBqmywZ1hkMuA7sJcm8mE3nlaII,1381
|
|
4
|
+
sendrelay/_http.py,sha256=ItWgECH8CiOEi10YGwMxmIoywZexKizCbIgZXj3x_qg,5300
|
|
5
|
+
sendrelay/_operations.py,sha256=OJLf2EpKehQ0C_e63m8rng4gvkF9FzbRSiBnScV3Gwo,3777
|
|
6
|
+
sendrelay/_version.py,sha256=MUtFtY_uMM8ayUww-tcJpB-VoFH1XLxZ-mXE73QznxE,124
|
|
7
|
+
sendrelay/_webhooks.py,sha256=ZaURo8EcbdcWHosHM4G9lCVuuY6HblJrUs1I1T37Zsk,2151
|
|
8
|
+
sendrelay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
sendrelay/types.py,sha256=c-jr4V1znX2QEadx3Ak0joZzCEo9dD1qo0E_4HWA7Fo,4749
|
|
10
|
+
sendrelay-1.7.0.dist-info/METADATA,sha256=rCIV8lv7CZMI23D68zqSUF4XU0bONUyVtrB7SinYTTw,3927
|
|
11
|
+
sendrelay-1.7.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
sendrelay-1.7.0.dist-info/licenses/LICENSE,sha256=1iFLFtAqcG9V0jaJGO6BN1Yc0M9qeyJPpj36q27V0Hw,1071
|
|
13
|
+
sendrelay-1.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Relay Delivery
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|