├── .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 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blueviolet?logo=gitpod)](https://gitpod.io/#https://github.com/customerio/customerio-python/) 8 | ![PyPI](https://img.shields.io/pypi/v/customerio) 9 | ![Software License](https://img.shields.io/github/license/customerio/customerio-python) 10 | [![Build status](https://github.com/customerio/customerio-python/actions/workflows/main.yml/badge.svg)](https://github.com/customerio/customerio-python/actions/workflows/main.yml) 11 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/customerio) 12 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/customerio) 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 | --------------------------------------------------------------------------------