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 +23 -0
- customerio/__version__.py +1 -0
- customerio/api.py +328 -0
- customerio/client_base.py +151 -0
- customerio/constants.py +5 -0
- customerio/regions.py +8 -0
- customerio/track.py +271 -0
- customerio-3.0.0.dist-info/METADATA +341 -0
- customerio-3.0.0.dist-info/RECORD +12 -0
- customerio-3.0.0.dist-info/WHEEL +5 -0
- customerio-3.0.0.dist-info/licenses/LICENSE +22 -0
- customerio-3.0.0.dist-info/top_level.txt +1 -0
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
|
customerio/constants.py
ADDED
customerio/regions.py
ADDED
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
|
+
[](https://gitpod.io/#https://github.com/customerio/customerio-python/)
|
|
40
|
+

|
|
41
|
+

|
|
42
|
+
[](https://github.com/customerio/customerio-python/actions/workflows/main.yml)
|
|
43
|
+

|
|
44
|
+

|
|
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,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
|