cdp-python-sdk 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/PKG-INFO +54 -11
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/README.md +52 -9
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_client/__init__.py +4 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_client/client.py +94 -24
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_client/gateway_urls.py +4 -4
- cdp_python_sdk-0.2.0/cdp_client/models.py +143 -0
- cdp_python_sdk-0.2.0/cdp_client/validators.py +119 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_python_sdk.egg-info/PKG-INFO +54 -11
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/pyproject.toml +16 -2
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/setup.py +2 -2
- cdp_python_sdk-0.1.0/cdp_client/models.py +0 -110
- cdp_python_sdk-0.1.0/cdp_client/validators.py +0 -35
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/MANIFEST.in +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_client/errors.py +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_python_sdk.egg-info/SOURCES.txt +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_python_sdk.egg-info/dependency_links.txt +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_python_sdk.egg-info/requires.txt +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/cdp_python_sdk.egg-info/top_level.txt +0 -0
- {cdp_python_sdk-0.1.0 → cdp_python_sdk-0.2.0}/setup.cfg +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cdp-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
|
|
5
5
|
Author-email: Codematic Engineering <engineering@codematic.io>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
Requires-Dist: httpx>=0.24.0
|
|
12
12
|
Requires-Dist: pydantic>=2.0.0
|
|
@@ -165,6 +165,25 @@ await client.send_email(EmailPayload(
|
|
|
165
165
|
))
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
+
#### Attachments
|
|
169
|
+
|
|
170
|
+
Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
payload = EmailPayload(
|
|
174
|
+
to="user@example.com",
|
|
175
|
+
identifiers=Identifiers(id="user-123"),
|
|
176
|
+
transactional_message_id="INVOICE_EMAIL",
|
|
177
|
+
)
|
|
178
|
+
payload.attach_file("./invoice.pdf") # named "invoice.pdf"
|
|
179
|
+
payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
|
|
180
|
+
payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
|
|
181
|
+
|
|
182
|
+
await client.send_email(payload)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
|
|
186
|
+
|
|
168
187
|
---
|
|
169
188
|
|
|
170
189
|
### Send Push Notification
|
|
@@ -197,6 +216,27 @@ await client.send_sms(SmsPayload(
|
|
|
197
216
|
|
|
198
217
|
---
|
|
199
218
|
|
|
219
|
+
### Send WhatsApp
|
|
220
|
+
|
|
221
|
+
WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from cdp_client import WhatsAppPayload, Identifiers
|
|
225
|
+
|
|
226
|
+
result = await client.send_whatsapp(WhatsAppPayload(
|
|
227
|
+
identifiers=Identifiers(id="user-123"),
|
|
228
|
+
transactional_message_id="ORDER_WHATSAPP",
|
|
229
|
+
to="+14155551234", # Optional: overrides the profile phone number
|
|
230
|
+
message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
|
|
231
|
+
))
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
|
|
235
|
+
- `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
|
|
236
|
+
- Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
200
240
|
### Clear Identity / Logout
|
|
201
241
|
|
|
202
242
|
To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
|
|
@@ -213,22 +253,25 @@ client = CDPClient(config)
|
|
|
213
253
|
|
|
214
254
|
## Error Handling
|
|
215
255
|
|
|
216
|
-
By default
|
|
256
|
+
By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
|
|
217
257
|
|
|
218
258
|
```python
|
|
219
|
-
import
|
|
259
|
+
from cdp_client import CDPError, CDPValidationError
|
|
260
|
+
|
|
261
|
+
config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
|
|
262
|
+
client = CDPClient(config)
|
|
220
263
|
|
|
221
264
|
try:
|
|
222
265
|
await client.identify("user-123", {"email": "user@example.com"})
|
|
223
|
-
except
|
|
224
|
-
#
|
|
225
|
-
print(f"
|
|
226
|
-
except
|
|
227
|
-
#
|
|
228
|
-
print(f"
|
|
266
|
+
except CDPValidationError as e:
|
|
267
|
+
# Invalid input, caught before any request is sent
|
|
268
|
+
print(f"Invalid {e.field}: {e}")
|
|
269
|
+
except CDPError as e:
|
|
270
|
+
# status_code is the HTTP status, or None when the gateway could not be reached
|
|
271
|
+
print(f"API error {e.status_code}: {e}")
|
|
229
272
|
```
|
|
230
273
|
|
|
231
|
-
>
|
|
274
|
+
> This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
|
|
232
275
|
|
|
233
276
|
---
|
|
234
277
|
|
|
@@ -147,6 +147,25 @@ await client.send_email(EmailPayload(
|
|
|
147
147
|
))
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
#### Attachments
|
|
151
|
+
|
|
152
|
+
Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
payload = EmailPayload(
|
|
156
|
+
to="user@example.com",
|
|
157
|
+
identifiers=Identifiers(id="user-123"),
|
|
158
|
+
transactional_message_id="INVOICE_EMAIL",
|
|
159
|
+
)
|
|
160
|
+
payload.attach_file("./invoice.pdf") # named "invoice.pdf"
|
|
161
|
+
payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
|
|
162
|
+
payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
|
|
163
|
+
|
|
164
|
+
await client.send_email(payload)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
|
|
168
|
+
|
|
150
169
|
---
|
|
151
170
|
|
|
152
171
|
### Send Push Notification
|
|
@@ -179,6 +198,27 @@ await client.send_sms(SmsPayload(
|
|
|
179
198
|
|
|
180
199
|
---
|
|
181
200
|
|
|
201
|
+
### Send WhatsApp
|
|
202
|
+
|
|
203
|
+
WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from cdp_client import WhatsAppPayload, Identifiers
|
|
207
|
+
|
|
208
|
+
result = await client.send_whatsapp(WhatsAppPayload(
|
|
209
|
+
identifiers=Identifiers(id="user-123"),
|
|
210
|
+
transactional_message_id="ORDER_WHATSAPP",
|
|
211
|
+
to="+14155551234", # Optional: overrides the profile phone number
|
|
212
|
+
message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
|
|
213
|
+
))
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
|
|
217
|
+
- `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
|
|
218
|
+
- Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
182
222
|
### Clear Identity / Logout
|
|
183
223
|
|
|
184
224
|
To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
|
|
@@ -195,22 +235,25 @@ client = CDPClient(config)
|
|
|
195
235
|
|
|
196
236
|
## Error Handling
|
|
197
237
|
|
|
198
|
-
By default
|
|
238
|
+
By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
|
|
199
239
|
|
|
200
240
|
```python
|
|
201
|
-
import
|
|
241
|
+
from cdp_client import CDPError, CDPValidationError
|
|
242
|
+
|
|
243
|
+
config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
|
|
244
|
+
client = CDPClient(config)
|
|
202
245
|
|
|
203
246
|
try:
|
|
204
247
|
await client.identify("user-123", {"email": "user@example.com"})
|
|
205
|
-
except
|
|
206
|
-
#
|
|
207
|
-
print(f"
|
|
208
|
-
except
|
|
209
|
-
#
|
|
210
|
-
print(f"
|
|
248
|
+
except CDPValidationError as e:
|
|
249
|
+
# Invalid input, caught before any request is sent
|
|
250
|
+
print(f"Invalid {e.field}: {e}")
|
|
251
|
+
except CDPError as e:
|
|
252
|
+
# status_code is the HTTP status, or None when the gateway could not be reached
|
|
253
|
+
print(f"API error {e.status_code}: {e}")
|
|
211
254
|
```
|
|
212
255
|
|
|
213
|
-
>
|
|
256
|
+
> This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
|
|
214
257
|
|
|
215
258
|
---
|
|
216
259
|
|
|
@@ -5,8 +5,10 @@ from .models import (
|
|
|
5
5
|
CustomerIoConfig,
|
|
6
6
|
DeviceRegistrationParameters,
|
|
7
7
|
EmailPayload,
|
|
8
|
+
Identifiers,
|
|
8
9
|
PushPayload,
|
|
9
10
|
SmsPayload,
|
|
11
|
+
WhatsAppPayload,
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
__all__ = [
|
|
@@ -17,6 +19,8 @@ __all__ = [
|
|
|
17
19
|
"CustomerIoConfig",
|
|
18
20
|
"DeviceRegistrationParameters",
|
|
19
21
|
"EmailPayload",
|
|
22
|
+
"Identifiers",
|
|
20
23
|
"PushPayload",
|
|
21
24
|
"SmsPayload",
|
|
25
|
+
"WhatsAppPayload",
|
|
22
26
|
]
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
from customerio import CustomerIO
|
|
@@ -15,11 +15,26 @@ from .models import (
|
|
|
15
15
|
EmailPayload,
|
|
16
16
|
PushPayload,
|
|
17
17
|
SmsPayload,
|
|
18
|
+
WhatsAppPayload,
|
|
19
|
+
)
|
|
20
|
+
from .validators import (
|
|
21
|
+
validate_email_attachments,
|
|
22
|
+
validate_event_name,
|
|
23
|
+
validate_identifier,
|
|
24
|
+
validate_properties,
|
|
25
|
+
validate_whatsapp_payload,
|
|
18
26
|
)
|
|
19
|
-
from .validators import validate_event_name, validate_identifier, validate_properties
|
|
20
27
|
|
|
21
28
|
logger = logging.getLogger("CDPClient")
|
|
22
29
|
|
|
30
|
+
# Cloudflare (in front of the primary host) reports these when it never sent the request to the
|
|
31
|
+
# gateway: 521 refused, 523 unreachable, 525/526 TLS failure. Generic 502/503
|
|
32
|
+
# are excluded because a proxy can return them after the gateway has already queued the message,
|
|
33
|
+
# and 522/524 because Cloudflare may already have sent the request when it timed out.
|
|
34
|
+
_SEND_RETRYABLE_STATUSES = (521, 523, 525, 526)
|
|
35
|
+
# Raised before the request was sent. Read timeouts are excluded: the request may have been accepted.
|
|
36
|
+
_SEND_RETRYABLE_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout)
|
|
37
|
+
|
|
23
38
|
|
|
24
39
|
class CDPClient:
|
|
25
40
|
"""Client for the OpenCDP Data Gateway with optional Customer.io dual-write."""
|
|
@@ -33,7 +48,7 @@ class CDPClient:
|
|
|
33
48
|
self._timeout = config.timeout_ms / 1000.0
|
|
34
49
|
logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
|
|
35
50
|
|
|
36
|
-
self.cio:
|
|
51
|
+
self.cio: CustomerIO | None = None
|
|
37
52
|
if self.config.send_to_customer_io and self.config.customer_io:
|
|
38
53
|
try:
|
|
39
54
|
self.cio = CustomerIO(
|
|
@@ -65,34 +80,48 @@ class CDPClient:
|
|
|
65
80
|
method: str,
|
|
66
81
|
path: str,
|
|
67
82
|
*,
|
|
68
|
-
json_body:
|
|
83
|
+
json_body: dict | None = None,
|
|
84
|
+
send_safe: bool = False,
|
|
69
85
|
) -> httpx.Response:
|
|
70
|
-
|
|
86
|
+
"""With send_safe, only fail over when the host provably never processed the request.
|
|
87
|
+
|
|
88
|
+
Message sends are not idempotent: retrying after a read timeout, a redirect, or a 4xx/5xx
|
|
89
|
+
outside _SEND_RETRYABLE_STATUSES could deliver the same message twice.
|
|
90
|
+
"""
|
|
91
|
+
last_error: Exception | None = None
|
|
71
92
|
async with httpx.AsyncClient(
|
|
72
93
|
timeout=self._timeout,
|
|
73
94
|
headers=self._headers(),
|
|
95
|
+
# A followed redirect would turn a failed connection to the redirect target into a
|
|
96
|
+
# "never reached" error, and failover would deliver the message twice.
|
|
97
|
+
follow_redirects=False,
|
|
74
98
|
) as client:
|
|
75
99
|
for base_url in self._base_urls:
|
|
76
100
|
url = f"{base_url}{path}"
|
|
77
101
|
try:
|
|
78
102
|
response = await client.request(method, url, json=json_body)
|
|
79
|
-
if 200 <= response.status_code < 300:
|
|
80
|
-
return response
|
|
81
|
-
last_error = httpx.HTTPStatusError(
|
|
82
|
-
f"HTTP {response.status_code}",
|
|
83
|
-
request=response.request,
|
|
84
|
-
response=response,
|
|
85
|
-
)
|
|
86
|
-
if self.config.debug:
|
|
87
|
-
logger.debug(
|
|
88
|
-
"Gateway %s returned %s, trying next host",
|
|
89
|
-
base_url,
|
|
90
|
-
response.status_code,
|
|
91
|
-
)
|
|
92
103
|
except Exception as e:
|
|
93
104
|
last_error = e
|
|
105
|
+
if send_safe and not isinstance(e, _SEND_RETRYABLE_ERRORS):
|
|
106
|
+
raise
|
|
94
107
|
if self.config.debug:
|
|
95
108
|
logger.debug("Gateway %s unreachable: %s", base_url, e)
|
|
109
|
+
continue
|
|
110
|
+
if 200 <= response.status_code < 300:
|
|
111
|
+
return response
|
|
112
|
+
last_error = httpx.HTTPStatusError(
|
|
113
|
+
f"HTTP {response.status_code}",
|
|
114
|
+
request=response.request,
|
|
115
|
+
response=response,
|
|
116
|
+
)
|
|
117
|
+
if send_safe and response.status_code not in _SEND_RETRYABLE_STATUSES:
|
|
118
|
+
raise last_error
|
|
119
|
+
if self.config.debug:
|
|
120
|
+
logger.debug(
|
|
121
|
+
"Gateway %s returned %s, trying next host",
|
|
122
|
+
base_url,
|
|
123
|
+
response.status_code,
|
|
124
|
+
)
|
|
96
125
|
assert last_error is not None
|
|
97
126
|
raise last_error
|
|
98
127
|
|
|
@@ -104,7 +133,7 @@ class CDPClient:
|
|
|
104
133
|
self._handle_error("Ping failed", e)
|
|
105
134
|
return False
|
|
106
135
|
|
|
107
|
-
async def identify(self, identifier: str, properties:
|
|
136
|
+
async def identify(self, identifier: str, properties: dict[str, Any] | None = None) -> None:
|
|
108
137
|
try:
|
|
109
138
|
validated_id = validate_identifier(identifier)
|
|
110
139
|
normalized_props = validate_properties(properties)
|
|
@@ -130,7 +159,7 @@ class CDPClient:
|
|
|
130
159
|
self,
|
|
131
160
|
identifier: str,
|
|
132
161
|
event_name: str,
|
|
133
|
-
properties:
|
|
162
|
+
properties: dict[str, Any] | None = None,
|
|
134
163
|
) -> None:
|
|
135
164
|
try:
|
|
136
165
|
validated_id = validate_identifier(identifier)
|
|
@@ -192,10 +221,17 @@ class CDPClient:
|
|
|
192
221
|
self._handle_error("Error registering device", e)
|
|
193
222
|
|
|
194
223
|
async def send_email(self, payload: EmailPayload) -> None:
|
|
224
|
+
try:
|
|
225
|
+
validate_email_attachments(payload.attachments)
|
|
226
|
+
except CDPValidationError as e:
|
|
227
|
+
if self.config.fail_on_exception:
|
|
228
|
+
raise
|
|
229
|
+
logger.error("Email validation failed: %s", e)
|
|
230
|
+
return
|
|
195
231
|
payload.check_unsupported_fields()
|
|
196
232
|
try:
|
|
197
233
|
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
198
|
-
await self._request("POST", "/v1/send/email", json_body=data)
|
|
234
|
+
await self._request("POST", "/v1/send/email", json_body=data, send_safe=True)
|
|
199
235
|
except Exception as e:
|
|
200
236
|
self._handle_error("Error sending email", e)
|
|
201
237
|
|
|
@@ -203,7 +239,7 @@ class CDPClient:
|
|
|
203
239
|
payload.check_unsupported_fields()
|
|
204
240
|
try:
|
|
205
241
|
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
206
|
-
await self._request("POST", "/v1/send/push", json_body=data)
|
|
242
|
+
await self._request("POST", "/v1/send/push", json_body=data, send_safe=True)
|
|
207
243
|
except Exception as e:
|
|
208
244
|
self._handle_error("Error sending push", e)
|
|
209
245
|
|
|
@@ -211,10 +247,44 @@ class CDPClient:
|
|
|
211
247
|
payload.check_unsupported_fields()
|
|
212
248
|
try:
|
|
213
249
|
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
214
|
-
await self._request("POST", "/v1/send/sms", json_body=data)
|
|
250
|
+
await self._request("POST", "/v1/send/sms", json_body=data, send_safe=True)
|
|
215
251
|
except Exception as e:
|
|
216
252
|
self._handle_error("Error sending SMS", e)
|
|
217
253
|
|
|
254
|
+
async def send_whatsapp(self, payload: WhatsAppPayload) -> dict[str, Any] | None:
|
|
255
|
+
"""Queue a WhatsApp transactional send.
|
|
256
|
+
|
|
257
|
+
Returns the gateway acknowledgement (the transactional execution record). A successful
|
|
258
|
+
return means the message was queued, not delivered. Returns None on failure unless
|
|
259
|
+
fail_on_exception is set.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
validate_whatsapp_payload(payload)
|
|
263
|
+
except CDPValidationError as e:
|
|
264
|
+
if self.config.fail_on_exception:
|
|
265
|
+
raise
|
|
266
|
+
logger.error("WhatsApp validation failed: %s", e)
|
|
267
|
+
return None
|
|
268
|
+
data = payload.model_dump(exclude_none=True)
|
|
269
|
+
data["transactional_message_id"] = str(payload.transactional_message_id)
|
|
270
|
+
try:
|
|
271
|
+
response = await self._request("POST", "/v1/send/whatsapp", json_body=data, send_safe=True)
|
|
272
|
+
return response.json() if response.content else None
|
|
273
|
+
except Exception as e:
|
|
274
|
+
self._handle_error(f"Error sending WhatsApp: {_gateway_message(e)}", e)
|
|
275
|
+
return None
|
|
276
|
+
|
|
218
277
|
async def close(self) -> None:
|
|
219
278
|
"""No persistent client; kept for API compatibility."""
|
|
220
|
-
return
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _gateway_message(exc: Exception) -> str:
|
|
283
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
284
|
+
try:
|
|
285
|
+
message = exc.response.json().get("message")
|
|
286
|
+
except ValueError:
|
|
287
|
+
message = None
|
|
288
|
+
if message:
|
|
289
|
+
return str(message)
|
|
290
|
+
return str(exc) or type(exc).__name__
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
DEFAULT_PRIMARY = "https://api.opencdp.
|
|
5
|
+
DEFAULT_PRIMARY = "https://api.opencdp.io/gateway/data-gateway"
|
|
6
6
|
DEFAULT_FALLBACKS = [
|
|
7
|
-
"https://api.
|
|
8
|
-
"https://api.
|
|
7
|
+
"https://api.open-cdp.com/gateway/data-gateway",
|
|
8
|
+
"https://api.open-cdp.xyz/gateway/data-gateway",
|
|
9
9
|
]
|
|
10
10
|
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ def normalize_base_url(url: str) -> str:
|
|
|
13
13
|
trimmed = url.strip()
|
|
14
14
|
if not trimmed:
|
|
15
15
|
return trimmed
|
|
16
|
-
return trimmed
|
|
16
|
+
return trimmed.removesuffix("/")
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def resolve_all_base_urls(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from .gateway_urls import DEFAULT_PRIMARY
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CustomerIoConfig(BaseModel):
|
|
16
|
+
site_id: str = Field(..., description="Customer.io Site ID")
|
|
17
|
+
api_key: str = Field(..., description="Customer.io API Key")
|
|
18
|
+
region: Literal["us", "eu"] | None = Field("us", description="Data center region")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CDPConfig(BaseModel):
|
|
22
|
+
cdp_api_key: str = Field(..., description="API Key for the CDP")
|
|
23
|
+
cdp_endpoint: str = Field(
|
|
24
|
+
DEFAULT_PRIMARY,
|
|
25
|
+
description="Primary gateway base URL",
|
|
26
|
+
)
|
|
27
|
+
cdp_fallback_endpoints: list[str] | None = Field(
|
|
28
|
+
None,
|
|
29
|
+
description="Optional fallback gateway base URLs",
|
|
30
|
+
)
|
|
31
|
+
timeout_ms: int = Field(10000, description="Request timeout in milliseconds")
|
|
32
|
+
fail_on_exception: bool = Field(
|
|
33
|
+
False,
|
|
34
|
+
description="When true, raise on validation and HTTP errors",
|
|
35
|
+
)
|
|
36
|
+
send_to_customer_io: bool = Field(False, description="Enable dual-write to Customer.io")
|
|
37
|
+
customer_io: CustomerIoConfig | None = Field(None, description="Customer.io configuration")
|
|
38
|
+
debug: bool = Field(False, description="Enable debug logging")
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Identifiers(BaseModel):
|
|
44
|
+
id: str | None = None
|
|
45
|
+
email: str | None = None
|
|
46
|
+
cdp_id: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BaseMessagePayload(BaseModel):
|
|
50
|
+
identifiers: Identifiers
|
|
51
|
+
transactional_message_id: str | None = None
|
|
52
|
+
message_data: dict[str, Any] | None = None
|
|
53
|
+
send_at: int | None = None
|
|
54
|
+
send_to_unsubscribed: bool | None = None
|
|
55
|
+
tracked: bool | None = None
|
|
56
|
+
disable_css_preprocessing: bool | None = None
|
|
57
|
+
headers: str | None = None
|
|
58
|
+
disable_message_retention: bool | None = None
|
|
59
|
+
queue_draft: bool | None = None
|
|
60
|
+
|
|
61
|
+
def check_unsupported_fields(self):
|
|
62
|
+
unsupported = [
|
|
63
|
+
"send_at",
|
|
64
|
+
"send_to_unsubscribed",
|
|
65
|
+
"tracked",
|
|
66
|
+
"disable_css_preprocessing",
|
|
67
|
+
"headers",
|
|
68
|
+
"disable_message_retention",
|
|
69
|
+
"queue_draft",
|
|
70
|
+
]
|
|
71
|
+
found = [field for field in unsupported if getattr(self, field) is not None]
|
|
72
|
+
if found:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"The following fields are not yet supported by the backend and will be ignored: %s",
|
|
75
|
+
", ".join(found),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EmailPayload(BaseMessagePayload):
|
|
80
|
+
to: str
|
|
81
|
+
from_: str | None = Field(None, alias="from")
|
|
82
|
+
subject: str | None = None
|
|
83
|
+
body: str | None = None
|
|
84
|
+
body_plain: str | None = None
|
|
85
|
+
reply_to: str | None = None
|
|
86
|
+
# Filename -> base64 content. Max 5 files, 2 MB decoded in total.
|
|
87
|
+
attachments: dict[str, str] | None = None
|
|
88
|
+
|
|
89
|
+
def attach(self, filename: str, data: bytes | str, encode: bool = True) -> None:
|
|
90
|
+
"""Attach a file. Bytes are always base64-encoded; strings are encoded unless encode=False,
|
|
91
|
+
in which case they must already be base64 (same semantics as customerio's attach)."""
|
|
92
|
+
if isinstance(data, str):
|
|
93
|
+
content = base64.b64encode(data.encode()).decode() if encode else data
|
|
94
|
+
else:
|
|
95
|
+
content = base64.b64encode(data).decode()
|
|
96
|
+
if self.attachments is None:
|
|
97
|
+
self.attachments = {}
|
|
98
|
+
self.attachments[filename] = content
|
|
99
|
+
|
|
100
|
+
def attach_file(self, path: str | os.PathLike[str], filename: str | None = None) -> None:
|
|
101
|
+
"""Read a file from disk and attach it, named after the file unless filename is given."""
|
|
102
|
+
with open(path, "rb") as f:
|
|
103
|
+
self.attach(filename or os.path.basename(path), f.read())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PushPayload(BaseMessagePayload):
|
|
107
|
+
title: str | None = None
|
|
108
|
+
body: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SmsPayload(BaseMessagePayload):
|
|
112
|
+
to: str | None = None
|
|
113
|
+
from_: str | None = Field(None, alias="from")
|
|
114
|
+
body: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class WhatsAppPayload(BaseModel):
|
|
118
|
+
"""Only the fields the gateway's WhatsApp endpoint accepts; it rejects any other key."""
|
|
119
|
+
|
|
120
|
+
identifiers: Identifiers
|
|
121
|
+
transactional_message_id: str | int
|
|
122
|
+
to: str | None = None
|
|
123
|
+
# {"header" | "body" | "button": {"1": value, "2": value, ...}}. Replaces the variables
|
|
124
|
+
# saved on the transactional entirely when given.
|
|
125
|
+
template_variables: dict[str, dict[str, Any]] | None = None
|
|
126
|
+
message_data: dict[str, Any] | None = None
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(extra="forbid")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DeviceRegistrationParameters(BaseModel):
|
|
132
|
+
device_id: str = Field(..., alias="deviceId")
|
|
133
|
+
platform: Literal["android", "ios", "web"]
|
|
134
|
+
fcm_token: str = Field(..., alias="fcmToken")
|
|
135
|
+
name: str | None = None
|
|
136
|
+
os_version: str | None = Field(None, alias="osVersion")
|
|
137
|
+
model: str | None = None
|
|
138
|
+
apn_token: str | None = Field(None, alias="apnToken")
|
|
139
|
+
app_version: str | None = Field(None, alias="appVersion")
|
|
140
|
+
last_active_at: str | None = None
|
|
141
|
+
attributes: dict[str, Any] | None = None
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Input validation — aligned with Flutter SDK rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .errors import CDPValidationError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .models import WhatsAppPayload
|
|
12
|
+
|
|
13
|
+
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
14
|
+
_PHONE_RE = re.compile(r"^\+?[1-9]\d{1,14}$")
|
|
15
|
+
_SLOT_RE = re.compile(r"^[1-9]\d*$")
|
|
16
|
+
_TEMPLATE_SECTIONS = ("header", "body", "button")
|
|
17
|
+
|
|
18
|
+
# Mirrors the gateway's limits (backend integrations/email-attachments.ts) so bad input fails before a network call.
|
|
19
|
+
MAX_EMAIL_ATTACHMENTS = 5
|
|
20
|
+
MAX_EMAIL_ATTACHMENTS_DECODED_BYTES = 2 * 1024 * 1024 # 2 MB
|
|
21
|
+
_MAX_EMAIL_ATTACHMENTS_ENCODED_LENGTH = -(-MAX_EMAIL_ATTACHMENTS_DECODED_BYTES // 3) * 4
|
|
22
|
+
# Standard or url-safe alphabet (not both), padding optional, "=" only at the end. Lenient decoders skip invalid
|
|
23
|
+
# characters and anything after padding, so decoding cannot be used to validate.
|
|
24
|
+
_BASE64_RE = re.compile(r"(?:[A-Za-z0-9+/_-]{4})*(?:[A-Za-z0-9+/_-]{2}(?:==)?|[A-Za-z0-9+/_-]{3}=?)?")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_identifier(identifier: str) -> str:
|
|
28
|
+
if identifier is None or str(identifier).strip() == "":
|
|
29
|
+
raise CDPValidationError("Identifier cannot be empty", "identifier")
|
|
30
|
+
value = str(identifier).strip()
|
|
31
|
+
if _EMAIL_RE.match(value):
|
|
32
|
+
raise CDPValidationError(
|
|
33
|
+
"Identifier must not be an email address",
|
|
34
|
+
"identifier",
|
|
35
|
+
)
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_event_name(event_name: str) -> str:
|
|
40
|
+
if not event_name or not event_name.strip():
|
|
41
|
+
raise CDPValidationError("Event name cannot be empty", "eventName")
|
|
42
|
+
return event_name.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_properties(properties: dict | None) -> dict:
|
|
46
|
+
if properties is None:
|
|
47
|
+
return {}
|
|
48
|
+
if not isinstance(properties, dict):
|
|
49
|
+
raise CDPValidationError("Properties must be a dictionary", "properties")
|
|
50
|
+
return properties
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_whatsapp_payload(payload: WhatsAppPayload) -> None:
|
|
54
|
+
identifiers = payload.identifiers
|
|
55
|
+
provided = [v for v in (identifiers.id, identifiers.email, identifiers.cdp_id) if v not in (None, "")]
|
|
56
|
+
if len(provided) != 1:
|
|
57
|
+
raise CDPValidationError(
|
|
58
|
+
"identifiers must contain exactly one of: id, email, or cdp_id",
|
|
59
|
+
"identifiers",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if payload.transactional_message_id is None or str(payload.transactional_message_id).strip() == "":
|
|
63
|
+
raise CDPValidationError("transactional_message_id is required", "transactional_message_id")
|
|
64
|
+
|
|
65
|
+
if payload.to is not None and not _PHONE_RE.match(payload.to):
|
|
66
|
+
raise CDPValidationError(
|
|
67
|
+
"Phone number must be in international format (e.g., +1234567890)",
|
|
68
|
+
"to",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for section, slots in (payload.template_variables or {}).items():
|
|
72
|
+
if section not in _TEMPLATE_SECTIONS:
|
|
73
|
+
raise CDPValidationError(
|
|
74
|
+
"template_variables may only contain header, body, and button",
|
|
75
|
+
"template_variables",
|
|
76
|
+
)
|
|
77
|
+
# The gateway sends parameters by position and drops non-numeric button keys.
|
|
78
|
+
for slot in slots:
|
|
79
|
+
if not _SLOT_RE.match(slot):
|
|
80
|
+
raise CDPValidationError(
|
|
81
|
+
f'template_variables.{section} keys must be positional slot numbers ("1", "2", ...), got "{slot}"',
|
|
82
|
+
"template_variables",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_email_attachments(attachments: object) -> None:
|
|
87
|
+
if attachments is None:
|
|
88
|
+
return
|
|
89
|
+
if not isinstance(attachments, dict):
|
|
90
|
+
raise CDPValidationError("attachments must be an object", "attachments")
|
|
91
|
+
if len(attachments) > MAX_EMAIL_ATTACHMENTS:
|
|
92
|
+
raise CDPValidationError(
|
|
93
|
+
f"attachments may contain at most {MAX_EMAIL_ATTACHMENTS} files",
|
|
94
|
+
"attachments",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
total_decoded_bytes = 0
|
|
98
|
+
for filename, content in attachments.items():
|
|
99
|
+
if not filename or "/" in filename or "\\" in filename or ".." in filename:
|
|
100
|
+
raise CDPValidationError(f"invalid attachment filename: {filename or '(empty)'}", "attachments")
|
|
101
|
+
if not isinstance(content, str) or content == "":
|
|
102
|
+
raise CDPValidationError(f'attachment "{filename}" must be a non-empty base64 string', "attachments")
|
|
103
|
+
normalized = re.sub(r"\s", "", content)
|
|
104
|
+
# Check the length before the pattern so oversized content fails fast with the size error.
|
|
105
|
+
if len(normalized) > _MAX_EMAIL_ATTACHMENTS_ENCODED_LENGTH:
|
|
106
|
+
raise CDPValidationError(
|
|
107
|
+
f"attachments decoded size exceeds {MAX_EMAIL_ATTACHMENTS_DECODED_BYTES} bytes (2 MB)",
|
|
108
|
+
"attachments",
|
|
109
|
+
)
|
|
110
|
+
# Mixing "+/" with "-_" is valid in neither the standard nor the url-safe alphabet.
|
|
111
|
+
mixes_alphabets = bool(re.search(r"[+/]", normalized)) and bool(re.search(r"[-_]", normalized))
|
|
112
|
+
if not normalized or not _BASE64_RE.fullmatch(normalized) or mixes_alphabets:
|
|
113
|
+
raise CDPValidationError(f'attachment "{filename}" must be a valid base64 string', "attachments")
|
|
114
|
+
total_decoded_bytes += len(normalized.rstrip("=")) * 3 // 4
|
|
115
|
+
if total_decoded_bytes > MAX_EMAIL_ATTACHMENTS_DECODED_BYTES:
|
|
116
|
+
raise CDPValidationError(
|
|
117
|
+
f"attachments decoded size exceeds {MAX_EMAIL_ATTACHMENTS_DECODED_BYTES} bytes (2 MB)",
|
|
118
|
+
"attachments",
|
|
119
|
+
)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cdp-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
|
|
5
5
|
Author-email: Codematic Engineering <engineering@codematic.io>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
Requires-Dist: httpx>=0.24.0
|
|
12
12
|
Requires-Dist: pydantic>=2.0.0
|
|
@@ -165,6 +165,25 @@ await client.send_email(EmailPayload(
|
|
|
165
165
|
))
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
+
#### Attachments
|
|
169
|
+
|
|
170
|
+
Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
payload = EmailPayload(
|
|
174
|
+
to="user@example.com",
|
|
175
|
+
identifiers=Identifiers(id="user-123"),
|
|
176
|
+
transactional_message_id="INVOICE_EMAIL",
|
|
177
|
+
)
|
|
178
|
+
payload.attach_file("./invoice.pdf") # named "invoice.pdf"
|
|
179
|
+
payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
|
|
180
|
+
payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
|
|
181
|
+
|
|
182
|
+
await client.send_email(payload)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
|
|
186
|
+
|
|
168
187
|
---
|
|
169
188
|
|
|
170
189
|
### Send Push Notification
|
|
@@ -197,6 +216,27 @@ await client.send_sms(SmsPayload(
|
|
|
197
216
|
|
|
198
217
|
---
|
|
199
218
|
|
|
219
|
+
### Send WhatsApp
|
|
220
|
+
|
|
221
|
+
WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from cdp_client import WhatsAppPayload, Identifiers
|
|
225
|
+
|
|
226
|
+
result = await client.send_whatsapp(WhatsAppPayload(
|
|
227
|
+
identifiers=Identifiers(id="user-123"),
|
|
228
|
+
transactional_message_id="ORDER_WHATSAPP",
|
|
229
|
+
to="+14155551234", # Optional: overrides the profile phone number
|
|
230
|
+
message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
|
|
231
|
+
))
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
|
|
235
|
+
- `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
|
|
236
|
+
- Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
200
240
|
### Clear Identity / Logout
|
|
201
241
|
|
|
202
242
|
To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
|
|
@@ -213,22 +253,25 @@ client = CDPClient(config)
|
|
|
213
253
|
|
|
214
254
|
## Error Handling
|
|
215
255
|
|
|
216
|
-
By default
|
|
256
|
+
By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
|
|
217
257
|
|
|
218
258
|
```python
|
|
219
|
-
import
|
|
259
|
+
from cdp_client import CDPError, CDPValidationError
|
|
260
|
+
|
|
261
|
+
config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
|
|
262
|
+
client = CDPClient(config)
|
|
220
263
|
|
|
221
264
|
try:
|
|
222
265
|
await client.identify("user-123", {"email": "user@example.com"})
|
|
223
|
-
except
|
|
224
|
-
#
|
|
225
|
-
print(f"
|
|
226
|
-
except
|
|
227
|
-
#
|
|
228
|
-
print(f"
|
|
266
|
+
except CDPValidationError as e:
|
|
267
|
+
# Invalid input, caught before any request is sent
|
|
268
|
+
print(f"Invalid {e.field}: {e}")
|
|
269
|
+
except CDPError as e:
|
|
270
|
+
# status_code is the HTTP status, or None when the gateway could not be reached
|
|
271
|
+
print(f"API error {e.status_code}: {e}")
|
|
229
272
|
```
|
|
230
273
|
|
|
231
|
-
>
|
|
274
|
+
> This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
|
|
232
275
|
|
|
233
276
|
---
|
|
234
277
|
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cdp-python-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Codematic Engineering", email = "engineering@codematic.io" }]
|
|
11
11
|
license = "MIT"
|
|
12
|
-
requires-python = ">=3.
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Programming Language :: Python :: 3",
|
|
15
15
|
"Operating System :: OS Independent",
|
|
@@ -34,3 +34,17 @@ exclude = ["tests*", "example*"]
|
|
|
34
34
|
[tool.pytest.ini_options]
|
|
35
35
|
asyncio_mode = "auto"
|
|
36
36
|
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py310"
|
|
40
|
+
exclude = [
|
|
41
|
+
"example",
|
|
42
|
+
"main.py",
|
|
43
|
+
".venv",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
ignore = [
|
|
48
|
+
# SDK intentionally catches Exception then uses fail_on_exception / _handle_error
|
|
49
|
+
"BLE001",
|
|
50
|
+
]
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Optional, Dict, Any, Literal
|
|
4
|
-
from pydantic import BaseModel, Field, ConfigDict
|
|
5
|
-
import logging
|
|
6
|
-
|
|
7
|
-
from .gateway_urls import DEFAULT_PRIMARY
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class CustomerIoConfig(BaseModel):
|
|
13
|
-
site_id: str = Field(..., description="Customer.io Site ID")
|
|
14
|
-
api_key: str = Field(..., description="Customer.io API Key")
|
|
15
|
-
region: Optional[Literal["us", "eu"]] = Field("us", description="Data center region")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CDPConfig(BaseModel):
|
|
19
|
-
cdp_api_key: str = Field(..., description="API Key for the CDP")
|
|
20
|
-
cdp_endpoint: str = Field(
|
|
21
|
-
DEFAULT_PRIMARY,
|
|
22
|
-
description="Primary gateway base URL",
|
|
23
|
-
)
|
|
24
|
-
cdp_fallback_endpoints: Optional[list[str]] = Field(
|
|
25
|
-
None,
|
|
26
|
-
description="Optional fallback gateway base URLs",
|
|
27
|
-
)
|
|
28
|
-
timeout_ms: int = Field(10000, description="Request timeout in milliseconds")
|
|
29
|
-
fail_on_exception: bool = Field(
|
|
30
|
-
False,
|
|
31
|
-
description="When true, raise on validation and HTTP errors",
|
|
32
|
-
)
|
|
33
|
-
send_to_customer_io: bool = Field(False, description="Enable dual-write to Customer.io")
|
|
34
|
-
customer_io: Optional[CustomerIoConfig] = Field(None, description="Customer.io configuration")
|
|
35
|
-
debug: bool = Field(False, description="Enable debug logging")
|
|
36
|
-
|
|
37
|
-
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class Identifiers(BaseModel):
|
|
41
|
-
id: Optional[str] = None
|
|
42
|
-
email: Optional[str] = None
|
|
43
|
-
cdp_id: Optional[str] = None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class BaseMessagePayload(BaseModel):
|
|
47
|
-
identifiers: Identifiers
|
|
48
|
-
transactional_message_id: Optional[str] = None
|
|
49
|
-
message_data: Optional[Dict[str, Any]] = None
|
|
50
|
-
send_at: Optional[int] = None
|
|
51
|
-
send_to_unsubscribed: Optional[bool] = None
|
|
52
|
-
tracked: Optional[bool] = None
|
|
53
|
-
disable_css_preprocessing: Optional[bool] = None
|
|
54
|
-
headers: Optional[str] = None
|
|
55
|
-
disable_message_retention: Optional[bool] = None
|
|
56
|
-
queue_draft: Optional[bool] = None
|
|
57
|
-
attachments: Optional[Dict[str, Any]] = None
|
|
58
|
-
|
|
59
|
-
def check_unsupported_fields(self):
|
|
60
|
-
unsupported = [
|
|
61
|
-
"send_at",
|
|
62
|
-
"send_to_unsubscribed",
|
|
63
|
-
"tracked",
|
|
64
|
-
"disable_css_preprocessing",
|
|
65
|
-
"headers",
|
|
66
|
-
"disable_message_retention",
|
|
67
|
-
"queue_draft",
|
|
68
|
-
"attachments",
|
|
69
|
-
]
|
|
70
|
-
found = [field for field in unsupported if getattr(self, field) is not None]
|
|
71
|
-
if found:
|
|
72
|
-
logger.warning(
|
|
73
|
-
"The following fields are not yet supported by the backend and will be ignored: %s",
|
|
74
|
-
", ".join(found),
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class EmailPayload(BaseMessagePayload):
|
|
79
|
-
to: str
|
|
80
|
-
from_: Optional[str] = Field(None, alias="from")
|
|
81
|
-
subject: Optional[str] = None
|
|
82
|
-
body: Optional[str] = None
|
|
83
|
-
body_plain: Optional[str] = None
|
|
84
|
-
reply_to: Optional[str] = None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class PushPayload(BaseMessagePayload):
|
|
88
|
-
title: Optional[str] = None
|
|
89
|
-
body: Optional[str] = None
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class SmsPayload(BaseMessagePayload):
|
|
93
|
-
to: Optional[str] = None
|
|
94
|
-
from_: Optional[str] = Field(None, alias="from")
|
|
95
|
-
body: Optional[str] = None
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
class DeviceRegistrationParameters(BaseModel):
|
|
99
|
-
device_id: str = Field(..., alias="deviceId")
|
|
100
|
-
platform: Literal["android", "ios", "web"]
|
|
101
|
-
fcm_token: str = Field(..., alias="fcmToken")
|
|
102
|
-
name: Optional[str] = None
|
|
103
|
-
os_version: Optional[str] = Field(None, alias="osVersion")
|
|
104
|
-
model: Optional[str] = None
|
|
105
|
-
apn_token: Optional[str] = Field(None, alias="apnToken")
|
|
106
|
-
app_version: Optional[str] = Field(None, alias="appVersion")
|
|
107
|
-
last_active_at: Optional[str] = None
|
|
108
|
-
attributes: Optional[Dict[str, Any]] = None
|
|
109
|
-
|
|
110
|
-
model_config = ConfigDict(populate_by_name=True)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""Input validation — aligned with Flutter SDK rules."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
|
|
7
|
-
from .errors import CDPValidationError
|
|
8
|
-
|
|
9
|
-
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def validate_identifier(identifier: str) -> str:
|
|
13
|
-
if identifier is None or str(identifier).strip() == "":
|
|
14
|
-
raise CDPValidationError("Identifier cannot be empty", "identifier")
|
|
15
|
-
value = str(identifier).strip()
|
|
16
|
-
if _EMAIL_RE.match(value):
|
|
17
|
-
raise CDPValidationError(
|
|
18
|
-
"Identifier must not be an email address",
|
|
19
|
-
"identifier",
|
|
20
|
-
)
|
|
21
|
-
return value
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def validate_event_name(event_name: str) -> str:
|
|
25
|
-
if not event_name or not event_name.strip():
|
|
26
|
-
raise CDPValidationError("Event name cannot be empty", "eventName")
|
|
27
|
-
return event_name.strip()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def validate_properties(properties: dict | None) -> dict:
|
|
31
|
-
if properties is None:
|
|
32
|
-
return {}
|
|
33
|
-
if not isinstance(properties, dict):
|
|
34
|
-
raise CDPValidationError("Properties must be a dictionary", "properties")
|
|
35
|
-
return properties
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|