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 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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cdp_client