customerio 3.0.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.
customerio/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from customerio.api import (
2
+ APIClient,
3
+ SendEmailRequest,
4
+ SendInAppRequest,
5
+ SendInboxMessageRequest,
6
+ SendPushRequest,
7
+ SendSMSRequest,
8
+ )
9
+ from customerio.client_base import CustomerIOException
10
+ from customerio.regions import Regions
11
+ from customerio.track import CustomerIO
12
+
13
+ __all__ = [
14
+ "APIClient",
15
+ "CustomerIO",
16
+ "CustomerIOException",
17
+ "Regions",
18
+ "SendEmailRequest",
19
+ "SendInAppRequest",
20
+ "SendInboxMessageRequest",
21
+ "SendPushRequest",
22
+ "SendSMSRequest",
23
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "3.0.0"
customerio/api.py ADDED
@@ -0,0 +1,328 @@
1
+ """
2
+ Implements the client that interacts with Customer.io's App API using app keys.
3
+ """
4
+
5
+ import base64
6
+
7
+ from .client_base import ClientBase, CustomerIOException
8
+ from .regions import Region, Regions
9
+
10
+
11
+ def _payload_from_fields(source, field_map):
12
+ return {
13
+ name: value
14
+ for field, name in field_map.items()
15
+ if (value := getattr(source, field, None)) is not None
16
+ }
17
+
18
+
19
+ COMMON_MESSAGE_FIELD_MAP = {
20
+ "transactional_message_id": "transactional_message_id",
21
+ "identifiers": "identifiers",
22
+ "disable_message_retention": "disable_message_retention",
23
+ "queue_draft": "queue_draft",
24
+ "message_data": "message_data",
25
+ "send_at": "send_at",
26
+ "language": "language",
27
+ }
28
+
29
+ EMAIL_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | {
30
+ # from is a reserved keyword, so the object field is `_from`.
31
+ "_from": "from",
32
+ "to": "to",
33
+ "headers": "headers",
34
+ "reply_to": "reply_to",
35
+ "bcc": "bcc",
36
+ "subject": "subject",
37
+ "preheader": "preheader",
38
+ "body": "body",
39
+ "body_plain": "body_plain",
40
+ "body_amp": "body_amp",
41
+ "fake_bcc": "fake_bcc",
42
+ "send_to_unsubscribed": "send_to_unsubscribed",
43
+ "tracked": "tracked",
44
+ "attachments": "attachments",
45
+ "disable_css_preprocessing": "disable_css_preprocessing",
46
+ }
47
+
48
+ PUSH_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | {
49
+ "to": "to",
50
+ "send_to_unsubscribed": "send_to_unsubscribed",
51
+ "title": "title",
52
+ "message": "message",
53
+ "image_url": "image_url",
54
+ "link": "link",
55
+ "custom_data": "custom_data",
56
+ "custom_payload": "custom_payload",
57
+ "device": "custom_device",
58
+ "sound": "sound",
59
+ }
60
+
61
+ SMS_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | {
62
+ "to": "to",
63
+ "send_to_unsubscribed": "send_to_unsubscribed",
64
+ }
65
+
66
+ INBOX_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP
67
+ IN_APP_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP
68
+
69
+
70
+ class APIClient(ClientBase):
71
+ def __init__(
72
+ self,
73
+ key,
74
+ url=None,
75
+ region=Regions.US,
76
+ retries=3,
77
+ timeout=10,
78
+ backoff_factor=0.02,
79
+ use_connection_pooling=True,
80
+ ):
81
+ if not isinstance(region, Region):
82
+ raise CustomerIOException("invalid region provided")
83
+
84
+ self.key = key
85
+ self.url = url or f"https://{region.api_host}"
86
+ super().__init__(
87
+ retries=retries,
88
+ timeout=timeout,
89
+ backoff_factor=backoff_factor,
90
+ use_connection_pooling=use_connection_pooling,
91
+ )
92
+
93
+ def send_email(self, request):
94
+ if isinstance(request, SendEmailRequest):
95
+ request = request._to_dict()
96
+ resp = self.send_request("POST", self.url + "/v1/send/email", request)
97
+ return resp.json()
98
+
99
+ def send_push(self, request):
100
+ if isinstance(request, SendPushRequest):
101
+ request = request._to_dict()
102
+ resp = self.send_request("POST", self.url + "/v1/send/push", request)
103
+ return resp.json()
104
+
105
+ def send_sms(self, request):
106
+ if isinstance(request, SendSMSRequest):
107
+ request = request._to_dict()
108
+ resp = self.send_request("POST", self.url + "/v1/send/sms", request)
109
+ return resp.json()
110
+
111
+ def send_inbox_message(self, request):
112
+ if isinstance(request, SendInboxMessageRequest):
113
+ request = request._to_dict()
114
+ resp = self.send_request("POST", self.url + "/v1/send/inbox_message", request)
115
+ return resp.json()
116
+
117
+ def send_in_app(self, request):
118
+ if isinstance(request, SendInAppRequest):
119
+ request = request._to_dict()
120
+ resp = self.send_request("POST", self.url + "/v1/send/in_app", request)
121
+ return resp.json()
122
+
123
+ def _build_session(self):
124
+ session = super()._build_session()
125
+ session.headers["Authorization"] = f"Bearer {self.key}"
126
+
127
+ return session
128
+
129
+
130
+ class SendEmailRequest:
131
+ """An object with all the options available for triggering a transactional email message."""
132
+
133
+ def __init__(
134
+ self,
135
+ transactional_message_id=None,
136
+ to=None,
137
+ identifiers=None,
138
+ _from=None,
139
+ headers=None,
140
+ reply_to=None,
141
+ bcc=None,
142
+ subject=None,
143
+ preheader=None,
144
+ body=None,
145
+ body_plain=None,
146
+ body_amp=None,
147
+ fake_bcc=None,
148
+ disable_message_retention=None,
149
+ send_to_unsubscribed=None,
150
+ tracked=None,
151
+ queue_draft=None,
152
+ message_data=None,
153
+ attachments=None,
154
+ disable_css_preprocessing=None,
155
+ send_at=None,
156
+ language=None,
157
+ ):
158
+ self.transactional_message_id = transactional_message_id
159
+ self.to = to
160
+ self.identifiers = identifiers
161
+ self._from = _from
162
+ self.headers = headers
163
+ self.reply_to = reply_to
164
+ self.bcc = bcc
165
+ self.subject = subject
166
+ self.preheader = preheader
167
+ self.body = body
168
+ self.body_plain = body_plain
169
+ self.body_amp = body_amp
170
+ self.fake_bcc = fake_bcc
171
+ self.disable_message_retention = disable_message_retention
172
+ self.send_to_unsubscribed = send_to_unsubscribed
173
+ self.tracked = tracked
174
+ self.queue_draft = queue_draft
175
+ self.message_data = message_data
176
+ self.attachments = attachments
177
+ self.disable_css_preprocessing = disable_css_preprocessing
178
+ self.send_at = send_at
179
+ self.language = language
180
+
181
+ def attach(self, name, content, encode=True):
182
+ """Helper method to add base64-encoded attachments."""
183
+ if not self.attachments:
184
+ self.attachments = {}
185
+
186
+ if name in self.attachments:
187
+ raise CustomerIOException(f"attachment {name} already exists")
188
+
189
+ if encode:
190
+ if isinstance(content, str):
191
+ content = base64.b64encode(content.encode("utf-8")).decode()
192
+ else:
193
+ content = base64.b64encode(content).decode()
194
+
195
+ self.attachments[name] = content
196
+
197
+ def _to_dict(self):
198
+ """Build a request payload from the object."""
199
+ return _payload_from_fields(self, EMAIL_FIELD_MAP)
200
+
201
+
202
+ class SendPushRequest:
203
+ """An object with all the options available for triggering a transactional push message."""
204
+
205
+ def __init__(
206
+ self,
207
+ transactional_message_id=None,
208
+ to=None,
209
+ identifiers=None,
210
+ title=None,
211
+ message=None,
212
+ disable_message_retention=None,
213
+ send_to_unsubscribed=None,
214
+ queue_draft=None,
215
+ message_data=None,
216
+ send_at=None,
217
+ language=None,
218
+ image_url=None,
219
+ link=None,
220
+ custom_data=None,
221
+ custom_payload=None,
222
+ device=None,
223
+ sound=None,
224
+ ):
225
+ self.transactional_message_id = transactional_message_id
226
+ self.to = to
227
+ self.identifiers = identifiers
228
+ self.disable_message_retention = disable_message_retention
229
+ self.send_to_unsubscribed = send_to_unsubscribed
230
+ self.queue_draft = queue_draft
231
+ self.message_data = message_data
232
+ self.send_at = send_at
233
+ self.language = language
234
+
235
+ self.title = title
236
+ self.message = message
237
+ self.image_url = image_url
238
+ self.link = link
239
+ self.custom_data = custom_data
240
+ self.custom_payload = custom_payload
241
+ self.device = device
242
+ self.sound = sound
243
+
244
+ def _to_dict(self):
245
+ """Build a request payload from the object."""
246
+ return _payload_from_fields(self, PUSH_FIELD_MAP)
247
+
248
+
249
+ class SendSMSRequest:
250
+ """An object with all the options available for triggering a transactional SMS message."""
251
+
252
+ def __init__(
253
+ self,
254
+ transactional_message_id=None,
255
+ to=None,
256
+ identifiers=None,
257
+ disable_message_retention=None,
258
+ send_to_unsubscribed=None,
259
+ queue_draft=None,
260
+ message_data=None,
261
+ send_at=None,
262
+ language=None,
263
+ ):
264
+ self.transactional_message_id = transactional_message_id
265
+ self.to = to
266
+ self.identifiers = identifiers
267
+ self.disable_message_retention = disable_message_retention
268
+ self.send_to_unsubscribed = send_to_unsubscribed
269
+ self.queue_draft = queue_draft
270
+ self.message_data = message_data
271
+ self.send_at = send_at
272
+ self.language = language
273
+
274
+ def _to_dict(self):
275
+ """Build a request payload from the object."""
276
+ return _payload_from_fields(self, SMS_FIELD_MAP)
277
+
278
+
279
+ class SendInboxMessageRequest:
280
+ """An object with all the options available for triggering a transactional inbox message."""
281
+
282
+ def __init__(
283
+ self,
284
+ transactional_message_id=None,
285
+ identifiers=None,
286
+ disable_message_retention=None,
287
+ queue_draft=None,
288
+ message_data=None,
289
+ send_at=None,
290
+ language=None,
291
+ ):
292
+ self.transactional_message_id = transactional_message_id
293
+ self.identifiers = identifiers
294
+ self.disable_message_retention = disable_message_retention
295
+ self.queue_draft = queue_draft
296
+ self.message_data = message_data
297
+ self.send_at = send_at
298
+ self.language = language
299
+
300
+ def _to_dict(self):
301
+ """Build a request payload from the object."""
302
+ return _payload_from_fields(self, INBOX_FIELD_MAP)
303
+
304
+
305
+ class SendInAppRequest:
306
+ """An object with all the options available for triggering a transactional in-app message."""
307
+
308
+ def __init__(
309
+ self,
310
+ transactional_message_id=None,
311
+ identifiers=None,
312
+ disable_message_retention=None,
313
+ queue_draft=None,
314
+ message_data=None,
315
+ send_at=None,
316
+ language=None,
317
+ ):
318
+ self.transactional_message_id = transactional_message_id
319
+ self.identifiers = identifiers
320
+ self.disable_message_retention = disable_message_retention
321
+ self.queue_draft = queue_draft
322
+ self.message_data = message_data
323
+ self.send_at = send_at
324
+ self.language = language
325
+
326
+ def _to_dict(self):
327
+ """Build a request payload from the object."""
328
+ return _payload_from_fields(self, IN_APP_FIELD_MAP)
@@ -0,0 +1,151 @@
1
+ """
2
+ Implements the base client that is used by other classes to make requests.
3
+ """
4
+
5
+ import math
6
+ import socket
7
+ from datetime import datetime, timezone
8
+
9
+ from requests import Session
10
+ from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
11
+ from urllib3.connection import HTTPConnection
12
+ from urllib3.util.retry import Retry
13
+
14
+ from .__version__ import __version__ as ClientVersion
15
+
16
+ TCP_KEEPALIVE_IDLE_TIMEOUT = 300
17
+ TCP_KEEPALIVE_INTERVAL = 60
18
+
19
+
20
+ def _tcp_keepalive_socket_options():
21
+ tcp_protocol = getattr(socket, "SOL_TCP", socket.IPPROTO_TCP)
22
+ tcp_keepidle = getattr(socket, "TCP_KEEPIDLE", getattr(socket, "TCP_KEEPALIVE", None))
23
+
24
+ options = list(HTTPConnection.default_socket_options)
25
+ keepalive_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
26
+ if tcp_keepidle is not None:
27
+ keepalive_options.append((tcp_protocol, tcp_keepidle, TCP_KEEPALIVE_IDLE_TIMEOUT))
28
+ if hasattr(socket, "TCP_KEEPINTVL"):
29
+ keepalive_options.append((tcp_protocol, socket.TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL))
30
+
31
+ for option in keepalive_options:
32
+ if option not in options:
33
+ options.append(option)
34
+
35
+ return options
36
+
37
+
38
+ class TCPKeepAliveHTTPAdapter(HTTPAdapter):
39
+ def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs):
40
+ pool_kwargs.setdefault("socket_options", _tcp_keepalive_socket_options())
41
+ super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
42
+
43
+ def proxy_manager_for(self, proxy, **proxy_kwargs):
44
+ proxy_kwargs.setdefault("socket_options", _tcp_keepalive_socket_options())
45
+ return super().proxy_manager_for(proxy, **proxy_kwargs)
46
+
47
+
48
+ class CustomerIOException(Exception):
49
+ pass
50
+
51
+
52
+ class ClientBase:
53
+ def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True):
54
+ self.timeout = timeout
55
+ self.retries = retries
56
+ self.backoff_factor = backoff_factor
57
+ self.use_connection_pooling = use_connection_pooling
58
+ self._current_session = None
59
+
60
+ def __enter__(self):
61
+ return self
62
+
63
+ def __exit__(self, *args):
64
+ self.close()
65
+
66
+ def close(self):
67
+ if self._current_session is not None:
68
+ try:
69
+ self._current_session.close()
70
+ finally:
71
+ self._current_session = None
72
+
73
+ @property
74
+ def http(self):
75
+ if self._current_session is None:
76
+ self._current_session = self._build_session()
77
+
78
+ return self._current_session
79
+
80
+ def send_request(self, method, url, data):
81
+ """Dispatches the request and returns a response."""
82
+
83
+ try:
84
+ if self.use_connection_pooling:
85
+ response = self.http.request(
86
+ method,
87
+ url=url,
88
+ json=self._sanitize(data),
89
+ timeout=self.timeout,
90
+ )
91
+ else:
92
+ with self._build_session() as http:
93
+ response = http.request(
94
+ method,
95
+ url=url,
96
+ json=self._sanitize(data),
97
+ timeout=self.timeout,
98
+ )
99
+
100
+ result_status = response.status_code
101
+ if result_status < 200 or result_status >= 300:
102
+ raise CustomerIOException(f"{result_status}: {url} {data} {response.text}")
103
+ return response
104
+
105
+ except CustomerIOException:
106
+ raise
107
+ except Exception as e:
108
+ message = (
109
+ f"Failed to receive valid response after {self.retries} retries.\n"
110
+ f"Check system status at http://status.customer.io.\n"
111
+ f"Last caught exception -- {type(e)}: {e}"
112
+ )
113
+ raise CustomerIOException(message) from e
114
+
115
+ def _sanitize(self, data):
116
+ return {key: self._sanitize_value(value) for key, value in data.items()}
117
+
118
+ def _sanitize_value(self, value):
119
+ if isinstance(value, datetime):
120
+ return self._datetime_to_timestamp(value)
121
+ if isinstance(value, float) and math.isnan(value):
122
+ return None
123
+ return value
124
+
125
+ def _datetime_to_timestamp(self, dt):
126
+ return int(dt.replace(tzinfo=timezone.utc).timestamp())
127
+
128
+ def _stringify_list(self, customer_ids):
129
+ customer_string_ids = []
130
+ for v in customer_ids:
131
+ if isinstance(v, str):
132
+ customer_string_ids.append(v)
133
+ elif isinstance(v, int):
134
+ customer_string_ids.append(str(v))
135
+ else:
136
+ raise CustomerIOException(f"customer_ids cannot be {type(v)}")
137
+ return customer_string_ids
138
+
139
+ def _build_session(self):
140
+ session = Session()
141
+ session.headers["User-Agent"] = f"Customer.io Python Client/{ClientVersion}"
142
+
143
+ retry = Retry(
144
+ total=self.retries,
145
+ backoff_factor=self.backoff_factor,
146
+ allowed_methods=None,
147
+ status_forcelist=[500, 502, 503, 504],
148
+ )
149
+ session.mount("https://", TCPKeepAliveHTTPAdapter(max_retries=retry))
150
+
151
+ return session
@@ -0,0 +1,5 @@
1
+ ## Identifier types
2
+
3
+ ID = "id"
4
+ EMAIL = "email"
5
+ CIOID = "cio_id"
customerio/regions.py ADDED
@@ -0,0 +1,8 @@
1
+ from collections import namedtuple
2
+
3
+ Region = namedtuple("Region", ["name", "track_host", "api_host"])
4
+
5
+
6
+ class Regions:
7
+ US = Region("us", "track.customer.io", "api.customer.io")
8
+ EU = Region("eu", "track-eu.customer.io", "api-eu.customer.io")
customerio/track.py ADDED
@@ -0,0 +1,271 @@
1
+ """
2
+ Implements the client that interacts with Customer.io's Track API using Site ID and API Keys.
3
+ """
4
+
5
+ import warnings
6
+ from datetime import datetime
7
+ from urllib.parse import quote
8
+
9
+ from customerio.constants import CIOID, EMAIL, ID
10
+
11
+ from .client_base import ClientBase, CustomerIOException
12
+ from .regions import Region, Regions
13
+
14
+
15
+ class CustomerIO(ClientBase):
16
+ def __init__(
17
+ self,
18
+ site_id=None,
19
+ api_key=None,
20
+ host=None,
21
+ region=Regions.US,
22
+ port=None,
23
+ url_prefix=None,
24
+ json_encoder=None,
25
+ retries=3,
26
+ timeout=10,
27
+ backoff_factor=0.02,
28
+ use_connection_pooling=True,
29
+ ):
30
+ if not isinstance(region, Region):
31
+ raise CustomerIOException("invalid region provided")
32
+
33
+ self.host = host or region.track_host
34
+ self.port = port or 443
35
+ self.url_prefix = url_prefix or "/api/v1"
36
+ self.api_key = api_key
37
+ self.site_id = site_id
38
+
39
+ if json_encoder is not None:
40
+ warnings.warn(
41
+ "With the switch to using requests library the `json_encoder` param is no longer used.",
42
+ DeprecationWarning,
43
+ stacklevel=2,
44
+ )
45
+
46
+ self.setup_base_url()
47
+ super().__init__(
48
+ retries=retries,
49
+ timeout=timeout,
50
+ backoff_factor=backoff_factor,
51
+ use_connection_pooling=use_connection_pooling,
52
+ )
53
+
54
+ def _url_encode(self, id):
55
+ return quote(str(id), safe="")
56
+
57
+ def setup_base_url(self):
58
+ template = "https://{host}:{port}/{prefix}"
59
+ if self.port == 443:
60
+ template = "https://{host}/{prefix}"
61
+
62
+ if "://" in self.host:
63
+ self.host = self.host.split("://")[1]
64
+
65
+ self.base_url = template.format(
66
+ host=self.host.strip("/"),
67
+ port=self.port,
68
+ prefix=self.url_prefix.strip("/"),
69
+ )
70
+
71
+ def get_customer_query_string(self, customer_id):
72
+ """Generates a customer API path."""
73
+ return f"{self.base_url}/customers/{self._url_encode(customer_id)}"
74
+
75
+ def get_event_query_string(self, customer_id):
76
+ """Generates an event API path."""
77
+ return f"{self.base_url}/customers/{self._url_encode(customer_id)}/events"
78
+
79
+ def get_events_query_string(self):
80
+ """Returns the events API path."""
81
+ return f"{self.base_url}/events"
82
+
83
+ def get_device_query_string(self, customer_id):
84
+ """Generates a device API path."""
85
+ return f"{self.base_url}/customers/{self._url_encode(customer_id)}/devices"
86
+
87
+ def identify(self, id, **kwargs):
88
+ """Identify a single customer by their unique id, and optionally add attributes."""
89
+ if not id:
90
+ raise CustomerIOException("id cannot be blank in identify")
91
+ url = self.get_customer_query_string(id)
92
+ return self.send_request("PUT", url, kwargs)
93
+
94
+ def track(self, customer_id, name, data=None, id=None, timestamp=None):
95
+ """Track an event for a given customer_id."""
96
+ if not customer_id:
97
+ raise CustomerIOException("customer_id cannot be blank in track")
98
+ url = self.get_event_query_string(customer_id)
99
+ post_data = self._build_event(name, data, id=id, timestamp=timestamp)
100
+ return self.send_request("POST", url, post_data)
101
+
102
+ def track_anonymous(self, anonymous_id, name, data=None, id=None, timestamp=None):
103
+ """Track an event for a given anonymous_id."""
104
+ url = self.get_events_query_string()
105
+ post_data = self._build_event(name, data, id=id, timestamp=timestamp)
106
+ if anonymous_id:
107
+ post_data["anonymous_id"] = anonymous_id
108
+
109
+ return self.send_request("POST", url, post_data)
110
+
111
+ def pageview(self, customer_id, page, **data):
112
+ """Track a pageview for a given customer_id."""
113
+ if not customer_id:
114
+ raise CustomerIOException("customer_id cannot be blank in pageview")
115
+ url = self.get_event_query_string(customer_id)
116
+ post_data = {
117
+ "type": "page",
118
+ "name": page,
119
+ "data": self._sanitize(data),
120
+ }
121
+ return self.send_request("POST", url, post_data)
122
+
123
+ def backfill(self, customer_id, name, timestamp, **data):
124
+ """Backfill an event (track with timestamp) for a given customer_id."""
125
+ if not customer_id:
126
+ raise CustomerIOException("customer_id cannot be blank in backfill")
127
+
128
+ url = self.get_event_query_string(customer_id)
129
+
130
+ if isinstance(timestamp, datetime):
131
+ timestamp = self._datetime_to_timestamp(timestamp)
132
+ elif not isinstance(timestamp, int):
133
+ try:
134
+ timestamp = int(timestamp)
135
+ except Exception as e:
136
+ raise CustomerIOException(f"{timestamp} is not a valid timestamp ({e})") from e
137
+
138
+ post_data = {
139
+ "name": name,
140
+ "data": self._sanitize(data),
141
+ "timestamp": timestamp,
142
+ }
143
+
144
+ return self.send_request("POST", url, post_data)
145
+
146
+ def _build_event(self, name, data=None, id=None, timestamp=None):
147
+ post_data = {
148
+ "name": name,
149
+ "data": self._sanitize(data or {}),
150
+ }
151
+ if id is not None:
152
+ post_data["id"] = id
153
+ if timestamp is not None:
154
+ if isinstance(timestamp, datetime):
155
+ timestamp = self._datetime_to_timestamp(timestamp)
156
+ elif isinstance(timestamp, int):
157
+ pass
158
+ else:
159
+ try:
160
+ timestamp = int(timestamp)
161
+ except (ValueError, TypeError, OverflowError):
162
+ timestamp = None
163
+ if timestamp is not None:
164
+ post_data["timestamp"] = timestamp
165
+ return post_data
166
+
167
+ def delete(self, customer_id):
168
+ """Delete a customer profile."""
169
+ if not customer_id:
170
+ raise CustomerIOException("customer_id cannot be blank in delete")
171
+
172
+ url = self.get_customer_query_string(customer_id)
173
+ return self.send_request("DELETE", url, {})
174
+
175
+ def add_device(self, customer_id, device_id, platform, **data):
176
+ """Add a device to a customer profile."""
177
+ if not customer_id:
178
+ raise CustomerIOException("customer_id cannot be blank in add_device")
179
+
180
+ if not device_id:
181
+ raise CustomerIOException("device_id cannot be blank in add_device")
182
+
183
+ if not platform:
184
+ raise CustomerIOException("platform cannot be blank in add_device")
185
+
186
+ data.update(
187
+ {
188
+ "id": device_id,
189
+ "platform": platform,
190
+ }
191
+ )
192
+ payload = {"device": data}
193
+ url = self.get_device_query_string(customer_id)
194
+ return self.send_request("PUT", url, payload)
195
+
196
+ def delete_device(self, customer_id, device_id):
197
+ """Delete a device from a customer profile."""
198
+ if not customer_id:
199
+ raise CustomerIOException("customer_id cannot be blank in delete_device")
200
+
201
+ if not device_id:
202
+ raise CustomerIOException("device_id cannot be blank in delete_device")
203
+
204
+ url = self.get_device_query_string(customer_id)
205
+ delete_url = f"{url}/{self._url_encode(device_id)}"
206
+ return self.send_request("DELETE", delete_url, {})
207
+
208
+ def suppress(self, customer_id):
209
+ if not customer_id:
210
+ raise CustomerIOException("customer_id cannot be blank in suppress")
211
+
212
+ return self.send_request(
213
+ "POST",
214
+ f"{self.base_url}/customers/{self._url_encode(customer_id)}/suppress",
215
+ {},
216
+ )
217
+
218
+ def unsuppress(self, customer_id):
219
+ if not customer_id:
220
+ raise CustomerIOException("customer_id cannot be blank in unsuppress")
221
+
222
+ return self.send_request(
223
+ "POST",
224
+ f"{self.base_url}/customers/{self._url_encode(customer_id)}/unsuppress",
225
+ {},
226
+ )
227
+
228
+ def is_valid_id_type(self, id_type):
229
+ return id_type in {ID, EMAIL, CIOID}
230
+
231
+ def merge_customers(self, primary_id_type, primary_id, secondary_id_type, secondary_id):
232
+ """Merge secondary profile into primary profile."""
233
+ if not self.is_valid_id_type(primary_id_type):
234
+ raise CustomerIOException("invalid primary id type")
235
+
236
+ if not self.is_valid_id_type(secondary_id_type):
237
+ raise CustomerIOException("invalid secondary id type")
238
+
239
+ if not primary_id:
240
+ raise CustomerIOException("primary customer_id cannot be blank")
241
+
242
+ if not secondary_id:
243
+ raise CustomerIOException("secondary customer_id cannot be blank")
244
+
245
+ url = f"{self.base_url}/merge_customers"
246
+ post_data = {
247
+ "primary": {primary_id_type: primary_id},
248
+ "secondary": {secondary_id_type: secondary_id},
249
+ }
250
+ return self.send_request("POST", url, post_data)
251
+
252
+ def batch(self, operations):
253
+ """Send multiple operations in a single request.
254
+
255
+ Each operation is a dict with at minimum 'type' and 'action' keys.
256
+ See https://customer.io/docs/api/track/#operation/batch
257
+ """
258
+ if not operations:
259
+ raise CustomerIOException("operations cannot be empty in batch")
260
+
261
+ if self.port == 443:
262
+ url = f"https://{self.host}/api/v2/batch"
263
+ else:
264
+ url = f"https://{self.host}:{self.port}/api/v2/batch"
265
+ return self.send_request("POST", url, {"batch": operations})
266
+
267
+ def _build_session(self):
268
+ session = super()._build_session()
269
+ session.auth = (self.site_id, self.api_key)
270
+
271
+ return session
@@ -0,0 +1,341 @@
1
+ Metadata-Version: 2.4
2
+ Name: customerio
3
+ Version: 3.0.0
4
+ Summary: Customer.io Python bindings.
5
+ Author-email: "Peaberry Software Inc." <support@customerio.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/customerio/customerio-python
8
+ Project-URL: Changelog, https://github.com/customerio/customerio-python/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/customerio/customerio-python/issues
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: urllib3>=2.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2.2; extra == "dev"
29
+ Requires-Dist: ruff>=0.15.12; extra == "dev"
30
+ Requires-Dist: twine>=6.1.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ <p align=center>
34
+ <a href="https://customer.io">
35
+ <img src="https://avatars.githubusercontent.com/u/1152079?s=200&v=4" height="60">
36
+ </a>
37
+ </p>
38
+
39
+ [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blueviolet?logo=gitpod)](https://gitpod.io/#https://github.com/customerio/customerio-python/)
40
+ ![PyPI](https://img.shields.io/pypi/v/customerio)
41
+ ![Software License](https://img.shields.io/github/license/customerio/customerio-python)
42
+ [![Build status](https://github.com/customerio/customerio-python/actions/workflows/main.yml/badge.svg)](https://github.com/customerio/customerio-python/actions/workflows/main.yml)
43
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/customerio)
44
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/customerio)
45
+
46
+ # Customer.io Python
47
+
48
+ This module is tested with Python 3.10 through 3.14. If you're new to Customer.io, we recommend that you integrate with our [Data Pipelines Python library](https://github.com/customerio/cdp-analytics-python) instead.
49
+
50
+ ## Installing
51
+
52
+ ```bash
53
+ pip install customerio
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```python
59
+ from customerio import CustomerIO, Regions
60
+ cio = CustomerIO(site_id, api_key, region=Regions.US)
61
+ cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium')
62
+ cio.track(customer_id="5", name='purchased')
63
+ cio.track(customer_id="5", name='purchased', data={"price": 23.45})
64
+ ```
65
+
66
+ ### Instantiating customer.io object
67
+
68
+ Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials).
69
+
70
+ ```python
71
+ from customerio import CustomerIO, Regions
72
+ cio = CustomerIO(site_id, api_key, region=Regions.US)
73
+ ```
74
+ `region` is optional and takes one of two values—`Regions.US` or `Regions.EU`. If you do not specify your region, we assume that your account is based in the US (`Regions.US`). If your account is based in the EU and you do not provide the correct region (`Regions.EU`), we'll route requests to our EU data centers accordingly, however this may cause data to be logged in the US.
75
+
76
+ ### Create or update a Customer.io customer profile
77
+
78
+ ```python
79
+ cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium')
80
+ ```
81
+
82
+ Only the id field is used to identify the customer here. Using an existing id with
83
+ a different email (or any other attribute) will update/overwrite any pre-existing
84
+ values for that field.
85
+
86
+ You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes.
87
+
88
+ See original REST documentation [here](http://customer.io/docs/api/track/#operation/identify)
89
+
90
+ ### Track a custom event
91
+
92
+ ```python
93
+ cio.track(customer_id="5", name='purchased')
94
+ ```
95
+
96
+ ### Track a custom event with custom data values
97
+
98
+ ```python
99
+ cio.track(customer_id="5", name='purchased', data={"price": 23.45, "product": "widget"})
100
+ ```
101
+
102
+ Pass custom event attributes to `track` in the `data` dict.
103
+
104
+ See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
105
+
106
+ ### Track a custom event with an event id or timestamp
107
+
108
+ ```python
109
+ cio.track(
110
+ customer_id="5",
111
+ name='purchased',
112
+ data={"price": 23.45, "product": "widget"},
113
+ id="01HB4HBDKTFWYZCK01DMRSWRFD",
114
+ timestamp=1561231234
115
+ )
116
+ ```
117
+
118
+ Pass `id` to provide a unique event identifier for deduplication. Pass `timestamp` to set the event time. These fields are sent as top-level event fields, not as custom attributes in `data`.
119
+
120
+ ### Backfill a custom event
121
+
122
+ ```python
123
+ from datetime import datetime, timedelta
124
+
125
+ customer_id = "5"
126
+ event_type = "purchase"
127
+
128
+ # Backfill an event one hour in the past
129
+ event_date = datetime.utcnow() - timedelta(hours=1)
130
+ cio.backfill(customer_id, event_type, event_date, price=23.45, coupon=True)
131
+
132
+ event_timestamp = 1408482633
133
+ cio.backfill(customer_id, event_type, event_timestamp, price=34.56)
134
+
135
+ event_timestamp = "1408482680"
136
+ cio.backfill(customer_id, event_type, event_timestamp, price=45.67)
137
+ ```
138
+
139
+ Event timestamp may be passed as a ```datetime.datetime``` object, an integer or a string UNIX timestamp
140
+
141
+ Keyword arguments to backfill are converted to custom event attributes.
142
+
143
+ See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
144
+
145
+ ### Track an anonymous event
146
+
147
+ ```python
148
+ cio.track_anonymous(
149
+ anonymous_id="anon-event",
150
+ name="purchased",
151
+ data={"price": 23.45, "product": "widget"}
152
+ )
153
+ ```
154
+
155
+ An anonymous event is an event associated with a person you haven't identified. The event requires an `anonymous_id` representing the unknown person and an event `name`. When you identify a person, you can set their `anonymous_id` attribute. If [event merging](https://customer.io/docs/anonymous-events/#turn-on-merging) is turned on in your workspace, and the attribute matches the `anonymous_id` in one or more events that were logged within the last 30 days, we associate those events with the person.
156
+
157
+ Like `track`, `track_anonymous` accepts custom event attributes in `data` and optional top-level `id` and `timestamp` fields.
158
+
159
+ #### Anonymous invite events
160
+
161
+ If you previously sent [invite events](https://customer.io/docs/journeys/anonymous-invite-emails/), you can achieve the same functionality by sending an anonymous event with the anonymous identifier set to `None`. To send anonymous invites, your event *must* include a `recipient` attribute.
162
+
163
+ ```python
164
+ cio.track_anonymous(
165
+ anonymous_id=None,
166
+ name="invite",
167
+ data={"first_name": "alex", "recipient": "alex.person@example.com"}
168
+ )
169
+ ```
170
+
171
+ ### Delete a customer profile
172
+ ```python
173
+ cio.delete(customer_id="5")
174
+ ```
175
+
176
+ Deletes the customer profile for a specified customer.
177
+
178
+ This method returns nothing. Attempts to delete non-existent customers will not raise any errors.
179
+
180
+ See original REST documentation [here](https://customer.io/docs/api/track/#operation/delete)
181
+
182
+
183
+ You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes.
184
+
185
+ ### Merge duplicate customer profiles
186
+
187
+ When you merge two people, you pick a primary person and merge a secondary, duplicate person into it. The primary person remains after the merge and the secondary is deleted. This process is permanent: you cannot recover the secondary person.
188
+
189
+ For each person, you'll set the type of identifier you want to use to identify a person—one of `id`, `email`, or `cio_id`—and then you'll provide the corresponding identifier.
190
+
191
+ ```python
192
+ ## Please import identifier types
193
+ cio.merge_customers(primary_id_type=ID,
194
+ primary_id="cool.person@company.com",
195
+ secondary_id_type=EMAIL,
196
+ secondary_id="cperson@gmail.com"
197
+ )
198
+ ```
199
+
200
+ ### Add a device
201
+ ```python
202
+ cio.add_device(customer_id="1", device_id='device_hash', platform='ios')
203
+ ```
204
+
205
+ Adds the device `device_hash` with the platform `ios` for a specified customer.
206
+
207
+ Supported platforms are `ios` and `android`.
208
+
209
+ Optionally, `last_used` can be passed in to specify the last touch of the device. Otherwise, this attribute is set by the API.
210
+
211
+ ```python
212
+ cio.add_device(customer_id="1", device_id='device_hash', platform='ios', last_used=1514764800})
213
+ ```
214
+
215
+ This method returns nothing.
216
+
217
+ ### Delete a device
218
+ ```python
219
+ cio.delete_device(customer_id="1", device_id='device_hash')
220
+ ```
221
+
222
+ Deletes the specified device for a specified customer.
223
+
224
+ This method returns nothing. Attempts to delete non-existent devices will not raise any errors.
225
+
226
+ ### Suppress a customer
227
+ ```python
228
+ cio.suppress(customer_id="1")
229
+ ```
230
+
231
+ Suppresses the specified customer. They will be deleted from Customer.io, and we will ignore all further attempts to identify or track activity for the suppressed customer ID
232
+
233
+ See REST documentation [here](https://customer.io/docs/api/track/#operation/suppress)
234
+
235
+ ### Unsuppress a customer
236
+ ```python
237
+ cio.unsuppress(customer_id="1")
238
+ ```
239
+
240
+ Unsuppresses the specified customer. We will remove the supplied id from our suppression list and start accepting new identify and track calls for the customer as normal
241
+
242
+ See REST documentation [here](https://customer.io/docs/api/track/#operation/unsuppress)
243
+
244
+ ### Send Transactional Messages
245
+
246
+ To use the [Transactional API](https://customer.io/docs/journeys/transactional-api), instantiate the Customer.io object using an [app key](https://customer.io/docs/managing-credentials#app-api-keys) and create a request object for your message type.
247
+
248
+ ## Email
249
+
250
+ SendEmailRequest requires:
251
+ * `transactional_message_id`: the ID of the transactional message you want to send, or the `body`, `_from`, and `subject` of a new message.
252
+ * `to`: the email address of your recipients
253
+ * an `identifiers` object containing the `email` and/or `id` of your recipient. If the person you reference by email or ID does not exist, Customer.io creates them.
254
+ * a `message_data` object containing properties that you want reference in your message using Liquid.
255
+ * You can also send attachments with your message. Use `attach` to encode attachments.
256
+
257
+ Use `send_email` referencing your request to send a transactional message. [Learn more about transactional messages and `SendEmailRequest` properties](https://customer.io/docs/journeys/transactional-api).
258
+
259
+ ```python
260
+ from customerio import APIClient, Regions, SendEmailRequest
261
+ client = APIClient("your API key", region=Regions.US)
262
+
263
+ request = SendEmailRequest(
264
+ to="person@example.com",
265
+ _from="override.sender@example.com",
266
+ transactional_message_id="3",
267
+ message_data={
268
+ "name": "person",
269
+ "items": [
270
+ {
271
+ "name": "shoes",
272
+ "price": "59.99",
273
+ },
274
+ ]
275
+ },
276
+ identifiers={
277
+ "email": "person@example.com",
278
+ }
279
+ )
280
+
281
+ with open("receipt.pdf", "rb") as f:
282
+ request.attach('receipt.pdf', f.read())
283
+
284
+ response = client.send_email(request)
285
+ print(response)
286
+ ```
287
+
288
+ ## Push
289
+
290
+ SendPushRequest requires:
291
+ * `transactional_message_id`: the ID of the transactional push message you want to send.
292
+ * an `identifiers` object containing the `id` or `email` of your recipient. If the profile does not exist, Customer.io will create it.
293
+
294
+ Use `send_push` referencing your request to send a transactional message. [Learn more about transactional messages and `SendPushRequest` properties](https://customer.io/docs/journeys/transactional-api).
295
+
296
+ ```python
297
+ from customerio import APIClient, Regions, SendPushRequest
298
+ client = APIClient("your API key", region=Regions.US)
299
+
300
+ request = SendPushRequest(
301
+ transactional_message_id="3",
302
+ message_data={
303
+ "name": "person",
304
+ "items": [
305
+ {
306
+ "name": "shoes",
307
+ "price": "59.99",
308
+ },
309
+ ]
310
+ },
311
+ identifiers={
312
+ "id": "2",
313
+ }
314
+ )
315
+
316
+ response = client.send_push(request)
317
+ print(response)
318
+ ```
319
+
320
+ ## Notes
321
+ - The Customer.io Python SDK depends on the [`Requests`](https://pypi.org/project/requests/) library which includes [`urllib3`](https://pypi.org/project/urllib3/) as a transitive dependency. The [`Requests`](https://pypi.org/project/requests/) library leverages connection pooling defined in [`urllib3`](https://pypi.org/project/urllib3/). [`urllib3`](https://pypi.org/project/urllib3/) only attempts to retry invocations of `HTTP` methods which are understood to be idempotent (See: [`Retry.DEFAULT_ALLOWED_METHODS`](https://github.com/urllib3/urllib3/blob/main/src/urllib3/util/retry.py#L184)). Since the `POST` method is not considered to be idempotent, any invocations which require `POST` are not retried.
322
+
323
+ - It is possible to have the Customer.io Python SDK effectively *disable* connection pooling by passing a named initialization parameter `use_connection_pooling` to either the `APIClient` class or `CustomerIO` class. Setting this parameter to `False` (default: `True`) causes the [`Session`](https://github.com/psf/requests/blob/main/requests/sessions.py#L355) to be initialized and discarded after each request. If you are experiencing integration issues where the cause is reported as `Connection Reset by Peer`, this may correct the problem. It will, however, impose a slight performance penalty as the TCP connection set-up and tear-down will now occur for each request.
324
+
325
+ ### Usage Example Disabling Connection Pooling
326
+ ```python
327
+ from customerio import CustomerIO, Regions
328
+ cio = CustomerIO(site_id, api_key, region=Regions.US, use_connection_pooling=False)
329
+ ```
330
+
331
+ ## Running tests
332
+
333
+ Changes to the library can be tested by running `make test` from the parent directory.
334
+
335
+ ## Thanks!
336
+
337
+ * [Dimitriy Narkevich](https://github.com/dimier) for creating the library.
338
+ * [EZL](https://github.com/ezl) for contributing customer deletes and improving README
339
+ * [Noemi Millman](https://github.com/sbnoemi) for adding custom JSON encoder
340
+ * [Jason Kraus](https://github.com/zbyte64) for event backfilling
341
+ * [Nicolas Paris](https://github.com/niparis) for better handling of NaN values
@@ -0,0 +1,12 @@
1
+ customerio/__init__.py,sha256=UjqelCLS-5mfclsHMSx9dka4901x8Xzh8VoEi9uywrA,510
2
+ customerio/__version__.py,sha256=EPmgXOdWKks5S__ZMH7Nu6xpAeVrZpfxaFy4pykuyeI,22
3
+ customerio/api.py,sha256=elGH8ih6VInumMQdIxziYK2YeKGxe3GtgqMmTKKEVZw,10008
4
+ customerio/client_base.py,sha256=t3YigWTA0fV9Nr0H7sqO-s2_6Vp6LprOg-tXNZtfg90,5113
5
+ customerio/constants.py,sha256=KgYkS-ywruXlzGyErGcweJsHwfMjTVyk9JLKi-1Od1c,64
6
+ customerio/regions.py,sha256=5EEXQyLoxSvrXoDeeXt6Asr-DXN6nNMNyh9CGbF-hn0,249
7
+ customerio/track.py,sha256=-jYn0-7JVMoSIxqn3dd1dU43JBEYVW7MO7-jGdg7mPM,9622
8
+ customerio-3.0.0.dist-info/licenses/LICENSE,sha256=DrMr_uGUjC4xPmcqLmymvDoh1OoAAV7rfc9s1nlr9xc,1067
9
+ customerio-3.0.0.dist-info/METADATA,sha256=bk7Z20mdeL8bkqnByex_IgIjDuisC_06fqtv8h9qamw,13899
10
+ customerio-3.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ customerio-3.0.0.dist-info/top_level.txt,sha256=d3H1hFIU5Igty-2IkC7fQoeLlaNAzjn8s-9iOjKRzvA,11
12
+ customerio-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2022 Customer.io
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ customerio