├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .gitpod.yml
├── AUTHORS
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── customerio
├── __init__.py
├── __version__.py
├── api.py
├── client_base.py
├── constants.py
├── regions.py
└── track.py
├── requirements.txt
├── setup.py
└── tests
├── __init__.py
├── server.py
├── test_api.py
└── test_customerio.py
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on: [ push ]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-20.04
8 | strategy:
9 | matrix:
10 | python: [ '3.6', '3.7', '3.8','3.9' ]
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Set up Python
14 | uses: actions/setup-python@v2
15 | with:
16 | python-version: ${{ matrix.python }}
17 | - name: install dependencies for the minor version
18 | run: |
19 | python -m venv venv
20 | . venv/bin/activate
21 | export version=$(grep -v '^$' ${GITHUB_WORKSPACE}/requirements.txt | grep requests | awk -F'=' '{print $2}' )
22 | pip install requests==$(echo $version)
23 | - name: run tests
24 | run: |
25 | . venv/bin/activate
26 | make test
27 | deactivate
28 | - name: reinstall to the latest version
29 | run: |
30 | python -m venv venv
31 | . venv/bin/activate
32 | pip install --upgrade requests
33 | - name: run tests again
34 | run: |
35 | . venv/bin/activate
36 | make test
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.pyo
3 | tests/server.pem
4 | dist
5 | customerio.egg-info
6 | .DS_Store
7 | MANIFEST
8 | build/
9 | setup.cfg
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 |
2 | tasks:
3 | - init: make
4 |
5 | vscode:
6 | extensions:
7 | - ms-python.python
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Original author: Dmitriy Narkevich (@dimier)
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [2.1]
4 | ### Added
5 | - Add support for sending [transactional push messages](https://customer.io/docs/transactional-api/#transactional-push-notifications) ([#95](https://github.com/customerio/customerio-python/pull/95))
6 |
7 | ## [2.0]
8 | ### Changed
9 | - Updated transactional email request optional argument `amp_body` to `body_amp` for consistency across APIs ([#93](https://github.com/customerio/customerio-python/pull/93))
10 |
11 | ## [1.6.1]
12 | ### Added
13 | - Added the `disable_css_preprocessing` and `language` optional fields to send request
14 |
15 | ## [1.3.0]
16 | ### Added
17 | - Support for merging duplicate customers using `merge_customers` function.
18 |
19 | ## [1.2.0]
20 | ### Added
21 | - Support for anonymous events using `track_anonymous` function.
22 |
23 | ## [1.1.0] - March 25, 2021
24 | ### Added
25 | - Support for EU region
26 |
27 | ### Changed
28 | - `customerio.CustomerIO` and `customerio.APIClient` have a new keyword parameter `region` that can be set to either `Regions.US` or `Regions.EU`
29 |
30 | ## [1.0.0] December 3, 2020
31 | ### Added
32 | - Support for transactional api
33 | - Validations for behavioural api methods
34 |
35 | ### Removed
36 | - Manual segmentation functions `add_to_segment` & `remove_from_segment`
37 | - Python 2 support
38 |
39 | ### Changed
40 | - ID fields in request path are url escaped
41 | - Sanitize event data ([#32](https://github.com/customerio/customerio-python/pull/32))
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | all:
3 | python setup.py sdist
4 | python -m doctest ./customerio/__init__.py
5 |
6 | install:
7 | python setup.py install
8 |
9 | clean:
10 | python setup.py clean
11 | rm -rf MANIFEST build dist
12 |
13 | dev: clean all
14 | if ! pip uninstall customerio; then echo "customerio not installed, installing it for the first time" ; fi
15 | pip install dist/*
16 | python -i -c "from customerio import *"
17 |
18 | upload:
19 | python setup.py register
20 | echo "*** Now upload the binary to PyPi *** (one second)" && sleep 3 && open dist & open "http://pypi.python.org/pypi?%3Aaction=pkg_edit&name=customerio" # python setup.py upload
21 |
22 | test:
23 | openssl req -new -newkey rsa:2048 -days 10 -nodes -x509 -subj "/C=CA/ST=Ontario/L=Toronto/O=Test/CN=127.0.0.1" -keyout ./tests/server.pem -out ./tests/server.pem
24 | python -m unittest discover -v
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](https://gitpod.io/#https://github.com/customerio/customerio-python/)
8 | 
9 | 
10 | [](https://github.com/customerio/customerio-python/actions/workflows/main.yml)
11 | 
12 | 
13 |
14 | # Customer.io Python
15 |
16 | This module has been tested with Python 3.6, 3.7, 3.8 and 3.9. 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.
17 |
18 | ## Installing
19 |
20 | ```bash
21 | pip install customerio
22 | ```
23 |
24 | ## Usage
25 |
26 | ```python
27 | from customerio import CustomerIO, Regions
28 | cio = CustomerIO(site_id, api_key, region=Regions.US)
29 | cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium')
30 | cio.track(customer_id="5", name='purchased')
31 | cio.track(customer_id="5", name='purchased', price=23.45)
32 | ```
33 |
34 | ### Instantiating customer.io object
35 |
36 | Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials).
37 |
38 | ```python
39 | from customerio import CustomerIO, Regions
40 | cio = CustomerIO(site_id, api_key, region=Regions.US)
41 | ```
42 | `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.
43 |
44 | ### Create or update a Customer.io customer profile
45 |
46 | ```python
47 | cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium')
48 | ```
49 |
50 | Only the id field is used to identify the customer here. Using an existing id with
51 | a different email (or any other attribute) will update/overwrite any pre-existing
52 | values for that field.
53 |
54 | You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
55 |
56 | See original REST documentation [here](http://customer.io/docs/api/track/#operation/identify)
57 |
58 | ### Track a custom event
59 |
60 | ```python
61 | cio.track(customer_id="5", name='purchased')
62 | ```
63 |
64 | ### Track a custom event with custom data values
65 |
66 | ```python
67 | cio.track(customer_id="5", name='purchased', price=23.45, product="widget")
68 | ```
69 |
70 | You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
71 |
72 | See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
73 |
74 | ### Backfill a custom event
75 |
76 | ```python
77 | from datetime import datetime, timedelta
78 |
79 | customer_id = "5"
80 | event_type = "purchase"
81 |
82 | # Backfill an event one hour in the past
83 | event_date = datetime.utcnow() - timedelta(hours=1)
84 | cio.backfill(customer_id, event_type, event_date, price=23.45, coupon=True)
85 |
86 | event_timestamp = 1408482633
87 | cio.backfill(customer_id, event_type, event_timestamp, price=34.56)
88 |
89 | event_timestamp = "1408482680"
90 | cio.backfill(customer_id, event_type, event_timestamp, price=45.67)
91 | ```
92 |
93 | Event timestamp may be passed as a ```datetime.datetime``` object, an integer or a string UNIX timestamp
94 |
95 | Keyword arguments to backfill work the same as a call to ```cio.track```.
96 |
97 | See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
98 |
99 | ### Track an anonymous event
100 |
101 | ```python
102 | cio.track_anonymous(anonymous_id="anon-event", name="purchased", price=23.45, product="widget")
103 | ```
104 |
105 | 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.
106 |
107 | #### Anonymous invite events
108 |
109 | 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.
110 |
111 | ```python
112 | cio.track_anonymous(anonymous_id=None, name="invite", first_name="alex", recipient="alex.person@example.com")
113 | ```
114 |
115 | ### Delete a customer profile
116 | ```python
117 | cio.delete(customer_id="5")
118 | ```
119 |
120 | Deletes the customer profile for a specified customer.
121 |
122 | This method returns nothing. Attempts to delete non-existent customers will not raise any errors.
123 |
124 | See original REST documentation [here](https://customer.io/docs/api/track/#operation/delete)
125 |
126 |
127 | You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
128 |
129 | ### Merge duplicate customer profiles
130 |
131 | 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.
132 |
133 | 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.
134 |
135 | ```python
136 | ## Please import identifier types
137 | cio.merge_customers(primary_id_type=ID,
138 | primary_id="cool.person@company.com",
139 | secondary_id_type=EMAIL,
140 | secondary_id="cperson@gmail.com"
141 | )
142 | ```
143 |
144 | ### Add a device
145 | ```python
146 | cio.add_device(customer_id="1", device_id='device_hash', platform='ios')
147 | ```
148 |
149 | Adds the device `device_hash` with the platform `ios` for a specified customer.
150 |
151 | Supported platforms are `ios` and `android`.
152 |
153 | Optionally, `last_used` can be passed in to specify the last touch of the device. Otherwise, this attribute is set by the API.
154 |
155 | ```python
156 | cio.add_device(customer_id="1", device_id='device_hash', platform='ios', last_used=1514764800})
157 | ```
158 |
159 | This method returns nothing.
160 |
161 | ### Delete a device
162 | ```python
163 | cio.delete_device(customer_id="1", device_id='device_hash')
164 | ```
165 |
166 | Deletes the specified device for a specified customer.
167 |
168 | This method returns nothing. Attempts to delete non-existent devices will not raise any errors.
169 |
170 | ### Suppress a customer
171 | ```python
172 | cio.suppress(customer_id="1")
173 | ```
174 |
175 | 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
176 |
177 | See REST documentation [here](https://customer.io/docs/api/track/#operation/suppress)
178 |
179 | ### Unsuppress a customer
180 | ```python
181 | cio.unsuppress(customer_id="1")
182 | ```
183 |
184 | 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
185 |
186 | See REST documentation [here](https://customer.io/docs/api/track/#operation/unsuppress)
187 |
188 | ### Send Transactional Messages
189 |
190 | 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.
191 |
192 | ## Email
193 |
194 | SendEmailRequest requires:
195 | * `transactional_message_id`: the ID of the transactional message you want to send, or the `body`, `_from`, and `subject` of a new message.
196 | * `to`: the email address of your recipients
197 | * an `identifiers` object containing the `id` of your recipient. If the `id` does not exist, Customer.io will create it.
198 | * a `message_data` object containing properties that you want reference in your message using Liquid.
199 | * You can also send attachments with your message. Use `attach` to encode attachments.
200 |
201 | 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).
202 |
203 | ```python
204 | from customerio import APIClient, Regions, SendEmailRequest
205 | client = APIClient("your API key", region=Regions.US)
206 |
207 | request = SendEmailRequest(
208 | to="person@example.com",
209 | _from="override.sender@example.com",
210 | transactional_message_id="3",
211 | message_data={
212 | "name": "person",
213 | "items": [
214 | {
215 | "name": "shoes",
216 | "price": "59.99",
217 | },
218 | ]
219 | },
220 | identifiers={
221 | "id": "2",
222 | }
223 | )
224 |
225 | with open("receipt.pdf", "rb") as f:
226 | request.attach('receipt.pdf', f.read())
227 |
228 | response = client.send_email(request)
229 | print(response)
230 | ```
231 |
232 | ## Push
233 |
234 | SendPushRequest requires:
235 | * `transactional_message_id`: the ID of the transactional push message you want to send.
236 | * an `identifiers` object containing the `id` or `email` of your recipient. If the profile does not exist, Customer.io will create it.
237 |
238 | 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).
239 |
240 | ```python
241 | from customerio import APIClient, Regions, SendPushRequest
242 | client = APIClient("your API key", region=Regions.US)
243 |
244 | request = SendPushRequest(
245 | transactional_message_id="3",
246 | message_data={
247 | "name": "person",
248 | "items": [
249 | {
250 | "name": "shoes",
251 | "price": "59.99",
252 | },
253 | ]
254 | },
255 | identifiers={
256 | "id": "2",
257 | }
258 | )
259 |
260 | response = client.send_push(request)
261 | print(response)
262 | ```
263 |
264 | ## Notes
265 | - 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.
266 |
267 | - 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.
268 |
269 | ### Usage Example Disabling Connection Pooling
270 | ```python
271 | from customerio import CustomerIO, Regions
272 | cio = CustomerIO(site_id, api_key, region=Regions.US, use_connection_pooling=False)
273 | ```
274 |
275 | ## Running tests
276 |
277 | Changes to the library can be tested by running `make test` from the parent directory.
278 |
279 | ## Thanks!
280 |
281 | * [Dimitriy Narkevich](https://github.com/dimier) for creating the library.
282 | * [EZL](https://github.com/ezl) for contributing customer deletes and improving README
283 | * [Noemi Millman](https://github.com/sbnoemi) for adding custom JSON encoder
284 | * [Jason Kraus](https://github.com/zbyte64) for event backfilling
285 | * [Nicolas Paris](https://github.com/niparis) for better handling of NaN values
286 |
--------------------------------------------------------------------------------
/customerio/__init__.py:
--------------------------------------------------------------------------------
1 | import warnings
2 |
3 | from customerio.client_base import CustomerIOException
4 | from customerio.track import CustomerIO
5 | from customerio.api import APIClient, SendEmailRequest, SendPushRequest
6 | from customerio.regions import Regions
7 |
--------------------------------------------------------------------------------
/customerio/__version__.py:
--------------------------------------------------------------------------------
1 | VERSION = (2, 1, 0, 'final', 0)
2 |
3 | def get_version():
4 | version = '%s.%s' % (VERSION[0], VERSION[1])
5 | if VERSION[2]:
6 | version = '%s.%s' % (version, VERSION[2])
7 | if VERSION[3:] == ('alpha', 0):
8 | version = '%s-pre-alpha' % version
9 | else:
10 | if VERSION[3] != 'final':
11 | version = '%s-%s-%s' % (version, VERSION[3], VERSION[4])
12 | return version
13 |
14 | __version__=get_version()
15 |
--------------------------------------------------------------------------------
/customerio/api.py:
--------------------------------------------------------------------------------
1 | """
2 | Implements the client that interacts with Customer.io's App API using app keys.
3 | """
4 | import base64
5 | import json
6 | from .client_base import ClientBase, CustomerIOException
7 | from .regions import Regions, Region
8 |
9 | class APIClient(ClientBase):
10 | def __init__(self, key, url=None, region=Regions.US, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True):
11 | if not isinstance(region, Region):
12 | raise CustomerIOException('invalid region provided')
13 |
14 | self.key = key
15 | self.url = url or 'https://{host}'.format(host=region.api_host)
16 | ClientBase.__init__(self, retries=retries,
17 | timeout=timeout, backoff_factor=backoff_factor, use_connection_pooling=use_connection_pooling)
18 |
19 | def send_email(self, request):
20 | if isinstance(request, SendEmailRequest):
21 | request = request._to_dict()
22 | resp = self.send_request('POST', self.url + "/v1/send/email", request)
23 | return json.loads(resp)
24 |
25 | def send_push(self, request):
26 | if isinstance(request, SendPushRequest):
27 | request = request._to_dict()
28 | resp = self.send_request('POST', self.url + "/v1/send/push", request)
29 | return json.loads(resp)
30 |
31 | # builds the session.
32 | def _build_session(self):
33 | session = super()._build_session()
34 | session.headers['Authorization'] = "Bearer {key}".format(key=self.key)
35 |
36 | return session
37 |
38 | class SendEmailRequest(object):
39 | '''An object with all the options avaiable for triggering a transactional email message'''
40 | def __init__(self,
41 | transactional_message_id=None,
42 | to=None,
43 | identifiers=None,
44 | _from=None,
45 | headers=None,
46 | reply_to=None,
47 | bcc=None,
48 | subject=None,
49 | preheader=None,
50 | body=None,
51 | body_plain=None,
52 | body_amp=None,
53 | fake_bcc=None,
54 | disable_message_retention=None,
55 | send_to_unsubscribed=None,
56 | tracked=None,
57 | queue_draft=None,
58 | message_data=None,
59 | attachments=None,
60 | disable_css_preproceessing=None,
61 | send_at=None,
62 | language=None,
63 | ):
64 |
65 | self.transactional_message_id = transactional_message_id
66 | self.to = to
67 | self.identifiers = identifiers
68 | self._from = _from
69 | self.headers = headers
70 | self.reply_to = reply_to
71 | self.bcc = bcc
72 | self.subject = subject
73 | self.preheader = preheader
74 | self.body = body
75 | self.body_plain = body_plain
76 | self.body_amp = body_amp
77 | self.fake_bcc = fake_bcc
78 | self.disable_message_retention = disable_message_retention
79 | self.send_to_unsubscribed = send_to_unsubscribed
80 | self.tracked = tracked
81 | self.queue_draft = queue_draft
82 | self.message_data = message_data
83 | self.attachments = attachments
84 | self.disable_css_preproceessing = disable_css_preproceessing
85 | self.send_at = send_at
86 | self.language = language
87 |
88 | def attach(self, name, content, encode=True):
89 | '''Helper method to add base64 encode the attachments'''
90 | if not self.attachments:
91 | self.attachments = {}
92 |
93 | if self.attachments.get(name, None):
94 | raise CustomerIOException("attachment {name} already exists".format(name=name))
95 |
96 | if encode:
97 | if isinstance(content, str):
98 | content = base64.b64encode(content.encode('utf-8')).decode()
99 | else:
100 | content = base64.b64encode(content).decode()
101 |
102 | self.attachments[name] = content
103 |
104 | def _to_dict(self):
105 | '''Build a request payload from the object'''
106 | field_map = dict(
107 | # from is reservered keyword hence the object has the field
108 | # `_from` but in the request payload we map it to `from`
109 | _from="from",
110 | # field name is the same as the payload field name
111 | transactional_message_id="transactional_message_id",
112 | to="to",
113 | identifiers="identifiers",
114 | headers="headers",
115 | reply_to="reply_to",
116 | bcc="bcc",
117 | subject="subject",
118 | preheader="preheader",
119 | body="body",
120 | body_plain="body_plain",
121 | body_amp="body_amp",
122 | fake_bcc="fake_bcc",
123 | disable_message_retention="disable_message_retention",
124 | send_to_unsubscribed="send_to_unsubscribed",
125 | tracked="tracked",
126 | queue_draft="queue_draft",
127 | message_data="message_data",
128 | attachments="attachments",
129 | disable_css_preproceessing="disable_css_preproceessing",
130 | send_at="send_at",
131 | language="language",
132 | )
133 |
134 | data = {}
135 | for field, name in field_map.items():
136 | value = getattr(self, field, None)
137 | if value is not None:
138 | data[name] = value
139 |
140 | return data
141 |
142 | class SendPushRequest(object):
143 | '''An object with all the options avaiable for triggering a transactional push message'''
144 | def __init__(self,
145 | transactional_message_id=None,
146 | to=None,
147 | identifiers=None,
148 | title=None,
149 | message=None,
150 | disable_message_retention=None,
151 | send_to_unsubscribed=None,
152 | queue_draft=None,
153 | message_data=None,
154 | send_at=None,
155 | language=None,
156 | image_url=None,
157 | link=None,
158 | custom_data=None,
159 | custom_payload=None,
160 | device=None,
161 | sound=None
162 | ):
163 |
164 | self.transactional_message_id = transactional_message_id
165 | self.to = to
166 | self.identifiers = identifiers
167 | self.disable_message_retention = disable_message_retention
168 | self.send_to_unsubscribed = send_to_unsubscribed
169 | self.queue_draft = queue_draft
170 | self.message_data = message_data
171 | self.send_at = send_at
172 | self.language = language
173 |
174 | self.title = title
175 | self.message = message
176 | self.image_url = image_url
177 | self.link = link
178 | self.custom_data = custom_data
179 | self.custom_payload = custom_payload
180 | self.device = device
181 | self.sound = sound
182 |
183 | def _to_dict(self):
184 | '''Build a request payload from the object'''
185 | field_map = dict(
186 | # field name is the same as the payload field name
187 | transactional_message_id="transactional_message_id",
188 | to="to",
189 | identifiers="identifiers",
190 | disable_message_retention="disable_message_retention",
191 | send_to_unsubscribed="send_to_unsubscribed",
192 | queue_draft="queue_draft",
193 | message_data="message_data",
194 | send_at="send_at",
195 | language="language",
196 |
197 | title="title",
198 | message="message",
199 | image_url="image_url",
200 | link="link",
201 | custom_data="custom_data",
202 | custom_payload="custom_payload",
203 | device="custom_device",
204 | sound="sound"
205 | )
206 |
207 | data = {}
208 | for field, name in field_map.items():
209 | value = getattr(self, field, None)
210 | if value is not None:
211 | data[name] = value
212 |
213 | return data
214 |
--------------------------------------------------------------------------------
/customerio/client_base.py:
--------------------------------------------------------------------------------
1 | """
2 | Implements the base client that is used by other classes to make requests
3 | """
4 | from __future__ import division
5 | from datetime import datetime, timezone
6 | import logging
7 | import math
8 |
9 | from requests import Session
10 | from requests.adapters import HTTPAdapter
11 | from requests.packages.urllib3.util.retry import Retry
12 |
13 | from .__version__ import __version__ as ClientVersion
14 |
15 | class CustomerIOException(Exception):
16 | pass
17 |
18 | class ClientBase(object):
19 | def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True):
20 | self.timeout = timeout
21 | self.retries = retries
22 | self.backoff_factor = backoff_factor
23 | self.use_connection_pooling = use_connection_pooling
24 | self._current_session = None
25 |
26 | @property
27 | def http(self):
28 | if self._current_session is None:
29 | self._current_session = self._get_session()
30 |
31 | return self._current_session
32 |
33 | def send_request(self, method, url, data):
34 | '''Dispatches the request and returns a response'''
35 |
36 | try:
37 | response = self.http.request(
38 | method, url=url, json=self._sanitize(data), timeout=self.timeout)
39 |
40 | result_status = response.status_code
41 | if result_status != 200:
42 | raise CustomerIOException('%s: %s %s %s' % (result_status, url, data, response.text))
43 | return response.text
44 |
45 | except Exception as e:
46 | # Raise exception alerting user that the system might be
47 | # experiencing an outage and refer them to system status page.
48 | message = '''Failed to receive valid response after {count} retries.
49 | Check system status at http://status.customer.io.
50 | Last caught exception -- {klass}: {message}
51 | '''.format(klass=type(e), message=e, count=self.retries)
52 | raise CustomerIOException(message)
53 |
54 | finally:
55 | self._close()
56 |
57 | def _sanitize(self, data):
58 | for k, v in data.items():
59 | if isinstance(v, datetime):
60 | data[k] = self._datetime_to_timestamp(v)
61 | if isinstance(v, float) and math.isnan(v):
62 | data[k] = None
63 | return data
64 |
65 | def _datetime_to_timestamp(self, dt):
66 | return int(dt.replace(tzinfo=timezone.utc).timestamp())
67 |
68 | def _stringify_list(self, customer_ids):
69 | customer_string_ids = []
70 | for v in customer_ids:
71 | if isinstance(v, str):
72 | customer_string_ids.append(v)
73 | elif isinstance(v, int):
74 | customer_string_ids.append(str(v))
75 | else:
76 | raise CustomerIOException(
77 | 'customer_ids cannot be {type}'.format(type=type(v)))
78 | return customer_string_ids
79 |
80 | # gets a session based on whether we want pooling or not. If no pooling is desired, we create a new session each time.
81 | def _get_session(self):
82 | if (self.use_connection_pooling):
83 | if (self._current_session is None):
84 | self._current_session = self._build_session()
85 |
86 | # if we're using pooling, return the existing session.
87 | logging.debug("Using existing session...")
88 | return self._current_session
89 | else:
90 | # if we're not using pooling, build a new session.
91 | logging.debug("Creating new session...")
92 | self._current_session = self._build_session()
93 | return self._current_session
94 |
95 | # builds the session.
96 | def _build_session(self):
97 | session = Session()
98 | session.headers['User-Agent'] = "Customer.io Python Client/{version}".format(version=ClientVersion)
99 |
100 | # Retry request a number of times before raising an exception
101 | # also define backoff_factor to delay each retry
102 | session.mount(
103 | 'https://',
104 | HTTPAdapter(max_retries=Retry(total=self.retries, backoff_factor=self.backoff_factor)))
105 |
106 | return session
107 |
108 | # closes the session if we're not using connection pooling.
109 | def _close(self):
110 | # if we're not using pooling; clean up the resources.
111 | if (not self.use_connection_pooling):
112 | self._current_session.close()
113 | self._current_session = None
114 |
--------------------------------------------------------------------------------
/customerio/constants.py:
--------------------------------------------------------------------------------
1 | ## Identifier types
2 |
3 | ID = "id"
4 | EMAIL = "email"
5 | CIOID = "cio_id"
6 |
--------------------------------------------------------------------------------
/customerio/regions.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 |
3 | Region = namedtuple('Region', ['name', 'track_host', 'api_host'])
4 |
5 | class Regions:
6 | US = Region('us', 'track.customer.io', 'api.customer.io')
7 | EU = Region('eu', 'track-eu.customer.io', 'api-eu.customer.io')
8 |
--------------------------------------------------------------------------------
/customerio/track.py:
--------------------------------------------------------------------------------
1 | """
2 | Implements the client that interacts with Customer.io's Track API using Site ID and API Keys.
3 | """
4 | from .client_base import ClientBase, CustomerIOException
5 | from datetime import datetime
6 | import warnings
7 | from urllib.parse import quote
8 | from .regions import Regions, Region
9 | from enum import Enum
10 | from customerio.constants import CIOID, EMAIL, ID
11 |
12 | class CustomerIO(ClientBase):
13 | def __init__(self, site_id=None, api_key=None, host=None, region=Regions.US, port=None, url_prefix=None, json_encoder=None, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True):
14 | if not isinstance(region, Region):
15 | raise CustomerIOException('invalid region provided')
16 |
17 | self.host = host or region.track_host
18 | self.port = port or 443
19 | self.url_prefix = url_prefix or '/api/v1'
20 | self.api_key = api_key
21 | self.site_id = site_id
22 |
23 | if json_encoder is not None:
24 | warnings.warn(
25 | "With the switch to using requests library the `json_encoder` param is no longer used.", DeprecationWarning)
26 |
27 | self.setup_base_url()
28 | ClientBase.__init__(
29 | self,
30 | retries=retries,
31 | timeout=timeout,
32 | backoff_factor=backoff_factor,
33 | use_connection_pooling=use_connection_pooling)
34 |
35 | def _url_encode(self, id):
36 | return quote(str(id), safe='')
37 |
38 | def setup_base_url(self):
39 | template = 'https://{host}:{port}/{prefix}'
40 | if self.port == 443:
41 | template = 'https://{host}/{prefix}'
42 |
43 | if '://' in self.host:
44 | self.host = self.host.split('://')[1]
45 |
46 | self.base_url = template.format(
47 | host=self.host.strip('/'),
48 | port=self.port,
49 | prefix=self.url_prefix.strip('/'))
50 |
51 | def get_customer_query_string(self, customer_id):
52 | '''Generates a customer API path'''
53 | return '{base}/customers/{id}'.format(base=self.base_url, id=self._url_encode(customer_id))
54 |
55 | def get_event_query_string(self, customer_id):
56 | '''Generates an event API path'''
57 | return '{base}/customers/{id}/events'.format(base=self.base_url, id=self._url_encode(customer_id))
58 |
59 | def get_events_query_string(self):
60 | '''Returns the events API path'''
61 | return '{base}/events'.format(base=self.base_url)
62 |
63 | def get_device_query_string(self, customer_id):
64 | '''Generates a device API path'''
65 | return '{base}/customers/{id}/devices'.format(base=self.base_url, id=self._url_encode(customer_id))
66 |
67 | def identify(self, id, **kwargs):
68 | '''Identify a single customer by their unique id, and optionally add attributes'''
69 | if not id:
70 | raise CustomerIOException("id cannot be blank in identify")
71 | url = self.get_customer_query_string(id)
72 | self.send_request('PUT', url, kwargs)
73 |
74 | def track(self, customer_id, name, **data):
75 | '''Track an event for a given customer_id'''
76 | if not customer_id:
77 | raise CustomerIOException("customer_id cannot be blank in track")
78 | url = self.get_event_query_string(customer_id)
79 | post_data = {
80 | 'name': name,
81 | 'data': self._sanitize(data),
82 | }
83 | self.send_request('POST', url, post_data)
84 |
85 | def track_anonymous(self, anonymous_id, name, **data):
86 | '''Track an event for a given anonymous_id'''
87 | url = self.get_events_query_string()
88 | post_data = {
89 | 'name': name,
90 | 'data': self._sanitize(data),
91 | }
92 | if anonymous_id:
93 | post_data['anonymous_id'] = anonymous_id
94 |
95 | self.send_request('POST', url, post_data)
96 |
97 | def pageview(self, customer_id, page, **data):
98 | '''Track a pageview for a given customer_id'''
99 | if not customer_id:
100 | raise CustomerIOException("customer_id cannot be blank in pageview")
101 | url = self.get_event_query_string(customer_id)
102 | post_data = {
103 | 'type': "page",
104 | 'name': page,
105 | 'data': self._sanitize(data),
106 | }
107 | self.send_request('POST', url, post_data)
108 |
109 | def backfill(self, customer_id, name, timestamp, **data):
110 | '''Backfill an event (track with timestamp) for a given customer_id'''
111 | if not customer_id:
112 | raise CustomerIOException("customer_id cannot be blank in backfill")
113 |
114 | url = self.get_event_query_string(customer_id)
115 |
116 | if isinstance(timestamp, datetime):
117 | timestamp = self._datetime_to_timestamp(timestamp)
118 | elif not isinstance(timestamp, int):
119 | try:
120 | timestamp = int(timestamp)
121 | except Exception as e:
122 | raise CustomerIOException(
123 | "{t} is not a valid timestamp ({err})".format(t=timestamp, err=e))
124 |
125 | post_data = {
126 | 'name': name,
127 | 'data': self._sanitize(data),
128 | 'timestamp': timestamp
129 | }
130 |
131 | self.send_request('POST', url, post_data)
132 |
133 | def delete(self, customer_id):
134 | '''Delete a customer profile'''
135 | if not customer_id:
136 | raise CustomerIOException("customer_id cannot be blank in delete")
137 |
138 | url = self.get_customer_query_string(customer_id)
139 | self.send_request('DELETE', url, {})
140 |
141 | def add_device(self, customer_id, device_id, platform, **data):
142 | '''Add a device to a customer profile'''
143 | if not customer_id:
144 | raise CustomerIOException(
145 | "customer_id cannot be blank in add_device")
146 |
147 | if not device_id:
148 | raise CustomerIOException(
149 | "device_id cannot be blank in add_device")
150 |
151 | if not platform:
152 | raise CustomerIOException("platform cannot be blank in add_device")
153 |
154 | data.update({
155 | 'id': device_id,
156 | 'platform': platform,
157 | })
158 | payload = {'device': data}
159 | url = self.get_device_query_string(customer_id)
160 | self.send_request('PUT', url, payload)
161 |
162 | def delete_device(self, customer_id, device_id):
163 | '''Delete a device from a customer profile'''
164 | if not customer_id:
165 | raise CustomerIOException("customer_id cannot be blank in delete_device")
166 |
167 | if not device_id:
168 | raise CustomerIOException("device_id cannot be blank in delete_device")
169 |
170 | url = self.get_device_query_string(customer_id)
171 | delete_url = '{base}/{token}'.format(base=url, token=self._url_encode(device_id))
172 | self.send_request('DELETE', delete_url, {})
173 |
174 | def suppress(self, customer_id):
175 | if not customer_id:
176 | raise CustomerIOException(
177 | "customer_id cannot be blank in suppress")
178 |
179 | self.send_request(
180 | 'POST', '{base}/customers/{id}/suppress'.format(base=self.base_url, id=self._url_encode(customer_id)), {})
181 |
182 | def unsuppress(self, customer_id):
183 | if not customer_id:
184 | raise CustomerIOException(
185 | "customer_id cannot be blank in unsuppress")
186 |
187 | self.send_request(
188 | 'POST', '{base}/customers/{id}/unsuppress'.format(base=self.base_url, id=self._url_encode(customer_id)), {})
189 |
190 | def is_valid_id_type(self, input):
191 | return [ID, EMAIL, CIOID].__contains__(input)
192 |
193 | def merge_customers(self, primary_id_type, primary_id, secondary_id_type, secondary_id):
194 | '''Merge seondary profile into primary profile'''
195 | if not self.is_valid_id_type(primary_id_type):
196 | raise CustomerIOException("invalid primary id type")
197 |
198 | if not self.is_valid_id_type(secondary_id_type):
199 | raise CustomerIOException("invalid secondary id type")
200 |
201 | if not primary_id:
202 | raise CustomerIOException("primary customer_id cannot be blank")
203 |
204 | if not secondary_id:
205 | raise CustomerIOException("secondary customer_id cannot be blank")
206 |
207 | url = '{base}/merge_customers'.format(base=self.base_url)
208 | post_data = {
209 | "primary": {
210 | primary_id_type: primary_id
211 | },
212 | "secondary": {
213 | secondary_id_type: secondary_id
214 | }
215 | }
216 | self.send_request('POST', url, post_data)
217 |
218 | # builds the session.
219 | def _build_session(self):
220 | session = super()._build_session()
221 | session.auth = (self.site_id, self.api_key)
222 |
223 | return session
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests>=2.20.0
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import find_packages, setup
3 |
4 | version = {}
5 | here = os.path.abspath(os.path.dirname(__file__))
6 | with open(os.path.join(here, 'customerio', '__version__.py')) as f:
7 | exec(f.read(), version)
8 |
9 | setup(
10 | name="customerio",
11 | version=version['__version__'],
12 | author="Peaberry Software Inc.",
13 | author_email="support@customerio.com",
14 | license="BSD",
15 | description="Customer.io Python bindings.",
16 | url="https://github.com/customerio/customerio-python",
17 | packages=find_packages(),
18 | classifiers=[
19 | 'Environment :: Web Environment',
20 | 'Intended Audience :: Developers',
21 | 'License :: OSI Approved :: BSD License',
22 | 'Operating System :: OS Independent',
23 | 'Programming Language :: Python',
24 | 'Programming Language :: Python :: 3.6',
25 | 'Programming Language :: Python :: 3.7',
26 | 'Programming Language :: Python :: 3.8',
27 | 'Programming Language :: Python :: 3.9',
28 | ],
29 | install_requires=['requests>=2.20.0'],
30 | test_suite="tests",
31 | )
32 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-python/bfc6707d65251c419724f0189e40cc63411ba4c0/tests/__init__.py
--------------------------------------------------------------------------------
/tests/server.py:
--------------------------------------------------------------------------------
1 | try:
2 | from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
3 | except ImportError:
4 | from http.server import BaseHTTPRequestHandler, HTTPServer
5 |
6 | from functools import wraps
7 | from random import randint
8 | import json
9 | import ssl
10 | import time
11 | import threading
12 | import unittest
13 |
14 | def sslwrap(func):
15 | @wraps(func)
16 | def bar(*args, **kw):
17 | kw['ssl_version'] = ssl.PROTOCOL_SSLv23
18 | return func(*args, **kw)
19 | return bar
20 |
21 | request_counts = dict()
22 |
23 | class Handler(BaseHTTPRequestHandler):
24 | '''Handler definition for the testing server instance.
25 |
26 | This handler returns without setting response status code which causes
27 | httplib to raise BadStatusLine exception.
28 | The handler reads the post body and fails for the `fail_count` specified.
29 | After sending specified number of bad responses will sent a valid response.
30 | '''
31 | def do_DELETE(self):
32 | self.send_response(200)
33 | self.end_headers()
34 |
35 | def do_POST(self):
36 | self.send_response(200)
37 | self.end_headers()
38 | self.wfile.write(bytes("{}", "utf-8"))
39 |
40 | def do_PUT(self):
41 | global request_counts
42 |
43 | # extract params
44 | _id = self.path.split("/")[-1]
45 | content_len = int(self.headers.get('content-length', 0))
46 | params = json.loads(self.rfile.read(content_len).decode('utf-8'))
47 | fail_count = params.get('fail_count', 0)
48 |
49 | # retrieve number of requests already served
50 | processed = request_counts.get(_id, 0)
51 | if processed > fail_count:
52 | # return a valid response
53 | self.send_response(200)
54 | self.end_headers()
55 | return
56 |
57 | # increment number of requests and return invalid response
58 | request_counts[_id] = processed + 1
59 | return
60 |
61 | # Silence the server so test output is not cluttered
62 | def log_message(self, format, *args):
63 | return
64 |
65 |
66 | class HTTPSTestCase(unittest.TestCase):
67 | '''Test case class that starts up a https server and exposes it via the `server` attribute.
68 |
69 | The testing server is only created in the setUpClass method so that multiple
70 | tests can use the same server instance. The server is started in a separate
71 | thread and once the tests are completed the server is shutdown and cleaned up.
72 | '''
73 |
74 | @classmethod
75 | def setUpClass(cls):
76 | # create a server
77 | cls.server = HTTPServer(("localhost", 0), Handler)
78 | # hack needed to setup ssl server
79 | ssl.wrap_socket = sslwrap(ssl.wrap_socket)
80 | # upgrade to https
81 | cls.server.socket = ssl.wrap_socket(cls.server.socket,
82 | certfile='./tests/server.pem',
83 | server_side=True)
84 | # start server instance in new thread
85 | cls.server_thread = threading.Thread(target=cls.server.serve_forever)
86 | cls.server_thread.start()
87 | # Wait a bit for the server to bind to port
88 | time.sleep(1)
89 |
90 | @classmethod
91 | def tearDownClass(cls):
92 | # shutdown server and close thread
93 | cls.server.shutdown()
94 | cls.server.socket.close()
95 | cls.server_thread.join()
96 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from datetime import datetime
3 | from functools import partial
4 | import json
5 | import sys
6 | import unittest
7 |
8 | from customerio import APIClient, SendEmailRequest, SendPushRequest, Regions, CustomerIOException
9 | from customerio.__version__ import __version__ as ClientVersion
10 | from tests.server import HTTPSTestCase
11 |
12 | import requests
13 | from requests.auth import _basic_auth_str
14 |
15 | # test uses a self signed certificate so disable the warning messages
16 | requests.packages.urllib3.disable_warnings()
17 |
18 |
19 | class TestAPIClient(HTTPSTestCase):
20 | '''Starts server which the client connects to in the following tests'''
21 |
22 | def setUp(self):
23 | self.client = APIClient(
24 | key='app_api_key',
25 | url="https://{addr}:{port}".format(
26 | addr=self.server.server_address[0], port=self.server.server_port))
27 |
28 | # do not verify the ssl certificate as it is self signed
29 | # should only be done for tests
30 | self.client.http.verify = False
31 |
32 | def _check_request(self, resp, rq, *args, **kwargs):
33 | request = resp.request
34 | self.assertEqual(request.method, rq['method'])
35 | self.assertEqual(json.loads(request.body.decode('utf-8')), rq['body'])
36 | self.assertEqual(request.headers['Authorization'], rq['authorization'])
37 | self.assertEqual(request.headers['Content-Type'], rq['content_type'])
38 | self.assertEqual(
39 | int(request.headers['Content-Length']), len(json.dumps(rq['body'])))
40 | self.assertTrue(request.url.endswith(rq['url_suffix']),
41 | 'url: {} expected suffix: {}'.format(request.url, rq['url_suffix']))
42 |
43 | def test_client_setup(self):
44 | client = APIClient(key='app_api_key')
45 | self.assertEqual(client.url, 'https://{host}'.format(host=Regions.US.api_host))
46 |
47 | client = APIClient(key='app_api_key', region=Regions.US)
48 | self.assertEqual(client.url, 'https://{host}'.format(host=Regions.US.api_host))
49 |
50 | client = APIClient(key='app_api_key', region=Regions.EU)
51 | self.assertEqual(client.url, 'https://{host}'.format(host=Regions.EU.api_host))
52 |
53 | self.assertEqual(self.client.http.headers['User-Agent'], 'Customer.io Python Client/{}'.format(ClientVersion))
54 |
55 | # Raises an exception when an invalid region is passed in
56 | with self.assertRaises(CustomerIOException):
57 | APIClient(key='app_api_key', region='au')
58 |
59 | def test_send_email(self):
60 | data = "1,2,3"
61 | expected = base64.b64encode(bytes(data,"utf-8")).decode()
62 |
63 | self.client.http.hooks = dict(response=partial(self._check_request, rq={
64 | 'method': 'POST',
65 | 'authorization': "Bearer app_api_key",
66 | 'content_type': 'application/json',
67 | 'url_suffix': '/v1/send/email',
68 | 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100, "subject": "transactional message", "attachments":{"sample.csv": expected}},
69 | }))
70 |
71 | email = SendEmailRequest(
72 | identifiers={"id":"customer_1"},
73 | transactional_message_id=100,
74 | subject="transactional message"
75 | )
76 | email.attach('sample.csv', data)
77 |
78 | self.client.send_email(email)
79 |
80 | def test_send_push(self):
81 | self.client.http.hooks = dict(response=partial(self._check_request, rq={
82 | 'method': 'POST',
83 | 'authorization': "Bearer app_api_key",
84 | 'content_type': 'application/json',
85 | 'url_suffix': '/v1/send/push',
86 | 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100, "title": "transactional push message", "message": "push message content"}
87 | }))
88 |
89 | push = SendPushRequest(
90 | identifiers={"id":"customer_1"},
91 | transactional_message_id=100,
92 | title="transactional push message",
93 | message="push message content"
94 | )
95 |
96 | self.client.send_push(push)
97 |
98 | if __name__ == '__main__':
99 | unittest.main()
100 |
--------------------------------------------------------------------------------
/tests/test_customerio.py:
--------------------------------------------------------------------------------
1 | from customerio.constants import CIOID, EMAIL, ID
2 | from datetime import datetime
3 | from functools import partial
4 | import json
5 | import unittest
6 |
7 | from customerio import CustomerIO, CustomerIOException, Regions
8 | from tests.server import HTTPSTestCase
9 |
10 | import requests
11 | from requests.auth import _basic_auth_str
12 |
13 | # test uses a self signed certificate so disable the warning messages
14 | requests.packages.urllib3.disable_warnings()
15 |
16 | class TestCustomerIO(HTTPSTestCase):
17 | '''Starts server which the client connects to in the following tests'''
18 | def setUp(self):
19 | self.cio = CustomerIO(
20 | site_id='siteid',
21 | api_key='apikey',
22 | host=self.server.server_address[0],
23 | port=self.server.server_port,
24 | retries=5,
25 | backoff_factor=0)
26 |
27 | # do not verify the ssl certificate as it is self signed
28 | # should only be done for tests
29 | self.cio.http.verify = False
30 |
31 | def _check_request(self, resp, rq, *args, **kwargs):
32 | request = resp.request
33 | body = request.body.decode('utf-8') if isinstance(request.body, bytes) else request.body
34 | if rq.get('method', None):
35 | self.assertEqual(request.method, rq['method'])
36 | if rq.get('body', None):
37 | self.assertEqual(json.loads(body), rq['body'])
38 | if rq.get('authorization', None):
39 | self.assertEqual(request.headers['Authorization'], rq['authorization'])
40 | if rq.get('content_type', None):
41 | self.assertEqual(request.headers['Content-Type'], rq['content_type'])
42 | if rq.get('body', None):
43 | self.assertEqual(int(request.headers['Content-Length']), len(json.dumps(rq['body'])))
44 | if rq.get('url_suffix', None):
45 | self.assertTrue(request.url.endswith(rq['url_suffix']),
46 | 'url: {} expected suffix: {}'.format(request.url, rq['url_suffix']))
47 |
48 | def test_client_setup(self):
49 | client = CustomerIO(site_id='site_id', api_key='api_key')
50 | self.assertEqual(client.host, Regions.US.track_host)
51 |
52 | client = CustomerIO(site_id='site_id', api_key='api_key', region=Regions.US)
53 | self.assertEqual(client.host, Regions.US.track_host)
54 |
55 | client = CustomerIO(site_id='site_id', api_key='api_key', region=Regions.EU)
56 | self.assertEqual(client.host, Regions.EU.track_host)
57 |
58 | # Raises an exception when an invalid region is passed in
59 | with self.assertRaises(CustomerIOException):
60 | client = CustomerIO(site_id='site_id', api_key='api_key', region='au')
61 |
62 |
63 | def test_client_connection_handling(self):
64 | retries = self.cio.retries
65 | # should not raise exception as i should be less than retries and
66 | # therefore the last request should return a valid response
67 | for i in range(retries):
68 | self.cio.identify(str(i), fail_count=i)
69 |
70 | # should raise expection as we get invalid responses for all retries
71 | with self.assertRaises(CustomerIOException):
72 | self.cio.identify(retries, fail_count=retries)
73 |
74 |
75 | def test_identify_call(self):
76 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
77 | 'method': 'PUT',
78 | 'authorization': _basic_auth_str('siteid', 'apikey'),
79 | 'content_type': 'application/json',
80 | 'url_suffix': '/customers/1',
81 | 'body': {"name": "john", "email": "john@test.com"},
82 | }))
83 |
84 | self.cio.identify(id=1, name='john', email='john@test.com')
85 |
86 | with self.assertRaises(TypeError):
87 | self.cio.identify(random_attr="some_value")
88 |
89 |
90 | def test_track_call(self):
91 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
92 | 'method': 'POST',
93 | 'authorization': _basic_auth_str('siteid', 'apikey'),
94 | 'content_type': 'application/json',
95 | 'url_suffix': '/customers/1/events',
96 | 'body': {"data": {"email": "john@test.com"}, "name": "sign_up"},
97 | }))
98 |
99 | self.cio.track(customer_id=1, name='sign_up', email='john@test.com')
100 |
101 | with self.assertRaises(TypeError):
102 | self.cio.track(random_attr="some_value")
103 |
104 |
105 | def test_track_anonymous_call(self):
106 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
107 | 'method': 'POST',
108 | 'authorization': _basic_auth_str('siteid', 'apikey'),
109 | 'content_type': 'application/json',
110 | 'url_suffix': '/events',
111 | 'body': {"data": {"email": "john@test.com"}, "name": "sign_up", "anonymous_id": 123},
112 | }))
113 |
114 | self.cio.track_anonymous(anonymous_id=123, name='sign_up', email='john@test.com')
115 |
116 | def test_pageview_call(self):
117 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
118 | 'method': 'POST',
119 | 'authorization': _basic_auth_str('siteid', 'apikey'),
120 | 'content_type': 'application/json',
121 | 'url_suffix': '/customers/1/events',
122 | 'body': {"data": {"referer": "category_1"}, "type": "page", "name": "product_1"},
123 | }))
124 |
125 | self.cio.pageview(customer_id=1, page='product_1', referer='category_1')
126 |
127 | with self.assertRaises(TypeError):
128 | self.cio.pageview(random_attr="some_value")
129 |
130 |
131 | def test_delete_call(self):
132 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
133 | 'method': 'DELETE',
134 | 'authorization': _basic_auth_str('siteid', 'apikey'),
135 | 'content_type': 'application/json',
136 | 'url_suffix': '/customers/1',
137 | 'body': {},
138 | }))
139 |
140 | self.cio.delete(customer_id=1)
141 |
142 | with self.assertRaises(TypeError):
143 | self.cio.delete(random_attr="some_value")
144 |
145 |
146 | def test_backfill_call(self):
147 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
148 | 'method': 'POST',
149 | 'authorization': _basic_auth_str('siteid', 'apikey'),
150 | 'content_type': 'application/json',
151 | 'url_suffix': '/customers/1/events',
152 | 'body': {"timestamp": 1234567890, "data": {"email": "john@test.com"}, "name": "signup"},
153 | }))
154 |
155 | self.cio.backfill(customer_id=1, name='signup', timestamp=1234567890, email='john@test.com')
156 |
157 | with self.assertRaises(TypeError):
158 | self.cio.backfill(random_attr="some_value")
159 |
160 | def test_base_url(self):
161 | test_cases = [
162 | # host, port, prefix, result
163 | (None, None, None, 'https://track.customer.io/api/v1'),
164 | (None, None, 'v2', 'https://track.customer.io/v2'),
165 | (None, None, '/v2/', 'https://track.customer.io/v2'),
166 | ('sub.domain.com', 1337, '/v2/', 'https://sub.domain.com:1337/v2'),
167 | ('/sub.domain.com/', 1337, '/v2/', 'https://sub.domain.com:1337/v2'),
168 | ('http://sub.domain.com/', 1337, '/v2/', 'https://sub.domain.com:1337/v2'),
169 | ]
170 |
171 | for host, port, prefix, result in test_cases:
172 | cio = CustomerIO(host=host, port=port, url_prefix=prefix)
173 | self.assertEqual(cio.base_url, result)
174 |
175 |
176 | def test_device_call(self):
177 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
178 | 'method': 'PUT',
179 | 'authorization': _basic_auth_str('siteid', 'apikey'),
180 | 'content_type': 'application/json',
181 | 'url_suffix': '/customers/1/devices',
182 | 'body': {"device": {"id": "device_1", "platform":"ios"}}
183 | }))
184 |
185 | self.cio.add_device(customer_id=1, device_id="device_1", platform="ios")
186 | with self.assertRaises(TypeError):
187 | self.cio.add_device(random_attr="some_value")
188 |
189 | def test_device_call_last_used(self):
190 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
191 | 'method': 'PUT',
192 | 'authorization': _basic_auth_str('siteid', 'apikey'),
193 | 'content_type': 'application/json',
194 | 'url_suffix': '/customers/1/devices',
195 | 'body': {"device": {"id": "device_2", "platform": "android", "last_used": 1234567890}}
196 | }))
197 |
198 | self.cio.add_device(customer_id=1, device_id="device_2", platform="android", last_used=1234567890)
199 |
200 | def test_device_call_valid_platform(self):
201 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
202 | 'method': 'PUT',
203 | 'authorization': _basic_auth_str('siteid', 'apikey'),
204 | 'content_type': 'application/json',
205 | 'url_suffix': '/customers/1/devices',
206 | 'body': {"device": {"id": "device_3", "platform": "notsupported"}}
207 | }))
208 |
209 | with self.assertRaises(CustomerIOException):
210 | self.cio.add_device(customer_id=1, device_id="device_3", platform=None)
211 |
212 | def test_device_call_has_customer_id(self):
213 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
214 | 'method': 'PUT',
215 | 'authorization': _basic_auth_str('siteid', 'apikey'),
216 | 'content_type': 'application/json',
217 | 'url_suffix': '/customers/1/devices',
218 | 'body': {"device": {"id": "device_4", "platform": "ios"}}
219 | }))
220 |
221 | with self.assertRaises(CustomerIOException):
222 | self.cio.add_device(customer_id="", device_id="device_4", platform="ios")
223 |
224 | def test_device_call_has_device_id(self):
225 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
226 | 'method': 'PUT',
227 | 'authorization': _basic_auth_str('siteid', 'apikey'),
228 | 'content_type': 'application/json',
229 | 'url_suffix': '/customers/1/devices',
230 | 'body': {"device": {"id": "device_5", "platform": "ios"}}
231 | }))
232 |
233 | with self.assertRaises(CustomerIOException):
234 | self.cio.add_device(customer_id=1, device_id="", platform="ios")
235 |
236 | def test_device_delete_call(self):
237 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
238 | 'method': 'DELETE',
239 | 'authorization': _basic_auth_str('siteid', 'apikey'),
240 | 'content_type': 'application/json',
241 | 'url_suffix': '/customers/1/devices/device_1',
242 | 'body': {}
243 | }))
244 |
245 | self.cio.delete_device(customer_id=1, device_id="device_1")
246 | with self.assertRaises(TypeError):
247 | self.cio.delete_device(random_attr="some_value")
248 |
249 | def test_suppress_call(self):
250 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
251 | 'method': 'POST',
252 | 'authorization': _basic_auth_str('siteid', 'apikey'),
253 | 'content_type': 'application/json',
254 | 'url_suffix': '/customers/1/suppress',
255 | 'body': {},
256 | }))
257 |
258 | self.cio.suppress(customer_id=1)
259 |
260 | with self.assertRaises(CustomerIOException):
261 | self.cio.suppress(None)
262 |
263 | def test_unsuppress_call(self):
264 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
265 | 'method': 'POST',
266 | 'authorization': _basic_auth_str('siteid', 'apikey'),
267 | 'content_type': 'application/json',
268 | 'url_suffix': '/customers/1/unsuppress',
269 | 'body': {},
270 | }))
271 |
272 | self.cio.unsuppress(customer_id=1)
273 |
274 | with self.assertRaises(CustomerIOException):
275 | self.cio.unsuppress(None)
276 |
277 | def test_sanitize(self):
278 | from datetime import timezone
279 | data_in = dict(dt=datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc))
280 | data_out = self.cio._sanitize(data_in)
281 | self.assertEqual(data_out, dict(dt=1234567890))
282 |
283 | def test_ids_are_encoded_in_url(self):
284 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
285 | 'url_suffix': '/customers/1/unsuppress',
286 | }))
287 | self.cio.unsuppress(customer_id=1)
288 |
289 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
290 | 'url_suffix': '/customers/1%2F',
291 | }))
292 | self.cio.identify(id="1/")
293 |
294 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
295 | 'url_suffix': '/customers/1%20/events',
296 | }))
297 | self.cio.track(customer_id="1 ", name="test")
298 |
299 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
300 | 'url_suffix': '/customers/1%2F/devices/2%20',
301 | }))
302 | self.cio.delete_device(customer_id="1/", device_id="2 ")
303 |
304 | def test_merge_customers_call(self):
305 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
306 | 'method': 'POST',
307 | 'authorization': _basic_auth_str('siteid', 'apikey'),
308 | 'content_type': 'application/json',
309 | 'url_suffix': '/merge_customers',
310 | 'body': {'primary': {ID: 'CIO123'}, 'secondary': {EMAIL: 'person1@company.com'}},
311 | }))
312 | self.cio.merge_customers(ID, "CIO123", EMAIL, "person1@company.com")
313 |
314 | self.cio.http.hooks=dict(response=partial(self._check_request, rq={
315 | 'method': 'POST',
316 | 'authorization': _basic_auth_str('siteid', 'apikey'),
317 | 'content_type': 'application/json',
318 | 'url_suffix': '/merge_customers',
319 | 'body': {'primary': {'cio_id': 'CIO456'}, 'secondary': {'id': 'MyCustomId'}},
320 | }))
321 | self.cio.merge_customers(CIOID, "CIO456", ID, "MyCustomId")
322 |
323 | with self.assertRaises(CustomerIOException):
324 | self.cio.merge_customers(primary_id_type=EMAIL,
325 | primary_id="coolperson@cio.com",
326 | secondary_id_type="something",
327 | secondary_id="C123"
328 | )
329 |
330 | with self.assertRaises(CustomerIOException):
331 | self.cio.merge_customers(primary_id_type="not_valid",
332 | primary_id="coolperson@cio.com",
333 | secondary_id_type="something",
334 | secondary_id="C123"
335 | )
336 |
337 | with self.assertRaises(CustomerIOException):
338 | self.cio.merge_customers(primary_id_type=EMAIL,
339 | primary_id="",
340 | secondary_id_type="something",
341 | secondary_id="C123"
342 | )
343 | with self.assertRaises(CustomerIOException):
344 | self.cio.merge_customers(primary_id_type=EMAIL,
345 | primary_id="coolperson@cio.com",
346 | secondary_id_type="something",
347 | secondary_id=""
348 | )
349 |
350 | if __name__ == '__main__':
351 | unittest.main()
352 |
--------------------------------------------------------------------------------