otok 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
otok/__init__.py ADDED
@@ -0,0 +1,187 @@
1
+ """Official Python SDK for the oToK marketing platform public API (/v1)."""
2
+
3
+ from ._http import (
4
+ DEFAULT_BASE_URL,
5
+ HttpClient,
6
+ Transport,
7
+ TransportRequest,
8
+ TransportResponse,
9
+ UrllibTransport,
10
+ compute_backoff_ms,
11
+ )
12
+ from ._version import __version__
13
+ from .client import OtokClient
14
+ from .commerce import (
15
+ CommerceAddress,
16
+ CommerceApi,
17
+ CommerceCustomer,
18
+ CommerceOrder,
19
+ CommerceReceipt,
20
+ TrackOrderResult,
21
+ customer_to_contact_params,
22
+ order_external_reference,
23
+ order_receipt_idempotency_key,
24
+ )
25
+ from .errors import OtokAPIError, OtokTimeoutError, OtokWebhookVerificationError
26
+ from .types import (
27
+ EMAIL_WEBHOOK_EVENT_TYPES,
28
+ Booking,
29
+ BookingCreateParams,
30
+ BookingInvitee,
31
+ BookingListParams,
32
+ BookingReassignParams,
33
+ BookingRescheduleParams,
34
+ BookingStatus,
35
+ Campaign,
36
+ CampaignCreateParams,
37
+ CampaignUpdateParams,
38
+ Contact,
39
+ ContactGroup,
40
+ ContactGroupCreateParams,
41
+ ContactGroupUpdateParams,
42
+ ContactSource,
43
+ ContactUpsertParams,
44
+ Deal,
45
+ DealCreateParams,
46
+ DealListParams,
47
+ DealMoveStageParams,
48
+ DealSetStatusParams,
49
+ DealStatus,
50
+ DealUpdateParams,
51
+ EmailBouncedEvent,
52
+ EmailClickedEvent,
53
+ EmailComplainedEvent,
54
+ EmailDeliveredEvent,
55
+ EmailFailedEvent,
56
+ EmailOpenedEvent,
57
+ EmailSendParams,
58
+ EmailSendResult,
59
+ EmailTracking,
60
+ EmailWebhookEventType,
61
+ LifecycleStage,
62
+ ListParams,
63
+ MeetingType,
64
+ MessageTemplate,
65
+ Note,
66
+ OtokWebhookEvent,
67
+ Paginated,
68
+ Payment,
69
+ PaymentCreateParams,
70
+ PaymentEntryStatus,
71
+ PaymentInterval,
72
+ PaymentListParams,
73
+ PaymentMethod,
74
+ PaymentRefundParams,
75
+ PaymentType,
76
+ PaymentUpdateParams,
77
+ Pipeline,
78
+ PipelineStage,
79
+ SlotsParams,
80
+ Tag,
81
+ TagCreateParams,
82
+ TagUpdateParams,
83
+ TemplateSendParams,
84
+ WebhookEndpoint,
85
+ WebhookEndpointCreated,
86
+ WebhookEndpointCreateParams,
87
+ WebhookEndpointList,
88
+ )
89
+ from .webhooks import (
90
+ DEFAULT_TOLERANCE_SECONDS,
91
+ ParsedSignatureHeader,
92
+ compute_webhook_signature,
93
+ construct_event,
94
+ parse_signature_header,
95
+ verify_webhook_signature,
96
+ )
97
+
98
+ __all__ = [
99
+ "DEFAULT_BASE_URL",
100
+ "DEFAULT_TOLERANCE_SECONDS",
101
+ "EMAIL_WEBHOOK_EVENT_TYPES",
102
+ "Booking",
103
+ "BookingCreateParams",
104
+ "BookingInvitee",
105
+ "BookingListParams",
106
+ "BookingReassignParams",
107
+ "BookingRescheduleParams",
108
+ "BookingStatus",
109
+ "Campaign",
110
+ "CampaignCreateParams",
111
+ "CampaignUpdateParams",
112
+ "CommerceAddress",
113
+ "CommerceApi",
114
+ "CommerceCustomer",
115
+ "CommerceOrder",
116
+ "CommerceReceipt",
117
+ "Contact",
118
+ "ContactGroup",
119
+ "ContactGroupCreateParams",
120
+ "ContactGroupUpdateParams",
121
+ "ContactSource",
122
+ "ContactUpsertParams",
123
+ "Deal",
124
+ "DealCreateParams",
125
+ "DealListParams",
126
+ "DealMoveStageParams",
127
+ "DealSetStatusParams",
128
+ "DealStatus",
129
+ "DealUpdateParams",
130
+ "EmailBouncedEvent",
131
+ "EmailClickedEvent",
132
+ "EmailComplainedEvent",
133
+ "EmailDeliveredEvent",
134
+ "EmailFailedEvent",
135
+ "EmailOpenedEvent",
136
+ "EmailSendParams",
137
+ "EmailSendResult",
138
+ "EmailTracking",
139
+ "EmailWebhookEventType",
140
+ "HttpClient",
141
+ "LifecycleStage",
142
+ "ListParams",
143
+ "MeetingType",
144
+ "MessageTemplate",
145
+ "Note",
146
+ "OtokAPIError",
147
+ "OtokClient",
148
+ "OtokTimeoutError",
149
+ "OtokWebhookEvent",
150
+ "OtokWebhookVerificationError",
151
+ "Paginated",
152
+ "ParsedSignatureHeader",
153
+ "Payment",
154
+ "PaymentCreateParams",
155
+ "PaymentEntryStatus",
156
+ "PaymentInterval",
157
+ "PaymentListParams",
158
+ "PaymentMethod",
159
+ "PaymentRefundParams",
160
+ "PaymentType",
161
+ "PaymentUpdateParams",
162
+ "Pipeline",
163
+ "PipelineStage",
164
+ "SlotsParams",
165
+ "Tag",
166
+ "TagCreateParams",
167
+ "TagUpdateParams",
168
+ "TemplateSendParams",
169
+ "TrackOrderResult",
170
+ "Transport",
171
+ "TransportRequest",
172
+ "TransportResponse",
173
+ "UrllibTransport",
174
+ "WebhookEndpoint",
175
+ "WebhookEndpointCreateParams",
176
+ "WebhookEndpointCreated",
177
+ "WebhookEndpointList",
178
+ "__version__",
179
+ "compute_backoff_ms",
180
+ "compute_webhook_signature",
181
+ "construct_event",
182
+ "customer_to_contact_params",
183
+ "order_external_reference",
184
+ "order_receipt_idempotency_key",
185
+ "parse_signature_header",
186
+ "verify_webhook_signature",
187
+ ]
otok/_http.py ADDED
@@ -0,0 +1,316 @@
1
+ """HTTP layer: transport abstraction, retries, backoff, and error mapping.
2
+
3
+ The transport is a tiny protocol (one ``send`` method) so the SDK stays
4
+ zero-dependency (the default transport wraps ``urllib.request``) and tests
5
+ can inject a scripted fake.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import random
13
+ import socket
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from datetime import timezone
21
+ from email.utils import parsedate_to_datetime
22
+ from typing import Any, Callable, Optional, Protocol, Union
23
+
24
+ from ._version import __version__
25
+ from .errors import OtokAPIError, OtokTimeoutError
26
+
27
+ #: Canonical base URL of the oToK API (endpoint paths ``/v1/...`` are
28
+ #: appended to it).
29
+ DEFAULT_BASE_URL = "https://app.otok.io/api"
30
+
31
+ DEFAULT_TIMEOUT_SECONDS = 30.0
32
+ DEFAULT_MAX_RETRIES = 2
33
+ #: Base backoff delay; grows exponentially per retry with full jitter.
34
+ BACKOFF_BASE_MS = 500
35
+ BACKOFF_CAP_MS = 30_000
36
+
37
+ QueryValue = Union[str, int, float, bool, None]
38
+
39
+
40
+ @dataclass
41
+ class TransportRequest:
42
+ """A single outgoing HTTP request handed to the transport."""
43
+
44
+ method: str
45
+ url: str
46
+ headers: Mapping[str, str]
47
+ body: Optional[bytes]
48
+ #: Timeout in seconds. With the default urllib transport this bounds
49
+ #: each blocking socket operation (connect, each read) rather than the
50
+ #: attempt's total wall-clock time.
51
+ timeout: float
52
+
53
+
54
+ @dataclass
55
+ class TransportResponse:
56
+ """The transport's response. ``headers`` keys must be lowercased."""
57
+
58
+ status: int
59
+ headers: Mapping[str, str]
60
+ body: bytes
61
+
62
+
63
+ class Transport(Protocol):
64
+ """Minimal transport contract.
65
+
66
+ Implementations perform exactly one HTTP round trip per ``send`` call
67
+ (no retries and no redirect following — the client owns retry policy,
68
+ and a 3xx is a terminal response), return a ``TransportResponse`` for
69
+ ANY HTTP status (including 3xx/4xx/5xx), and raise ``OtokTimeoutError``
70
+ when the request times out.
71
+ """
72
+
73
+ def send(self, request: TransportRequest) -> TransportResponse: # pragma: no cover
74
+ ...
75
+
76
+
77
+ class _NoRedirectFollowHandler(urllib.request.HTTPRedirectHandler):
78
+ """Treat every 3xx as a terminal response instead of following it.
79
+
80
+ urllib's default redirect handler re-sends the original headers —
81
+ including ``Authorization: Bearer otok_live_…`` — to the redirect
82
+ target, even cross-origin. The API never redirects, so the safe
83
+ behavior is to surface the 3xx to the caller (the same posture as
84
+ oToK's own outbound webhook dispatcher, which follows no redirects).
85
+ """
86
+
87
+ def redirect_request(
88
+ self,
89
+ req: urllib.request.Request,
90
+ fp: Any,
91
+ code: int,
92
+ msg: str,
93
+ headers: Any,
94
+ newurl: str,
95
+ ) -> None:
96
+ # Returning None makes HTTPRedirectHandler raise HTTPError for the
97
+ # 3xx, which UrllibTransport converts into a TransportResponse.
98
+ return None
99
+
100
+
101
+ class UrllibTransport:
102
+ """Default zero-dependency transport built on ``urllib.request``.
103
+
104
+ Redirects are NOT followed — a 3xx comes back as a plain
105
+ ``TransportResponse`` — so the bearer API key is never re-sent to a
106
+ redirect target. The per-request ``timeout`` bounds each socket
107
+ operation (connect, each read), not the whole attempt.
108
+ """
109
+
110
+ def __init__(self) -> None:
111
+ self._opener = urllib.request.build_opener(_NoRedirectFollowHandler())
112
+
113
+ def send(self, request: TransportRequest) -> TransportResponse:
114
+ req = urllib.request.Request(
115
+ request.url,
116
+ data=request.body,
117
+ headers=dict(request.headers),
118
+ method=request.method,
119
+ )
120
+ try:
121
+ with self._opener.open(req, timeout=request.timeout) as response:
122
+ return TransportResponse(
123
+ status=response.status,
124
+ headers=_lower_headers(response.headers.items()),
125
+ body=response.read(),
126
+ )
127
+ except urllib.error.HTTPError as err:
128
+ # Non-2xx responses (including unfollowed 3xx) are data, not
129
+ # exceptions: the client decides whether to retry or raise
130
+ # OtokAPIError.
131
+ return TransportResponse(
132
+ status=err.code,
133
+ headers=_lower_headers(err.headers.items()),
134
+ body=err.read(),
135
+ )
136
+ except (TimeoutError, socket.timeout) as err: # socket.timeout: Python 3.9
137
+ raise OtokTimeoutError(request.timeout) from err
138
+ except urllib.error.URLError as err:
139
+ if isinstance(err.reason, (TimeoutError, socket.timeout)):
140
+ raise OtokTimeoutError(request.timeout) from err
141
+ raise
142
+
143
+
144
+ def _lower_headers(items: Any) -> dict[str, str]:
145
+ return {str(key).lower(): str(value) for key, value in items}
146
+
147
+
148
+ def compute_backoff_ms(
149
+ attempt: int,
150
+ retry_after_header: Optional[str] = None,
151
+ random_func: Callable[[], float] = random.random,
152
+ ) -> float:
153
+ """Compute the delay (in milliseconds) before the next retry attempt.
154
+
155
+ A ``Retry-After`` header (delta-seconds or HTTP-date), when present and
156
+ parseable, wins over the computed backoff. Otherwise exponential backoff
157
+ with full jitter: ``random(0, min(cap, base * 2**attempt))``.
158
+
159
+ ``attempt`` is the 0-based index of the retry being scheduled.
160
+ """
161
+ if retry_after_header:
162
+ header = retry_after_header.strip()
163
+ seconds: Optional[float]
164
+ try:
165
+ seconds = float(header)
166
+ except ValueError:
167
+ seconds = None
168
+ if seconds is not None and math.isfinite(seconds) and seconds >= 0:
169
+ return min(seconds * 1000.0, float(BACKOFF_CAP_MS))
170
+ date_ms = _parse_http_date_ms(header)
171
+ if date_ms is not None:
172
+ return min(max(0.0, date_ms - time.time() * 1000.0), float(BACKOFF_CAP_MS))
173
+ cap = min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * (2**attempt))
174
+ return float(math.floor(random_func() * cap))
175
+
176
+
177
+ def _parse_http_date_ms(value: str) -> Optional[float]:
178
+ try:
179
+ parsed = parsedate_to_datetime(value)
180
+ except (TypeError, ValueError):
181
+ return None
182
+ if parsed is None: # Python 3.9 returns None for some malformed input
183
+ return None
184
+ if parsed.tzinfo is None:
185
+ parsed = parsed.replace(tzinfo=timezone.utc)
186
+ return parsed.timestamp() * 1000.0
187
+
188
+
189
+ def _is_retryable_status(status: int) -> bool:
190
+ return status == 429 or status >= 500
191
+
192
+
193
+ class HttpClient:
194
+ """Minimal HTTP client: auth header injection, JSON (de)serialization,
195
+ request timeouts, and exponential-backoff retries on 429/5xx respecting
196
+ ``Retry-After``.
197
+
198
+ ``timeout`` is passed to the transport per attempt; with the default
199
+ urllib transport it bounds each socket operation (connect, each read),
200
+ not the attempt's total wall-clock time.
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ api_key: str,
206
+ *,
207
+ base_url: Optional[str] = None,
208
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
209
+ max_retries: int = DEFAULT_MAX_RETRIES,
210
+ transport: Optional[Transport] = None,
211
+ ) -> None:
212
+ if not api_key:
213
+ raise ValueError("otok: api_key is required")
214
+ self._api_key = api_key
215
+ self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
216
+ self._timeout = timeout
217
+ self._max_retries = max_retries
218
+ self._transport: Transport = transport if transport is not None else UrllibTransport()
219
+
220
+ def request(
221
+ self,
222
+ method: str,
223
+ path: str,
224
+ *,
225
+ query: Optional[Mapping[str, QueryValue]] = None,
226
+ body: Any = None,
227
+ ) -> Any:
228
+ url = self._build_url(path, query)
229
+ headers: dict[str, str] = {
230
+ "Authorization": f"Bearer {self._api_key}",
231
+ "Accept": "application/json",
232
+ "User-Agent": f"otok-python/{__version__}",
233
+ }
234
+ data: Optional[bytes] = None
235
+ if body is not None:
236
+ headers["Content-Type"] = "application/json"
237
+ data = json.dumps(body).encode("utf-8")
238
+
239
+ for attempt in range(self._max_retries + 1):
240
+ # Network failures and timeouts are not retried in v0.1 — not
241
+ # every endpoint is idempotent, and a request may have reached
242
+ # the server.
243
+ response = self._transport.send(
244
+ TransportRequest(
245
+ method=method,
246
+ url=url,
247
+ headers=headers,
248
+ body=data,
249
+ timeout=self._timeout,
250
+ )
251
+ )
252
+
253
+ if 200 <= response.status < 300:
254
+ return _parse_body(response)
255
+
256
+ if _is_retryable_status(response.status) and attempt < self._max_retries:
257
+ delay_ms = compute_backoff_ms(attempt, response.headers.get("retry-after"))
258
+ time.sleep(delay_ms / 1000.0)
259
+ continue
260
+
261
+ raise _to_api_error(response)
262
+
263
+ # Unreachable: the final loop iteration always returns or raises.
264
+ raise RuntimeError("otok: retry loop exited unexpectedly")
265
+
266
+ def _build_url(self, path: str, query: Optional[Mapping[str, QueryValue]]) -> str:
267
+ url = self._base_url + path
268
+ if query:
269
+ pairs = [
270
+ (key, _serialize_query_value(value))
271
+ for key, value in query.items()
272
+ if value is not None
273
+ ]
274
+ if pairs:
275
+ url = url + "?" + urllib.parse.urlencode(pairs)
276
+ return url
277
+
278
+
279
+ def _serialize_query_value(value: Union[str, int, float, bool]) -> str:
280
+ if isinstance(value, bool):
281
+ return "true" if value else "false"
282
+ return str(value)
283
+
284
+
285
+ def _parse_body(response: TransportResponse) -> Any:
286
+ if response.status == 204:
287
+ return None
288
+ if not response.body:
289
+ return None
290
+ text = response.body.decode("utf-8", errors="replace")
291
+ if not text:
292
+ return None
293
+ try:
294
+ return json.loads(text)
295
+ except ValueError:
296
+ return text
297
+
298
+
299
+ def _to_api_error(response: TransportResponse) -> OtokAPIError:
300
+ body = _parse_body(response)
301
+ code: Optional[str] = None
302
+ message = f"HTTP {response.status}"
303
+ if isinstance(body, dict):
304
+ error = body.get("error")
305
+ if isinstance(error, dict):
306
+ # Domain envelope: { "error": { "code", "message" } }
307
+ if isinstance(error.get("code"), str):
308
+ code = error["code"]
309
+ if isinstance(error.get("message"), str):
310
+ message = error["message"]
311
+ elif isinstance(body.get("message"), str):
312
+ # Framework shape: { "statusCode", "message", "error" }
313
+ message = body["message"]
314
+ elif isinstance(body.get("message"), list):
315
+ message = "; ".join(str(item) for item in body["message"])
316
+ return OtokAPIError(response.status, message, code=code, body=body)
otok/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
otok/client.py ADDED
@@ -0,0 +1,86 @@
1
+ """The oToK API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ._http import (
8
+ DEFAULT_MAX_RETRIES,
9
+ DEFAULT_TIMEOUT_SECONDS,
10
+ HttpClient,
11
+ Transport,
12
+ )
13
+ from .commerce import CommerceApi
14
+ from .resources import (
15
+ BookingsApi,
16
+ CampaignsApi,
17
+ ContactGroupsApi,
18
+ ContactsApi,
19
+ DealsApi,
20
+ EmailsApi,
21
+ MeetingTypesApi,
22
+ PaymentsApi,
23
+ PipelinesApi,
24
+ TagsApi,
25
+ TemplatesApi,
26
+ WebhookEndpointsApi,
27
+ )
28
+
29
+
30
+ class OtokClient:
31
+ """Client for the oToK public API (/v1).
32
+
33
+ ::
34
+
35
+ from otok import OtokClient
36
+
37
+ client = OtokClient(api_key=os.environ["OTOK_API_KEY"]) # "otok_live_…"
38
+ contact = client.contacts.upsert({"email": "jane@example.com"})
39
+
40
+ Rate limits: requests are throttled per API key (default 100/min; POST
41
+ /v1/emails allows 300/min). The client retries 429 and 5xx responses
42
+ with exponential backoff + jitter, honoring ``Retry-After``.
43
+
44
+ :param api_key: API key (``otok_live_…``), sent as
45
+ ``Authorization: Bearer <key>``.
46
+ :param timeout: Request timeout in seconds. Default 30. With the default
47
+ urllib transport this bounds each socket operation (connect, each
48
+ read), not a whole attempt's wall-clock time.
49
+ :param max_retries: Retry attempts after the first request (429 and 5xx
50
+ responses only). Default 2 (i.e. up to 3 requests total). Set 0 to
51
+ disable retries.
52
+ :param transport: Injectable transport implementation (used by tests).
53
+ Defaults to the built-in ``urllib``-based transport.
54
+ :param base_url: Internal/testing override only — leave unset.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: str,
60
+ *,
61
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
62
+ max_retries: int = DEFAULT_MAX_RETRIES,
63
+ transport: Optional[Transport] = None,
64
+ base_url: Optional[str] = None,
65
+ ) -> None:
66
+ self._http = HttpClient(
67
+ api_key,
68
+ base_url=base_url,
69
+ timeout=timeout,
70
+ max_retries=max_retries,
71
+ transport=transport,
72
+ )
73
+ self.contacts = ContactsApi(self._http)
74
+ self.tags = TagsApi(self._http)
75
+ self.contact_groups = ContactGroupsApi(self._http)
76
+ self.pipelines = PipelinesApi(self._http)
77
+ self.deals = DealsApi(self._http)
78
+ self.emails = EmailsApi(self._http)
79
+ self.campaigns = CampaignsApi(self._http)
80
+ self.templates = TemplatesApi(self._http)
81
+ self.payments = PaymentsApi(self._http)
82
+ self.meeting_types = MeetingTypesApi(self._http)
83
+ self.bookings = BookingsApi(self._http)
84
+ self.webhook_endpoints = WebhookEndpointsApi(self._http)
85
+ #: High-level e-commerce helpers (identify_customer, track_order).
86
+ self.commerce = CommerceApi(self.contacts, self.deals, self.emails)