cdp-python-sdk 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.
- cdp_client/__init__.py +22 -0
- cdp_client/client.py +220 -0
- cdp_client/errors.py +15 -0
- cdp_client/gateway_urls.py +40 -0
- cdp_client/models.py +110 -0
- cdp_client/validators.py +35 -0
- cdp_python_sdk-0.1.0.dist-info/METADATA +310 -0
- cdp_python_sdk-0.1.0.dist-info/RECORD +10 -0
- cdp_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- cdp_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
cdp_client/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .client import CDPClient
|
|
2
|
+
from .errors import CDPError, CDPValidationError
|
|
3
|
+
from .models import (
|
|
4
|
+
CDPConfig,
|
|
5
|
+
CustomerIoConfig,
|
|
6
|
+
DeviceRegistrationParameters,
|
|
7
|
+
EmailPayload,
|
|
8
|
+
PushPayload,
|
|
9
|
+
SmsPayload,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CDPClient",
|
|
14
|
+
"CDPConfig",
|
|
15
|
+
"CDPError",
|
|
16
|
+
"CDPValidationError",
|
|
17
|
+
"CustomerIoConfig",
|
|
18
|
+
"DeviceRegistrationParameters",
|
|
19
|
+
"EmailPayload",
|
|
20
|
+
"PushPayload",
|
|
21
|
+
"SmsPayload",
|
|
22
|
+
]
|
cdp_client/client.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from customerio import CustomerIO
|
|
9
|
+
|
|
10
|
+
from .errors import CDPError, CDPValidationError
|
|
11
|
+
from .gateway_urls import resolve_all_base_urls
|
|
12
|
+
from .models import (
|
|
13
|
+
CDPConfig,
|
|
14
|
+
DeviceRegistrationParameters,
|
|
15
|
+
EmailPayload,
|
|
16
|
+
PushPayload,
|
|
17
|
+
SmsPayload,
|
|
18
|
+
)
|
|
19
|
+
from .validators import validate_event_name, validate_identifier, validate_properties
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("CDPClient")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CDPClient:
|
|
25
|
+
"""Client for the OpenCDP Data Gateway with optional Customer.io dual-write."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: CDPConfig):
|
|
28
|
+
self.config = config
|
|
29
|
+
self._base_urls = resolve_all_base_urls(
|
|
30
|
+
config.cdp_endpoint,
|
|
31
|
+
config.cdp_fallback_endpoints,
|
|
32
|
+
)
|
|
33
|
+
self._timeout = config.timeout_ms / 1000.0
|
|
34
|
+
logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
|
|
35
|
+
|
|
36
|
+
self.cio: Optional[CustomerIO] = None
|
|
37
|
+
if self.config.send_to_customer_io and self.config.customer_io:
|
|
38
|
+
try:
|
|
39
|
+
self.cio = CustomerIO(
|
|
40
|
+
site_id=self.config.customer_io.site_id,
|
|
41
|
+
api_key=self.config.customer_io.api_key,
|
|
42
|
+
region=self.config.customer_io.region,
|
|
43
|
+
)
|
|
44
|
+
logger.info("Customer.io integration enabled.")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error("Failed to initialize Customer.io client: %s", e)
|
|
47
|
+
|
|
48
|
+
def _headers(self) -> dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"Authorization": self.config.cdp_api_key,
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"User-Agent": "cdp-python-sdk/0.2.0",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def _handle_error(self, message: str, exc: Exception) -> None:
|
|
56
|
+
status_code = None
|
|
57
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
58
|
+
status_code = exc.response.status_code
|
|
59
|
+
if self.config.fail_on_exception:
|
|
60
|
+
raise CDPError(message, status_code) from exc
|
|
61
|
+
logger.error("%s: %s", message, exc)
|
|
62
|
+
|
|
63
|
+
async def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
json_body: Optional[dict] = None,
|
|
69
|
+
) -> httpx.Response:
|
|
70
|
+
last_error: Optional[Exception] = None
|
|
71
|
+
async with httpx.AsyncClient(
|
|
72
|
+
timeout=self._timeout,
|
|
73
|
+
headers=self._headers(),
|
|
74
|
+
) as client:
|
|
75
|
+
for base_url in self._base_urls:
|
|
76
|
+
url = f"{base_url}{path}"
|
|
77
|
+
try:
|
|
78
|
+
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
|
+
except Exception as e:
|
|
93
|
+
last_error = e
|
|
94
|
+
if self.config.debug:
|
|
95
|
+
logger.debug("Gateway %s unreachable: %s", base_url, e)
|
|
96
|
+
assert last_error is not None
|
|
97
|
+
raise last_error
|
|
98
|
+
|
|
99
|
+
async def ping(self) -> bool:
|
|
100
|
+
try:
|
|
101
|
+
response = await self._request("GET", "/v1/health/ping")
|
|
102
|
+
return response.status_code == 200
|
|
103
|
+
except Exception as e:
|
|
104
|
+
self._handle_error("Ping failed", e)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
async def identify(self, identifier: str, properties: Optional[Dict[str, Any]] = None) -> None:
|
|
108
|
+
try:
|
|
109
|
+
validated_id = validate_identifier(identifier)
|
|
110
|
+
normalized_props = validate_properties(properties)
|
|
111
|
+
await self._request(
|
|
112
|
+
"POST",
|
|
113
|
+
"/v1/persons/identify",
|
|
114
|
+
json_body={"identifier": validated_id, "properties": normalized_props},
|
|
115
|
+
)
|
|
116
|
+
if self.cio:
|
|
117
|
+
await asyncio.to_thread(
|
|
118
|
+
self.cio.identify,
|
|
119
|
+
id=validated_id,
|
|
120
|
+
**normalized_props,
|
|
121
|
+
)
|
|
122
|
+
except CDPValidationError:
|
|
123
|
+
if self.config.fail_on_exception:
|
|
124
|
+
raise
|
|
125
|
+
logger.error("Identify validation failed for %s", identifier)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
self._handle_error(f"Error identifying user {identifier}", e)
|
|
128
|
+
|
|
129
|
+
async def track(
|
|
130
|
+
self,
|
|
131
|
+
identifier: str,
|
|
132
|
+
event_name: str,
|
|
133
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
try:
|
|
136
|
+
validated_id = validate_identifier(identifier)
|
|
137
|
+
validated_event = validate_event_name(event_name)
|
|
138
|
+
normalized_props = validate_properties(properties)
|
|
139
|
+
await self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
"/v1/persons/track",
|
|
142
|
+
json_body={
|
|
143
|
+
"identifier": validated_id,
|
|
144
|
+
"eventName": validated_event,
|
|
145
|
+
"properties": normalized_props,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
if self.cio:
|
|
149
|
+
await asyncio.to_thread(
|
|
150
|
+
self.cio.track,
|
|
151
|
+
customer_id=validated_id,
|
|
152
|
+
name=validated_event,
|
|
153
|
+
**normalized_props,
|
|
154
|
+
)
|
|
155
|
+
except CDPValidationError:
|
|
156
|
+
if self.config.fail_on_exception:
|
|
157
|
+
raise
|
|
158
|
+
logger.error("Track validation failed for %s / %s", identifier, event_name)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
self._handle_error(f"Error tracking event {event_name}", e)
|
|
161
|
+
|
|
162
|
+
async def register_device(
|
|
163
|
+
self,
|
|
164
|
+
identifier: str,
|
|
165
|
+
params: DeviceRegistrationParameters,
|
|
166
|
+
) -> None:
|
|
167
|
+
try:
|
|
168
|
+
validated_id = validate_identifier(identifier)
|
|
169
|
+
if self.cio:
|
|
170
|
+
try:
|
|
171
|
+
await asyncio.to_thread(
|
|
172
|
+
self.cio.add_device,
|
|
173
|
+
customer_id=validated_id,
|
|
174
|
+
device_id=params.device_id,
|
|
175
|
+
platform=params.platform,
|
|
176
|
+
data={
|
|
177
|
+
"token": params.fcm_token,
|
|
178
|
+
**(params.attributes or {}),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.warning("Customer.io add_device failed (non-blocking): %s", e)
|
|
183
|
+
|
|
184
|
+
body = params.model_dump(by_alias=True, exclude_none=True)
|
|
185
|
+
body["identifier"] = validated_id
|
|
186
|
+
await self._request("POST", "/v1/persons/registerDevice", json_body=body)
|
|
187
|
+
except CDPValidationError:
|
|
188
|
+
if self.config.fail_on_exception:
|
|
189
|
+
raise
|
|
190
|
+
logger.error("Register device validation failed for %s", identifier)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
self._handle_error("Error registering device", e)
|
|
193
|
+
|
|
194
|
+
async def send_email(self, payload: EmailPayload) -> None:
|
|
195
|
+
payload.check_unsupported_fields()
|
|
196
|
+
try:
|
|
197
|
+
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
198
|
+
await self._request("POST", "/v1/send/email", json_body=data)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
self._handle_error("Error sending email", e)
|
|
201
|
+
|
|
202
|
+
async def send_push(self, payload: PushPayload) -> None:
|
|
203
|
+
payload.check_unsupported_fields()
|
|
204
|
+
try:
|
|
205
|
+
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
206
|
+
await self._request("POST", "/v1/send/push", json_body=data)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
self._handle_error("Error sending push", e)
|
|
209
|
+
|
|
210
|
+
async def send_sms(self, payload: SmsPayload) -> None:
|
|
211
|
+
payload.check_unsupported_fields()
|
|
212
|
+
try:
|
|
213
|
+
data = payload.model_dump(exclude_none=True, by_alias=True)
|
|
214
|
+
await self._request("POST", "/v1/send/sms", json_body=data)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
self._handle_error("Error sending SMS", e)
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
"""No persistent client; kept for API compatibility."""
|
|
220
|
+
return None
|
cdp_client/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Typed SDK errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CDPError(Exception):
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CDPValidationError(CDPError):
|
|
13
|
+
def __init__(self, message: str, field: str | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.field = field
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Gateway URL resolution — matches Flutter CdpGatewayUrls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DEFAULT_PRIMARY = "https://api.opencdp.com/gateway/data-gateway"
|
|
6
|
+
DEFAULT_FALLBACKS = [
|
|
7
|
+
"https://api.opencdp.xyz/gateway/data-gateway",
|
|
8
|
+
"https://api.opencdp.io/gateway/data-gateway",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_base_url(url: str) -> str:
|
|
13
|
+
trimmed = url.strip()
|
|
14
|
+
if not trimmed:
|
|
15
|
+
return trimmed
|
|
16
|
+
return trimmed[:-1] if trimmed.endswith("/") else trimmed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_all_base_urls(
|
|
20
|
+
primary_override: str | None = None,
|
|
21
|
+
fallback_overrides: list[str] | None = None,
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
primary = normalize_base_url(
|
|
24
|
+
primary_override if primary_override and primary_override.strip() else DEFAULT_PRIMARY
|
|
25
|
+
)
|
|
26
|
+
fallbacks = fallback_overrides if fallback_overrides is not None else DEFAULT_FALLBACKS
|
|
27
|
+
seen: set[str] = set()
|
|
28
|
+
ordered: list[str] = []
|
|
29
|
+
|
|
30
|
+
def add(url: str) -> None:
|
|
31
|
+
normalized = normalize_base_url(url)
|
|
32
|
+
if not normalized or normalized in seen:
|
|
33
|
+
return
|
|
34
|
+
seen.add(normalized)
|
|
35
|
+
ordered.append(normalized)
|
|
36
|
+
|
|
37
|
+
add(primary)
|
|
38
|
+
for fallback in fallbacks:
|
|
39
|
+
add(fallback)
|
|
40
|
+
return ordered
|
cdp_client/models.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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)
|
cdp_client/validators.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cdp-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
|
|
5
|
+
Author-email: Codematic Engineering <engineering@codematic.io>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx>=0.24.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0.0
|
|
13
|
+
Requires-Dist: customerio>=1.1.0
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-httpx>=0.21.0; extra == "test"
|
|
18
|
+
|
|
19
|
+
# CDP Python SDK
|
|
20
|
+
|
|
21
|
+
A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cdp-python-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or via `requirements.txt`:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
cdp-python-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Async Support**: Built on `httpx` and `asyncio` for high-performance non-blocking I/O.
|
|
38
|
+
- **Dual-Write**: Optional integration to send data to both CDP and Customer.io simultaneously.
|
|
39
|
+
- **Type Safety**: Uses Pydantic models for request validation.
|
|
40
|
+
- **Transactional Messaging**: Support for Email, Push, and SMS.
|
|
41
|
+
- **Device Registration**: Register devices for push notification targeting.
|
|
42
|
+
|
|
43
|
+
## Examples
|
|
44
|
+
|
|
45
|
+
A complete runnable example is available in [`example/`](example/):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
example/
|
|
49
|
+
├── main.py # Full flow: init → identify → track → register_device → email → push → sms
|
|
50
|
+
└── README.md # How to run
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [`example/README.md`](example/README.md) for run instructions.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from cdp_client import CDPClient, CDPConfig
|
|
62
|
+
|
|
63
|
+
async def main():
|
|
64
|
+
config = CDPConfig(cdp_api_key="your-cdp-api-key")
|
|
65
|
+
client = CDPClient(config)
|
|
66
|
+
|
|
67
|
+
await client.identify("user-123", {"email": "user@example.com", "name": "Jane"})
|
|
68
|
+
await client.track("user-123", "signed_up", {"plan": "pro"})
|
|
69
|
+
|
|
70
|
+
await client.close()
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### Initialization
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from cdp_client import CDPClient, CDPConfig, CustomerIoConfig
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
config = CDPConfig(
|
|
85
|
+
cdp_api_key="your-cdp-api-key",
|
|
86
|
+
debug=True,
|
|
87
|
+
# Optional: enable Customer.io dual-write
|
|
88
|
+
send_to_customer_io=True,
|
|
89
|
+
customer_io=CustomerIoConfig(
|
|
90
|
+
site_id="your-cio-site-id",
|
|
91
|
+
api_key="your-cio-api-key",
|
|
92
|
+
region="us" # "us" or "eu"
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
client = CDPClient(config)
|
|
96
|
+
|
|
97
|
+
# ... use client ...
|
|
98
|
+
|
|
99
|
+
await client.close()
|
|
100
|
+
|
|
101
|
+
asyncio.run(main())
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### Identify a User
|
|
107
|
+
|
|
108
|
+
Associate a user ID with a set of traits (name, email, plan, etc.). Call this on sign-up, login, or whenever user attributes change.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
await client.identify("user-123", {
|
|
112
|
+
"email": "user@example.com",
|
|
113
|
+
"name": "Jane Doe",
|
|
114
|
+
"plan": "premium"
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### Track an Event
|
|
121
|
+
|
|
122
|
+
Record a user action or behaviour.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
await client.track("user-123", "purchase_completed", {
|
|
126
|
+
"amount": 99.99,
|
|
127
|
+
"currency": "USD",
|
|
128
|
+
"item_id": "prod-456"
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### Register a Device
|
|
135
|
+
|
|
136
|
+
Register a device token for push notification targeting. Call this after you receive a push token from your mobile platform.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
await client.register_device(
|
|
140
|
+
user_id="user-123",
|
|
141
|
+
device_id="device-abc", # Unique device identifier
|
|
142
|
+
platform="ios", # "ios", "android", or "web"
|
|
143
|
+
token="apns-or-fcm-token",
|
|
144
|
+
attributes={ # Optional device metadata
|
|
145
|
+
"app_version": "2.1.0",
|
|
146
|
+
"os_version": "17.2"
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### Send Transactional Email
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from cdp_client import EmailPayload, Identifiers
|
|
157
|
+
|
|
158
|
+
await client.send_email(EmailPayload(
|
|
159
|
+
to="user@example.com",
|
|
160
|
+
identifiers=Identifiers(id="user-123"),
|
|
161
|
+
transactional_message_id="WELCOME_EMAIL",
|
|
162
|
+
subject="Welcome!",
|
|
163
|
+
body="<h1>Thanks for joining!</h1>",
|
|
164
|
+
body_plain="Thanks for joining!"
|
|
165
|
+
))
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### Send Push Notification
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from cdp_client import PushPayload, Identifiers
|
|
174
|
+
|
|
175
|
+
await client.send_push(PushPayload(
|
|
176
|
+
identifiers=Identifiers(id="user-123"),
|
|
177
|
+
transactional_message_id="PROMO_PUSH",
|
|
178
|
+
title="Flash Sale 🔥",
|
|
179
|
+
body="50% off for the next hour."
|
|
180
|
+
))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### Send SMS
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from cdp_client import SmsPayload, Identifiers
|
|
189
|
+
|
|
190
|
+
await client.send_sms(SmsPayload(
|
|
191
|
+
identifiers=Identifiers(id="user-123"),
|
|
192
|
+
to="+14155551234", # Optional: raw phone number
|
|
193
|
+
transactional_message_id="OTP_MSG",
|
|
194
|
+
body="Your one-time code is 881234."
|
|
195
|
+
))
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
### Clear Identity / Logout
|
|
201
|
+
|
|
202
|
+
To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
# On user logout: flush pending work and release the HTTP client
|
|
206
|
+
await client.close()
|
|
207
|
+
|
|
208
|
+
# Re-initialize for anonymous or new user session
|
|
209
|
+
client = CDPClient(config)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Error Handling
|
|
215
|
+
|
|
216
|
+
By default, the Python SDK raises exceptions on HTTP errors or network failures. Wrap calls in `try/except` to handle them gracefully:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
import httpx
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
await client.identify("user-123", {"email": "user@example.com"})
|
|
223
|
+
except httpx.HTTPStatusError as e:
|
|
224
|
+
# Server returned 4xx or 5xx
|
|
225
|
+
print(f"API error {e.response.status_code}: {e.response.text}")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
# Network error, timeout, etc.
|
|
228
|
+
print(f"Unexpected error: {e}")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
> All methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `register_device`) raise on failure. Dual-write Customer.io errors are non-fatal and only emit a warning log.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Configuration Options
|
|
236
|
+
|
|
237
|
+
| Option | Type | Default | Description |
|
|
238
|
+
|--------|------|---------|-------------|
|
|
239
|
+
| `cdp_api_key` | `str` | **Required** | Your CDP API Key |
|
|
240
|
+
| `cdp_endpoint` | `str` | Production URL | Custom CDP Gateway URL |
|
|
241
|
+
| `debug` | `bool` | `False` | Enable verbose debug logging |
|
|
242
|
+
| `send_to_customer_io` | `bool` | `False` | Enable dual-write to Customer.io |
|
|
243
|
+
| `customer_io` | `CustomerIoConfig` | `None` | Customer.io integration config (see below) |
|
|
244
|
+
|
|
245
|
+
### `CustomerIoConfig` Options
|
|
246
|
+
|
|
247
|
+
| Option | Type | Description |
|
|
248
|
+
|--------|------|-------------|
|
|
249
|
+
| `site_id` | `str` | Customer.io Site ID |
|
|
250
|
+
| `api_key` | `str` | Customer.io API Key |
|
|
251
|
+
| `region` | `str` | `"us"` (default) or `"eu"` |
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Dual-Write to Customer.io
|
|
256
|
+
|
|
257
|
+
When `send_to_customer_io=True`, all `identify`, `track`, and `register_device` calls are mirrored to Customer.io automatically. Customer.io failures are **non-blocking** — the CDP call succeeds even if the Customer.io call fails.
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
config = CDPConfig(
|
|
261
|
+
cdp_api_key="your-cdp-api-key",
|
|
262
|
+
send_to_customer_io=True,
|
|
263
|
+
customer_io=CustomerIoConfig(
|
|
264
|
+
site_id="cio-site-id",
|
|
265
|
+
api_key="cio-api-key",
|
|
266
|
+
region="eu"
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Development
|
|
274
|
+
|
|
275
|
+
### Setup
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
python3 -m venv venv
|
|
279
|
+
source venv/bin/activate
|
|
280
|
+
python -m pip install --upgrade pip setuptools wheel build
|
|
281
|
+
python -m pip install -e ".[test]"
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Run Tests
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
pytest -v
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Building the Package
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
python -m build
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
This creates files in the `dist/` directory:
|
|
297
|
+
- `cdp_python_sdk-{version}-py3-none-any.whl`
|
|
298
|
+
- `cdp_python_sdk-{version}.tar.gz`
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Versioning
|
|
303
|
+
|
|
304
|
+
Follow [Semantic Versioning](https://semver.org/):
|
|
305
|
+
|
|
306
|
+
| Bump | When |
|
|
307
|
+
|------|------|
|
|
308
|
+
| **PATCH** `1.0.0 → 1.0.1` | Bug fixes |
|
|
309
|
+
| **MINOR** `1.0.0 → 1.1.0` | New features, backward compatible |
|
|
310
|
+
| **MAJOR** `1.0.0 → 2.0.0` | Breaking API changes |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cdp_client/__init__.py,sha256=e5RV_Us_IdqebbQjPynufcM5i41oba0EomvUwJpLxOE,433
|
|
2
|
+
cdp_client/client.py,sha256=wVBufNCh4gDCU1Dlfua0zalC8yfc0QCJ9Ja-j_qEIE4,8275
|
|
3
|
+
cdp_client/errors.py,sha256=a_TmPv2gxKDy3BlgYhgSmQBSvrcTNbXWVMYH6DNLWqg,395
|
|
4
|
+
cdp_client/gateway_urls.py,sha256=08B8Y3OIc48T3KHiHLndD-R8j3tI43YFywd1eyRw6Ek,1184
|
|
5
|
+
cdp_client/models.py,sha256=Egrdq9tugatsJRM_sqv4350XNaleZM2KRsaeFLK-YRQ,3690
|
|
6
|
+
cdp_client/validators.py,sha256=MDjTrEqj-ALkuuDgMZcUZ9mKeueKny2SEgeYetFZelc,1054
|
|
7
|
+
cdp_python_sdk-0.1.0.dist-info/METADATA,sha256=cuDbg7ViwrZ6tlsb6JO-2uqSrcadnl5JSNBCbb0lghU,7495
|
|
8
|
+
cdp_python_sdk-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
cdp_python_sdk-0.1.0.dist-info/top_level.txt,sha256=jB1SkK6Q3zW7mSoeDfKRHQM2UZCUkDzD5A4TfJrOkZs,11
|
|
10
|
+
cdp_python_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cdp_client
|