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 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
@@ -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
@@ -0,0 +1,2 @@
1
+ #: The published package version. Kept in sync with pyproject.toml by scripts/check-sdk-versions.mjs.
2
+ __version__ = "1.7.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.