From 9107693ec76144d0f43476483fb07aa025008a19 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 00:08:07 -0400 Subject: [PATCH 1/6] fix ci --- .github/workflows/main.yml | 5 ++++- Makefile | 13 +++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2abd4f7..44b14d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,9 @@ name: build -on: [ push ] +on: + push: + pull_request: + workflow_dispatch: jobs: build: diff --git a/Makefile b/Makefile index 2f5501c..71c60b5 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,20 @@ +PYTHON ?= python3 all: - python setup.py sdist - python -m doctest ./customerio/__init__.py + $(PYTHON) setup.py sdist + $(PYTHON) -m doctest ./customerio/__init__.py install: - python setup.py install + $(PYTHON) setup.py install clean: - python setup.py clean + $(PYTHON) setup.py clean rm -rf MANIFEST build dist dev: clean all if ! pip uninstall customerio; then echo "customerio not installed, installing it for the first time" ; fi pip install dist/* - python -i -c "from customerio import *" + $(PYTHON) -i -c "from customerio import *" upload: python setup.py register @@ -21,4 +22,4 @@ upload: test: 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 - python -m unittest discover -v + $(PYTHON) -m unittest discover -v From b07e0d19b34d87d8a9ea9a717bf2d2a7c2524ef4 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 10:02:55 -0400 Subject: [PATCH 2/6] whoop --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 71c60b5..500a6d8 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ dev: clean all $(PYTHON) -i -c "from customerio import *" upload: - python setup.py register + $(PYTHON) setup.py register 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 test: From 50dbd1e6c05f56b0ccf637b7e5a5165388168f22 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 11:44:39 -0400 Subject: [PATCH 3/6] Modernize Python CI and packaging --- .github/dependabot.yml | 11 + .github/workflows/main.yml | 91 +++++-- .gitignore | 6 +- Makefile | 30 ++- README.md | 4 +- customerio/__init__.py | 25 +- customerio/__version__.py | 15 +- customerio/api.py | 435 +++++++++++++++---------------- customerio/client_base.py | 74 +++--- customerio/regions.py | 7 +- customerio/track.py | 192 +++++++------- pyproject.toml | 61 +++++ setup.py | 30 +-- tests/server.py | 47 ++-- tests/test_api.py | 193 ++++++++------ tests/test_customerio.py | 509 +++++++++++++++++++++++-------------- 16 files changed, 992 insertions(+), 738 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 pyproject.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0e28f6a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44b14d5..55df217 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,39 +1,76 @@ -name: build +name: CI on: push: pull_request: workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + lint: + name: Lint and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Run ruff + run: | + python -m ruff check . + python -m ruff format --check . + + test: + name: Test Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install package + run: | + python -m pip install --upgrade pip + python -m pip install -e . + - name: Run tests + run: make test + + package: + name: Build distribution + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5.6.0 - with: - python-version: ${{ matrix.python }} - - name: install dependencies for the minor version - run: | - python -m venv venv - . venv/bin/activate - pip install -r requirements.txt - - name: run tests - run: | - . venv/bin/activate - make test - deactivate - - name: reinstall to the latest version - run: | - python -m venv venv - . venv/bin/activate - pip install --upgrade requests - - name: run tests again - run: | - . venv/bin/activate - make test + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Build and verify package + run: | + python -m build + python -m twine check dist/* diff --git a/.gitignore b/.gitignore index 7df5218..2b13534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ *.pyc *.pyo +.venv/ +.coverage +.ruff_cache/ +.pytest_cache/ tests/server.pem dist customerio.egg-info .DS_Store MANIFEST build/ -setup.cfg \ No newline at end of file +setup.cfg diff --git a/Makefile b/Makefile index 500a6d8..913a986 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,15 @@ PYTHON ?= python3 +OPENSSL ?= openssl +SERVER_CERT := tests/server.pem -all: - $(PYTHON) setup.py sdist +all: build $(PYTHON) -m doctest ./customerio/__init__.py install: - $(PYTHON) setup.py install + $(PYTHON) -m pip install . clean: - $(PYTHON) setup.py clean - rm -rf MANIFEST build dist + rm -rf MANIFEST build dist customerio.egg-info .ruff_cache dev: clean all if ! pip uninstall customerio; then echo "customerio not installed, installing it for the first time" ; fi @@ -17,9 +17,21 @@ dev: clean all $(PYTHON) -i -c "from customerio import *" upload: - $(PYTHON) setup.py register - 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 + $(PYTHON) -m twine upload dist/* -test: - 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 +build: + $(PYTHON) -m build + +lint: + $(PYTHON) -m ruff check . + $(PYTHON) -m ruff format --check . + +format: + $(PYTHON) -m ruff check --fix . + $(PYTHON) -m ruff format . + +test: $(SERVER_CERT) $(PYTHON) -m unittest discover -v + +$(SERVER_CERT): + $(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 $(SERVER_CERT) -out $(SERVER_CERT) diff --git a/README.md b/README.md index 7df2a61..16a2ff1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/customerio) ![PyPI - Downloads](https://img.shields.io/pypi/dm/customerio) -# Customer.io Python +# Customer.io Python -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. +This module is tested with Python 3.10 through 3.14. If you're new to Customer.io, we recommend that you integrate with our [Data Pipelines Python library](https://github.com/customerio/cdp-analytics-python) instead. ## Installing diff --git a/customerio/__init__.py b/customerio/__init__.py index 29f9d4e..92eb5c3 100644 --- a/customerio/__init__.py +++ b/customerio/__init__.py @@ -1,6 +1,23 @@ -import warnings - +from customerio.api import ( + APIClient, + SendEmailRequest, + SendInAppRequest, + SendInboxMessageRequest, + SendPushRequest, + SendSMSRequest, +) from customerio.client_base import CustomerIOException -from customerio.track import CustomerIO -from customerio.api import APIClient, SendEmailRequest, SendPushRequest, SendSMSRequest, SendInboxMessageRequest, SendInAppRequest from customerio.regions import Regions +from customerio.track import CustomerIO + +__all__ = [ + "APIClient", + "CustomerIO", + "CustomerIOException", + "Regions", + "SendEmailRequest", + "SendInAppRequest", + "SendInboxMessageRequest", + "SendPushRequest", + "SendSMSRequest", +] diff --git a/customerio/__version__.py b/customerio/__version__.py index cd6d34a..3d67cd6 100644 --- a/customerio/__version__.py +++ b/customerio/__version__.py @@ -1,14 +1 @@ -VERSION = (2, 4, 0, 'final', 0) - -def get_version(): - version = '%s.%s' % (VERSION[0], VERSION[1]) - if VERSION[2]: - version = '%s.%s' % (version, VERSION[2]) - if VERSION[3:] == ('alpha', 0): - version = '%s-pre-alpha' % version - else: - if VERSION[3] != 'final': - version = '%s-%s-%s' % (version, VERSION[3], VERSION[4]) - return version - -__version__=get_version() +__version__ = "2.4.0" diff --git a/customerio/api.py b/customerio/api.py index 85ee02b..7f09727 100644 --- a/customerio/api.py +++ b/customerio/api.py @@ -1,85 +1,161 @@ """ Implements the client that interacts with Customer.io's App API using app keys. """ + import base64 import json + from .client_base import ClientBase, CustomerIOException -from .regions import Regions, Region +from .regions import Region, Regions + + +def _payload_from_fields(source, field_map): + return { + name: value + for field, name in field_map.items() + if (value := getattr(source, field, None)) is not None + } + + +COMMON_MESSAGE_FIELD_MAP = { + "transactional_message_id": "transactional_message_id", + "identifiers": "identifiers", + "disable_message_retention": "disable_message_retention", + "queue_draft": "queue_draft", + "message_data": "message_data", + "send_at": "send_at", + "language": "language", +} + +EMAIL_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | { + # from is a reserved keyword, so the object field is `_from`. + "_from": "from", + "to": "to", + "headers": "headers", + "reply_to": "reply_to", + "bcc": "bcc", + "subject": "subject", + "preheader": "preheader", + "body": "body", + "body_plain": "body_plain", + "body_amp": "body_amp", + "fake_bcc": "fake_bcc", + "send_to_unsubscribed": "send_to_unsubscribed", + "tracked": "tracked", + "attachments": "attachments", + "disable_css_preproceessing": "disable_css_preproceessing", +} + +PUSH_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | { + "to": "to", + "send_to_unsubscribed": "send_to_unsubscribed", + "title": "title", + "message": "message", + "image_url": "image_url", + "link": "link", + "custom_data": "custom_data", + "custom_payload": "custom_payload", + "device": "custom_device", + "sound": "sound", +} + +SMS_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP | { + "to": "to", + "send_to_unsubscribed": "send_to_unsubscribed", +} + +INBOX_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP +IN_APP_FIELD_MAP = COMMON_MESSAGE_FIELD_MAP + class APIClient(ClientBase): - def __init__(self, key, url=None, region=Regions.US, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True): + def __init__( + self, + key, + url=None, + region=Regions.US, + retries=3, + timeout=10, + backoff_factor=0.02, + use_connection_pooling=True, + ): if not isinstance(region, Region): - raise CustomerIOException('invalid region provided') + raise CustomerIOException("invalid region provided") self.key = key - self.url = url or 'https://{host}'.format(host=region.api_host) - ClientBase.__init__(self, retries=retries, - timeout=timeout, backoff_factor=backoff_factor, use_connection_pooling=use_connection_pooling) + self.url = url or f"https://{region.api_host}" + super().__init__( + retries=retries, + timeout=timeout, + backoff_factor=backoff_factor, + use_connection_pooling=use_connection_pooling, + ) def send_email(self, request): if isinstance(request, SendEmailRequest): request = request._to_dict() - resp = self.send_request('POST', self.url + "/v1/send/email", request) + resp = self.send_request("POST", self.url + "/v1/send/email", request) return json.loads(resp) def send_push(self, request): if isinstance(request, SendPushRequest): request = request._to_dict() - resp = self.send_request('POST', self.url + "/v1/send/push", request) + resp = self.send_request("POST", self.url + "/v1/send/push", request) return json.loads(resp) - + def send_sms(self, request): if isinstance(request, SendSMSRequest): request = request._to_dict() - resp = self.send_request('POST', self.url + "/v1/send/sms", request) + resp = self.send_request("POST", self.url + "/v1/send/sms", request) return json.loads(resp) def send_inbox_message(self, request): if isinstance(request, SendInboxMessageRequest): request = request._to_dict() - resp = self.send_request('POST', self.url + "/v1/send/inbox_message", request) + resp = self.send_request("POST", self.url + "/v1/send/inbox_message", request) return json.loads(resp) def send_in_app(self, request): if isinstance(request, SendInAppRequest): request = request._to_dict() - resp = self.send_request('POST', self.url + "/v1/send/in_app", request) + resp = self.send_request("POST", self.url + "/v1/send/in_app", request) return json.loads(resp) - # builds the session. def _build_session(self): session = super()._build_session() - session.headers['Authorization'] = "Bearer {key}".format(key=self.key) + session.headers["Authorization"] = f"Bearer {self.key}" return session -class SendEmailRequest(object): - '''An object with all the options avaiable for triggering a transactional email message''' - def __init__(self, - transactional_message_id=None, - to=None, - identifiers=None, - _from=None, - headers=None, - reply_to=None, - bcc=None, - subject=None, - preheader=None, - body=None, - body_plain=None, - body_amp=None, - fake_bcc=None, - disable_message_retention=None, - send_to_unsubscribed=None, - tracked=None, - queue_draft=None, - message_data=None, - attachments=None, - disable_css_preproceessing=None, - send_at=None, - language=None, - ): +class SendEmailRequest: + """An object with all the options available for triggering a transactional email message.""" + + def __init__( + self, + transactional_message_id=None, + to=None, + identifiers=None, + _from=None, + headers=None, + reply_to=None, + bcc=None, + subject=None, + preheader=None, + body=None, + body_plain=None, + body_amp=None, + fake_bcc=None, + disable_message_retention=None, + send_to_unsubscribed=None, + tracked=None, + queue_draft=None, + message_data=None, + attachments=None, + disable_css_preproceessing=None, + send_at=None, + language=None, + ): self.transactional_message_id = transactional_message_id self.to = to self.identifiers = identifiers @@ -104,81 +180,49 @@ def __init__(self, self.language = language def attach(self, name, content, encode=True): - '''Helper method to add base64 encode the attachments''' + """Helper method to add base64-encoded attachments.""" if not self.attachments: self.attachments = {} - if self.attachments.get(name, None): - raise CustomerIOException("attachment {name} already exists".format(name=name)) + if name in self.attachments: + raise CustomerIOException(f"attachment {name} already exists") if encode: if isinstance(content, str): - content = base64.b64encode(content.encode('utf-8')).decode() + content = base64.b64encode(content.encode("utf-8")).decode() else: content = base64.b64encode(content).decode() self.attachments[name] = content def _to_dict(self): - '''Build a request payload from the object''' - field_map = dict( - # from is reservered keyword hence the object has the field - # `_from` but in the request payload we map it to `from` - _from="from", - # field name is the same as the payload field name - transactional_message_id="transactional_message_id", - to="to", - identifiers="identifiers", - headers="headers", - reply_to="reply_to", - bcc="bcc", - subject="subject", - preheader="preheader", - body="body", - body_plain="body_plain", - body_amp="body_amp", - fake_bcc="fake_bcc", - disable_message_retention="disable_message_retention", - send_to_unsubscribed="send_to_unsubscribed", - tracked="tracked", - queue_draft="queue_draft", - message_data="message_data", - attachments="attachments", - disable_css_preproceessing="disable_css_preproceessing", - send_at="send_at", - language="language", - ) - - data = {} - for field, name in field_map.items(): - value = getattr(self, field, None) - if value is not None: - data[name] = value - - return data - -class SendPushRequest(object): - '''An object with all the options avaiable for triggering a transactional push message''' - def __init__(self, - transactional_message_id=None, - to=None, - identifiers=None, - title=None, - message=None, - disable_message_retention=None, - send_to_unsubscribed=None, - queue_draft=None, - message_data=None, - send_at=None, - language=None, - image_url=None, - link=None, - custom_data=None, - custom_payload=None, - device=None, - sound=None - ): - + """Build a request payload from the object.""" + return _payload_from_fields(self, EMAIL_FIELD_MAP) + + +class SendPushRequest: + """An object with all the options available for triggering a transactional push message.""" + + def __init__( + self, + transactional_message_id=None, + to=None, + identifiers=None, + title=None, + message=None, + disable_message_retention=None, + send_to_unsubscribed=None, + queue_draft=None, + message_data=None, + send_at=None, + language=None, + image_url=None, + link=None, + custom_data=None, + custom_payload=None, + device=None, + sound=None, + ): self.transactional_message_id = transactional_message_id self.to = to self.identifiers = identifiers @@ -199,51 +243,25 @@ def __init__(self, self.sound = sound def _to_dict(self): - '''Build a request payload from the object''' - field_map = dict( - # field name is the same as the payload field name - transactional_message_id="transactional_message_id", - to="to", - identifiers="identifiers", - disable_message_retention="disable_message_retention", - send_to_unsubscribed="send_to_unsubscribed", - queue_draft="queue_draft", - message_data="message_data", - send_at="send_at", - language="language", - - title="title", - message="message", - image_url="image_url", - link="link", - custom_data="custom_data", - custom_payload="custom_payload", - device="custom_device", - sound="sound" - ) - - data = {} - for field, name in field_map.items(): - value = getattr(self, field, None) - if value is not None: - data[name] = value - - return data - -class SendSMSRequest(object): - '''An object with all the options avaiable for triggering a transactional SMS message''' - def __init__(self, - transactional_message_id=None, - to=None, - identifiers=None, - disable_message_retention=None, - send_to_unsubscribed=None, - queue_draft=None, - message_data=None, - send_at=None, - language=None, - ): - + """Build a request payload from the object.""" + return _payload_from_fields(self, PUSH_FIELD_MAP) + + +class SendSMSRequest: + """An object with all the options available for triggering a transactional SMS message.""" + + def __init__( + self, + transactional_message_id=None, + to=None, + identifiers=None, + disable_message_retention=None, + send_to_unsubscribed=None, + queue_draft=None, + message_data=None, + send_at=None, + language=None, + ): self.transactional_message_id = transactional_message_id self.to = to self.identifiers = identifiers @@ -255,40 +273,23 @@ def __init__(self, self.language = language def _to_dict(self): - '''Build a request payload from the object''' - field_map = dict( - # field name is the same as the payload field name - transactional_message_id="transactional_message_id", - to="to", - identifiers="identifiers", - disable_message_retention="disable_message_retention", - send_to_unsubscribed="send_to_unsubscribed", - queue_draft="queue_draft", - message_data="message_data", - send_at="send_at", - language="language", - ) - - data = {} - for field, name in field_map.items(): - value = getattr(self, field, None) - if value is not None: - data[name] = value - - return data - -class SendInboxMessageRequest(object): - '''An object with all the options avaiable for triggering a transactional inbox message''' - def __init__(self, - transactional_message_id=None, - identifiers=None, - disable_message_retention=None, - queue_draft=None, - message_data=None, - send_at=None, - language=None, - ): - + """Build a request payload from the object.""" + return _payload_from_fields(self, SMS_FIELD_MAP) + + +class SendInboxMessageRequest: + """An object with all the options available for triggering a transactional inbox message.""" + + def __init__( + self, + transactional_message_id=None, + identifiers=None, + disable_message_retention=None, + queue_draft=None, + message_data=None, + send_at=None, + language=None, + ): self.transactional_message_id = transactional_message_id self.identifiers = identifiers self.disable_message_retention = disable_message_retention @@ -298,38 +299,23 @@ def __init__(self, self.language = language def _to_dict(self): - '''Build a request payload from the object''' - field_map = dict( - # field name is the same as the payload field name - transactional_message_id="transactional_message_id", - identifiers="identifiers", - disable_message_retention="disable_message_retention", - queue_draft="queue_draft", - message_data="message_data", - send_at="send_at", - language="language", - ) - - data = {} - for field, name in field_map.items(): - value = getattr(self, field, None) - if value is not None: - data[name] = value - - return data - -class SendInAppRequest(object): - '''An object with all the options available for triggering a transactional in-app message''' - def __init__(self, - transactional_message_id=None, - identifiers=None, - disable_message_retention=None, - queue_draft=None, - message_data=None, - send_at=None, - language=None, - ): - + """Build a request payload from the object.""" + return _payload_from_fields(self, INBOX_FIELD_MAP) + + +class SendInAppRequest: + """An object with all the options available for triggering a transactional in-app message.""" + + def __init__( + self, + transactional_message_id=None, + identifiers=None, + disable_message_retention=None, + queue_draft=None, + message_data=None, + send_at=None, + language=None, + ): self.transactional_message_id = transactional_message_id self.identifiers = identifiers self.disable_message_retention = disable_message_retention @@ -339,22 +325,5 @@ def __init__(self, self.language = language def _to_dict(self): - '''Build a request payload from the object''' - field_map = dict( - # field name is the same as the payload field name - transactional_message_id="transactional_message_id", - identifiers="identifiers", - disable_message_retention="disable_message_retention", - queue_draft="queue_draft", - message_data="message_data", - send_at="send_at", - language="language", - ) - - data = {} - for field, name in field_map.items(): - value = getattr(self, field, None) - if value is not None: - data[name] = value - - return data + """Build a request payload from the object.""" + return _payload_from_fields(self, IN_APP_FIELD_MAP) diff --git a/customerio/client_base.py b/customerio/client_base.py index 60735d2..1096336 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -1,21 +1,23 @@ """ -Implements the base client that is used by other classes to make requests +Implements the base client that is used by other classes to make requests. """ -from __future__ import division -from datetime import datetime, timezone + import logging import math +from datetime import datetime, timezone from requests import Session from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry +from urllib3.util.retry import Retry from .__version__ import __version__ as ClientVersion + class CustomerIOException(Exception): pass -class ClientBase(object): + +class ClientBase: def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True): self.timeout = timeout self.retries = retries @@ -31,36 +33,42 @@ def http(self): return self._current_session def send_request(self, method, url, data): - '''Dispatches the request and returns a response''' + """Dispatches the request and returns a response.""" try: response = self.http.request( - method, url=url, json=self._sanitize(data), timeout=self.timeout) + method, + url=url, + json=self._sanitize(data), + timeout=self.timeout, + ) result_status = response.status_code if result_status != 200: - raise CustomerIOException('%s: %s %s %s' % (result_status, url, data, response.text)) + raise CustomerIOException(f"{result_status}: {url} {data} {response.text}") return response.text except Exception as e: # Raise exception alerting user that the system might be # experiencing an outage and refer them to system status page. - message = '''Failed to receive valid response after {count} retries. + message = f"""Failed to receive valid response after {self.retries} retries. Check system status at http://status.customer.io. -Last caught exception -- {klass}: {message} - '''.format(klass=type(e), message=e, count=self.retries) - raise CustomerIOException(message) +Last caught exception -- {type(e)}: {e} + """ + raise CustomerIOException(message) from e finally: self._close() def _sanitize(self, data): - for k, v in data.items(): - if isinstance(v, datetime): - data[k] = self._datetime_to_timestamp(v) - if isinstance(v, float) and math.isnan(v): - data[k] = None - return data + return {key: self._sanitize_value(value) for key, value in data.items()} + + def _sanitize_value(self, value): + if isinstance(value, datetime): + return self._datetime_to_timestamp(value) + if isinstance(value, float) and math.isnan(value): + return None + return value def _datetime_to_timestamp(self, dt): return int(dt.replace(tzinfo=timezone.utc).timestamp()) @@ -73,41 +81,33 @@ def _stringify_list(self, customer_ids): elif isinstance(v, int): customer_string_ids.append(str(v)) else: - raise CustomerIOException( - 'customer_ids cannot be {type}'.format(type=type(v))) + raise CustomerIOException(f"customer_ids cannot be {type(v)}") return customer_string_ids - # gets a session based on whether we want pooling or not. If no pooling is desired, we create a new session each time. def _get_session(self): - if (self.use_connection_pooling): - if (self._current_session is None): + if self.use_connection_pooling: + if self._current_session is None: self._current_session = self._build_session() - # if we're using pooling, return the existing session. logging.debug("Using existing session...") return self._current_session - else: - # if we're not using pooling, build a new session. - logging.debug("Creating new session...") - self._current_session = self._build_session() + + logging.debug("Creating new session...") + self._current_session = self._build_session() return self._current_session - # builds the session. def _build_session(self): session = Session() - session.headers['User-Agent'] = "Customer.io Python Client/{version}".format(version=ClientVersion) + session.headers["User-Agent"] = f"Customer.io Python Client/{ClientVersion}" - # Retry request a number of times before raising an exception - # also define backoff_factor to delay each retry session.mount( - 'https://', - HTTPAdapter(max_retries=Retry(total=self.retries, backoff_factor=self.backoff_factor))) + "https://", + HTTPAdapter(max_retries=Retry(total=self.retries, backoff_factor=self.backoff_factor)), + ) return session - # closes the session if we're not using connection pooling. def _close(self): - # if we're not using pooling; clean up the resources. - if (not self.use_connection_pooling): + if not self.use_connection_pooling and self._current_session is not None: self._current_session.close() self._current_session = None diff --git a/customerio/regions.py b/customerio/regions.py index d6d10bf..e1460af 100644 --- a/customerio/regions.py +++ b/customerio/regions.py @@ -1,7 +1,8 @@ from collections import namedtuple -Region = namedtuple('Region', ['name', 'track_host', 'api_host']) +Region = namedtuple("Region", ["name", "track_host", "api_host"]) + class Regions: - US = Region('us', 'track.customer.io', 'api.customer.io') - EU = Region('eu', 'track-eu.customer.io', 'api-eu.customer.io') + US = Region("us", "track.customer.io", "api.customer.io") + EU = Region("eu", "track-eu.customer.io", "api-eu.customer.io") diff --git a/customerio/track.py b/customerio/track.py index de90b16..e76d3fc 100644 --- a/customerio/track.py +++ b/customerio/track.py @@ -1,113 +1,133 @@ """ Implements the client that interacts with Customer.io's Track API using Site ID and API Keys. """ -from .client_base import ClientBase, CustomerIOException -from datetime import datetime + import warnings +from datetime import datetime from urllib.parse import quote -from .regions import Regions, Region -from enum import Enum + from customerio.constants import CIOID, EMAIL, ID +from .client_base import ClientBase, CustomerIOException +from .regions import Region, Regions + + class CustomerIO(ClientBase): - 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): + 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, + ): if not isinstance(region, Region): - raise CustomerIOException('invalid region provided') + raise CustomerIOException("invalid region provided") self.host = host or region.track_host self.port = port or 443 - self.url_prefix = url_prefix or '/api/v1' + self.url_prefix = url_prefix or "/api/v1" self.api_key = api_key self.site_id = site_id if json_encoder is not None: warnings.warn( - "With the switch to using requests library the `json_encoder` param is no longer used.", DeprecationWarning) + "With the switch to using requests library the `json_encoder` param is no longer used.", + DeprecationWarning, + stacklevel=2, + ) self.setup_base_url() - ClientBase.__init__( - self, + super().__init__( retries=retries, timeout=timeout, backoff_factor=backoff_factor, - use_connection_pooling=use_connection_pooling) + use_connection_pooling=use_connection_pooling, + ) def _url_encode(self, id): - return quote(str(id), safe='') + return quote(str(id), safe="") def setup_base_url(self): - template = 'https://{host}:{port}/{prefix}' + template = "https://{host}:{port}/{prefix}" if self.port == 443: - template = 'https://{host}/{prefix}' + template = "https://{host}/{prefix}" - if '://' in self.host: - self.host = self.host.split('://')[1] + if "://" in self.host: + self.host = self.host.split("://")[1] self.base_url = template.format( - host=self.host.strip('/'), + host=self.host.strip("/"), port=self.port, - prefix=self.url_prefix.strip('/')) + prefix=self.url_prefix.strip("/"), + ) def get_customer_query_string(self, customer_id): - '''Generates a customer API path''' - return '{base}/customers/{id}'.format(base=self.base_url, id=self._url_encode(customer_id)) + """Generates a customer API path.""" + return f"{self.base_url}/customers/{self._url_encode(customer_id)}" def get_event_query_string(self, customer_id): - '''Generates an event API path''' - return '{base}/customers/{id}/events'.format(base=self.base_url, id=self._url_encode(customer_id)) + """Generates an event API path.""" + return f"{self.base_url}/customers/{self._url_encode(customer_id)}/events" def get_events_query_string(self): - '''Returns the events API path''' - return '{base}/events'.format(base=self.base_url) + """Returns the events API path.""" + return f"{self.base_url}/events" def get_device_query_string(self, customer_id): - '''Generates a device API path''' - return '{base}/customers/{id}/devices'.format(base=self.base_url, id=self._url_encode(customer_id)) + """Generates a device API path.""" + return f"{self.base_url}/customers/{self._url_encode(customer_id)}/devices" def identify(self, id, **kwargs): - '''Identify a single customer by their unique id, and optionally add attributes''' + """Identify a single customer by their unique id, and optionally add attributes.""" if not id: raise CustomerIOException("id cannot be blank in identify") url = self.get_customer_query_string(id) - self.send_request('PUT', url, kwargs) + self.send_request("PUT", url, kwargs) def track(self, customer_id, name, **data): - '''Track an event for a given customer_id''' + """Track an event for a given customer_id.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in track") url = self.get_event_query_string(customer_id) post_data = { - 'name': name, - 'data': self._sanitize(data), + "name": name, + "data": self._sanitize(data), } - self.send_request('POST', url, post_data) + self.send_request("POST", url, post_data) def track_anonymous(self, anonymous_id, name, **data): - '''Track an event for a given anonymous_id''' + """Track an event for a given anonymous_id.""" url = self.get_events_query_string() post_data = { - 'name': name, - 'data': self._sanitize(data), + "name": name, + "data": self._sanitize(data), } if anonymous_id: - post_data['anonymous_id'] = anonymous_id - - self.send_request('POST', url, post_data) + post_data["anonymous_id"] = anonymous_id + + self.send_request("POST", url, post_data) def pageview(self, customer_id, page, **data): - '''Track a pageview for a given customer_id''' + """Track a pageview for a given customer_id.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in pageview") url = self.get_event_query_string(customer_id) post_data = { - 'type': "page", - 'name': page, - 'data': self._sanitize(data), + "type": "page", + "name": page, + "data": self._sanitize(data), } - self.send_request('POST', url, post_data) + self.send_request("POST", url, post_data) def backfill(self, customer_id, name, timestamp, **data): - '''Backfill an event (track with timestamp) for a given customer_id''' + """Backfill an event (track with timestamp) for a given customer_id.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in backfill") @@ -119,48 +139,47 @@ def backfill(self, customer_id, name, timestamp, **data): try: timestamp = int(timestamp) except Exception as e: - raise CustomerIOException( - "{t} is not a valid timestamp ({err})".format(t=timestamp, err=e)) + raise CustomerIOException(f"{timestamp} is not a valid timestamp ({e})") from e post_data = { - 'name': name, - 'data': self._sanitize(data), - 'timestamp': timestamp + "name": name, + "data": self._sanitize(data), + "timestamp": timestamp, } - self.send_request('POST', url, post_data) + self.send_request("POST", url, post_data) def delete(self, customer_id): - '''Delete a customer profile''' + """Delete a customer profile.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in delete") url = self.get_customer_query_string(customer_id) - self.send_request('DELETE', url, {}) + self.send_request("DELETE", url, {}) def add_device(self, customer_id, device_id, platform, **data): - '''Add a device to a customer profile''' + """Add a device to a customer profile.""" if not customer_id: - raise CustomerIOException( - "customer_id cannot be blank in add_device") + raise CustomerIOException("customer_id cannot be blank in add_device") if not device_id: - raise CustomerIOException( - "device_id cannot be blank in add_device") + raise CustomerIOException("device_id cannot be blank in add_device") if not platform: raise CustomerIOException("platform cannot be blank in add_device") - data.update({ - 'id': device_id, - 'platform': platform, - }) - payload = {'device': data} + data.update( + { + "id": device_id, + "platform": platform, + } + ) + payload = {"device": data} url = self.get_device_query_string(customer_id) - self.send_request('PUT', url, payload) + self.send_request("PUT", url, payload) def delete_device(self, customer_id, device_id): - '''Delete a device from a customer profile''' + """Delete a device from a customer profile.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in delete_device") @@ -168,34 +187,38 @@ def delete_device(self, customer_id, device_id): raise CustomerIOException("device_id cannot be blank in delete_device") url = self.get_device_query_string(customer_id) - delete_url = '{base}/{token}'.format(base=url, token=self._url_encode(device_id)) - self.send_request('DELETE', delete_url, {}) + delete_url = f"{url}/{self._url_encode(device_id)}" + self.send_request("DELETE", delete_url, {}) def suppress(self, customer_id): if not customer_id: - raise CustomerIOException( - "customer_id cannot be blank in suppress") + raise CustomerIOException("customer_id cannot be blank in suppress") self.send_request( - 'POST', '{base}/customers/{id}/suppress'.format(base=self.base_url, id=self._url_encode(customer_id)), {}) + "POST", + f"{self.base_url}/customers/{self._url_encode(customer_id)}/suppress", + {}, + ) def unsuppress(self, customer_id): if not customer_id: - raise CustomerIOException( - "customer_id cannot be blank in unsuppress") + raise CustomerIOException("customer_id cannot be blank in unsuppress") self.send_request( - 'POST', '{base}/customers/{id}/unsuppress'.format(base=self.base_url, id=self._url_encode(customer_id)), {}) + "POST", + f"{self.base_url}/customers/{self._url_encode(customer_id)}/unsuppress", + {}, + ) - def is_valid_id_type(self, input): - return [ID, EMAIL, CIOID].__contains__(input) + def is_valid_id_type(self, id_type): + return id_type in {ID, EMAIL, CIOID} def merge_customers(self, primary_id_type, primary_id, secondary_id_type, secondary_id): - '''Merge seondary profile into primary profile''' - if not self.is_valid_id_type(primary_id_type): + """Merge secondary profile into primary profile.""" + if not self.is_valid_id_type(primary_id_type): raise CustomerIOException("invalid primary id type") - if not self.is_valid_id_type(secondary_id_type): + if not self.is_valid_id_type(secondary_id_type): raise CustomerIOException("invalid secondary id type") if not primary_id: @@ -204,20 +227,15 @@ def merge_customers(self, primary_id_type, primary_id, secondary_id_type, second if not secondary_id: raise CustomerIOException("secondary customer_id cannot be blank") - url = '{base}/merge_customers'.format(base=self.base_url) + url = f"{self.base_url}/merge_customers" post_data = { - "primary": { - primary_id_type: primary_id - }, - "secondary": { - secondary_id_type: secondary_id - } + "primary": {primary_id_type: primary_id}, + "secondary": {secondary_id_type: secondary_id}, } - self.send_request('POST', url, post_data) + self.send_request("POST", url, post_data) - # builds the session. def _build_session(self): session = super()._build_session() session.auth = (self.site_id, self.api_key) - return session \ No newline at end of file + return session diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..16c2d26 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "customerio" +dynamic = ["version"] +description = "Customer.io Python bindings." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +authors = [ + { name = "Peaberry Software Inc.", email = "support@customerio.com" }, +] +dependencies = [ + "requests>=2.31.0", + "urllib3>=2.0.0", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +[project.optional-dependencies] +dev = [ + "build>=1.2.2", + "ruff>=0.14.0", + "twine>=6.1.0", +] + +[project.urls] +Homepage = "https://github.com/customerio/customerio-python" +Changelog = "https://github.com/customerio/customerio-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/customerio/customerio-python/issues" + +[tool.setuptools.dynamic] +version = { attr = "customerio.__version__.__version__" } + +[tool.setuptools.packages.find] +include = ["customerio*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["B", "E4", "E7", "E9", "F", "I", "SIM", "UP"] + +[tool.ruff.lint.isort] +known-first-party = ["customerio", "tests"] diff --git a/setup.py b/setup.py index f348d88..df789d2 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,3 @@ -import os from setuptools import find_packages, setup -version = {} -here = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(here, 'customerio', '__version__.py')) as f: - exec(f.read(), version) - -setup( - name="customerio", - version=version['__version__'], - author="Peaberry Software Inc.", - author_email="support@customerio.com", - license="BSD", - description="Customer.io Python bindings.", - url="https://github.com/customerio/customerio-python", - packages=find_packages(), - classifiers=[ - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - install_requires=['requests>=2.20.0'], - test_suite="tests", -) +setup(packages=find_packages(include=["customerio", "customerio.*"])) diff --git a/tests/server.py b/tests/server.py index d815595..4795ff3 100644 --- a/tests/server.py +++ b/tests/server.py @@ -1,14 +1,11 @@ -try: - from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -except ImportError: - from http.server import BaseHTTPRequestHandler, HTTPServer - -from random import randint import json import ssl -import time import threading +import time import unittest +from contextlib import suppress +from http.server import BaseHTTPRequestHandler, HTTPServer + def create_ssl_context(): """Create SSL context for Python 3.12+ compatibility""" @@ -18,33 +15,33 @@ def create_ssl_context(): context.check_hostname = False context.verify_mode = ssl.CERT_NONE # Allow weaker ciphers for test compatibility - try: - context.set_ciphers('DEFAULT@SECLEVEL=1') - except ssl.SSLError: - # Fall back if the cipher string is not supported - pass + with suppress(ssl.SSLError): + context.set_ciphers("DEFAULT@SECLEVEL=1") return context -request_counts = dict() + +request_counts = {} + class Handler(BaseHTTPRequestHandler): - '''Handler definition for the testing server instance. + """Handler definition for the testing server instance. This handler returns without setting response status code which causes httplib to raise BadStatusLine exception. The handler reads the post body and fails for the `fail_count` specified. After sending specified number of bad responses will sent a valid response. - ''' + """ + def do_DELETE(self): self.send_response(200) - self.send_header('Content-Length', '0') + self.send_header("Content-Length", "0") self.end_headers() def do_POST(self): response_body = bytes("{}", "utf-8") self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(response_body))) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response_body))) self.end_headers() self.wfile.write(response_body) @@ -53,16 +50,16 @@ def do_PUT(self): # extract params _id = self.path.split("/")[-1] - content_len = int(self.headers.get('content-length', 0)) - params = json.loads(self.rfile.read(content_len).decode('utf-8')) - fail_count = params.get('fail_count', 0) + content_len = int(self.headers.get("content-length", 0)) + params = json.loads(self.rfile.read(content_len).decode("utf-8")) + fail_count = params.get("fail_count", 0) # retrieve number of requests already served processed = request_counts.get(_id, 0) if processed > fail_count: # return a valid response self.send_response(200) - self.send_header('Content-Length', '0') + self.send_header("Content-Length", "0") self.end_headers() return @@ -76,12 +73,12 @@ def log_message(self, format, *args): class HTTPSTestCase(unittest.TestCase): - '''Test case class that starts up a https server and exposes it via the `server` attribute. + """Test case class that starts up a https server and exposes it via the `server` attribute. The testing server is only created in the setUpClass method so that multiple tests can use the same server instance. The server is started in a separate thread and once the tests are completed the server is shutdown and cleaned up. - ''' + """ @classmethod def setUpClass(cls): @@ -89,7 +86,7 @@ def setUpClass(cls): cls.server = HTTPServer(("localhost", 0), Handler) # create SSL context for Python 3.12+ compatibility context = create_ssl_context() - context.load_cert_chain('./tests/server.pem') + context.load_cert_chain("./tests/server.pem") # upgrade to https cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) # start server instance in new thread diff --git a/tests/test_api.py b/tests/test_api.py index 3bfa878..2b71c63 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,29 +1,35 @@ import base64 -from datetime import datetime -from functools import partial import json -import sys import unittest +from functools import partial -from customerio import APIClient, SendEmailRequest, SendPushRequest, SendSMSRequest, SendInboxMessageRequest, SendInAppRequest, Regions, CustomerIOException +import urllib3 + +from customerio import ( + APIClient, + CustomerIOException, + Regions, + SendEmailRequest, + SendInAppRequest, + SendInboxMessageRequest, + SendPushRequest, + SendSMSRequest, +) from customerio.__version__ import __version__ as ClientVersion from tests.server import HTTPSTestCase -import requests -from requests.auth import _basic_auth_str - # test uses a self signed certificate so disable the warning messages -requests.packages.urllib3.disable_warnings() +urllib3.disable_warnings() class TestAPIClient(HTTPSTestCase): - '''Starts server which the client connects to in the following tests''' + """Starts server which the client connects to in the following tests""" def setUp(self): self.client = APIClient( - key='app_api_key', - url="https://{addr}:{port}".format( - addr=self.server.server_address[0], port=self.server.server_port)) + key="app_api_key", + url=f"https://{self.server.server_address[0]}:{self.server.server_port}", + ) # do not verify the ssl certificate as it is self signed # should only be done for tests @@ -31,117 +37,156 @@ def setUp(self): def _check_request(self, resp, rq, *args, **kwargs): request = resp.request - self.assertEqual(request.method, rq['method']) - self.assertEqual(json.loads(request.body.decode('utf-8')), rq['body']) - self.assertEqual(request.headers['Authorization'], rq['authorization']) - self.assertEqual(request.headers['Content-Type'], rq['content_type']) - self.assertEqual( - int(request.headers['Content-Length']), len(json.dumps(rq['body']))) - self.assertTrue(request.url.endswith(rq['url_suffix']), - 'url: {} expected suffix: {}'.format(request.url, rq['url_suffix'])) + self.assertEqual(request.method, rq["method"]) + self.assertEqual(json.loads(request.body.decode("utf-8")), rq["body"]) + self.assertEqual(request.headers["Authorization"], rq["authorization"]) + self.assertEqual(request.headers["Content-Type"], rq["content_type"]) + self.assertEqual(int(request.headers["Content-Length"]), len(json.dumps(rq["body"]))) + self.assertTrue( + request.url.endswith(rq["url_suffix"]), + "url: {} expected suffix: {}".format(request.url, rq["url_suffix"]), + ) def test_client_setup(self): - client = APIClient(key='app_api_key') - self.assertEqual(client.url, 'https://{host}'.format(host=Regions.US.api_host)) + client = APIClient(key="app_api_key") + self.assertEqual(client.url, f"https://{Regions.US.api_host}") - client = APIClient(key='app_api_key', region=Regions.US) - self.assertEqual(client.url, 'https://{host}'.format(host=Regions.US.api_host)) + client = APIClient(key="app_api_key", region=Regions.US) + self.assertEqual(client.url, f"https://{Regions.US.api_host}") - client = APIClient(key='app_api_key', region=Regions.EU) - self.assertEqual(client.url, 'https://{host}'.format(host=Regions.EU.api_host)) + client = APIClient(key="app_api_key", region=Regions.EU) + self.assertEqual(client.url, f"https://{Regions.EU.api_host}") - self.assertEqual(self.client.http.headers['User-Agent'], 'Customer.io Python Client/{}'.format(ClientVersion)) + self.assertEqual( + self.client.http.headers["User-Agent"], f"Customer.io Python Client/{ClientVersion}" + ) # Raises an exception when an invalid region is passed in with self.assertRaises(CustomerIOException): - APIClient(key='app_api_key', region='au') + APIClient(key="app_api_key", region="au") def test_send_email(self): data = "1,2,3" - expected = base64.b64encode(bytes(data,"utf-8")).decode() - - self.client.http.hooks = dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': "Bearer app_api_key", - 'content_type': 'application/json', - 'url_suffix': '/v1/send/email', - 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100, "subject": "transactional message", "attachments":{"sample.csv": expected}}, - })) + expected = base64.b64encode(bytes(data, "utf-8")).decode() + + self.client.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": "Bearer app_api_key", + "content_type": "application/json", + "url_suffix": "/v1/send/email", + "body": { + "identifiers": {"id": "customer_1"}, + "transactional_message_id": 100, + "subject": "transactional message", + "attachments": {"sample.csv": expected}, + }, + }, + ) + ) email = SendEmailRequest( - identifiers={"id":"customer_1"}, + identifiers={"id": "customer_1"}, transactional_message_id=100, - subject="transactional message" + subject="transactional message", ) - email.attach('sample.csv', data) + email.attach("sample.csv", data) self.client.send_email(email) def test_send_push(self): - self.client.http.hooks = dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': "Bearer app_api_key", - 'content_type': 'application/json', - 'url_suffix': '/v1/send/push', - 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100, "title": "transactional push message", "message": "push message content"} - })) + self.client.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": "Bearer app_api_key", + "content_type": "application/json", + "url_suffix": "/v1/send/push", + "body": { + "identifiers": {"id": "customer_1"}, + "transactional_message_id": 100, + "title": "transactional push message", + "message": "push message content", + }, + }, + ) + ) push = SendPushRequest( - identifiers={"id":"customer_1"}, + identifiers={"id": "customer_1"}, transactional_message_id=100, title="transactional push message", - message="push message content" + message="push message content", ) self.client.send_push(push) def test_send_sms(self): - self.client.http.hooks = dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': "Bearer app_api_key", - 'content_type': 'application/json', - 'url_suffix': '/v1/send/sms', - 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100} - })) + self.client.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": "Bearer app_api_key", + "content_type": "application/json", + "url_suffix": "/v1/send/sms", + "body": {"identifiers": {"id": "customer_1"}, "transactional_message_id": 100}, + }, + ) + ) sms = SendSMSRequest( - identifiers={"id":"customer_1"}, + identifiers={"id": "customer_1"}, transactional_message_id=100, ) self.client.send_sms(sms) def test_send_inbox_message(self): - self.client.http.hooks = dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': "Bearer app_api_key", - 'content_type': 'application/json', - 'url_suffix': '/v1/send/inbox_message', - 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100} - })) + self.client.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": "Bearer app_api_key", + "content_type": "application/json", + "url_suffix": "/v1/send/inbox_message", + "body": {"identifiers": {"id": "customer_1"}, "transactional_message_id": 100}, + }, + ) + ) inbox_message = SendInboxMessageRequest( - identifiers={"id":"customer_1"}, + identifiers={"id": "customer_1"}, transactional_message_id=100, ) self.client.send_inbox_message(inbox_message) def test_send_in_app(self): - self.client.http.hooks = dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': "Bearer app_api_key", - 'content_type': 'application/json', - 'url_suffix': '/v1/send/in_app', - 'body': {"identifiers": {"id":"customer_1"}, "transactional_message_id": 100} - })) + self.client.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": "Bearer app_api_key", + "content_type": "application/json", + "url_suffix": "/v1/send/in_app", + "body": {"identifiers": {"id": "customer_1"}, "transactional_message_id": 100}, + }, + ) + ) in_app = SendInAppRequest( - identifiers={"id":"customer_1"}, + identifiers={"id": "customer_1"}, transactional_message_id=100, ) self.client.send_in_app(in_app) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_customerio.py b/tests/test_customerio.py index ae1d34a..37dd4b0 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -1,28 +1,31 @@ -from customerio.constants import CIOID, EMAIL, ID -from datetime import datetime -from functools import partial import json import unittest +from datetime import datetime +from functools import partial + +import urllib3 +from requests.auth import _basic_auth_str from customerio import CustomerIO, CustomerIOException, Regions +from customerio.constants import CIOID, EMAIL, ID from tests.server import HTTPSTestCase -import requests -from requests.auth import _basic_auth_str - # test uses a self signed certificate so disable the warning messages -requests.packages.urllib3.disable_warnings() +urllib3.disable_warnings() + class TestCustomerIO(HTTPSTestCase): - '''Starts server which the client connects to in the following tests''' + """Starts server which the client connects to in the following tests""" + def setUp(self): self.cio = CustomerIO( - site_id='siteid', - api_key='apikey', + site_id="siteid", + api_key="apikey", host=self.server.server_address[0], port=self.server.server_port, retries=5, - backoff_factor=0) + backoff_factor=0, + ) # do not verify the ssl certificate as it is self signed # should only be done for tests @@ -30,39 +33,40 @@ def setUp(self): def _check_request(self, resp, rq, *args, **kwargs): request = resp.request - body = request.body.decode('utf-8') if isinstance(request.body, bytes) else request.body - if rq.get('method', None): - self.assertEqual(request.method, rq['method']) - if rq.get('body', None): - self.assertEqual(json.loads(body), rq['body']) - if rq.get('authorization', None): - self.assertEqual(request.headers['Authorization'], rq['authorization']) - if rq.get('content_type', None): - self.assertEqual(request.headers['Content-Type'], rq['content_type']) - if rq.get('body', None): - self.assertEqual(int(request.headers['Content-Length']), len(json.dumps(rq['body']))) - if rq.get('url_suffix', None): - self.assertTrue(request.url.endswith(rq['url_suffix']), - 'url: {} expected suffix: {}'.format(request.url, rq['url_suffix'])) + body = request.body.decode("utf-8") if isinstance(request.body, bytes) else request.body + if rq.get("method", None): + self.assertEqual(request.method, rq["method"]) + if rq.get("body", None): + self.assertEqual(json.loads(body), rq["body"]) + if rq.get("authorization", None): + self.assertEqual(request.headers["Authorization"], rq["authorization"]) + if rq.get("content_type", None): + self.assertEqual(request.headers["Content-Type"], rq["content_type"]) + if rq.get("body", None): + self.assertEqual(int(request.headers["Content-Length"]), len(json.dumps(rq["body"]))) + if rq.get("url_suffix", None): + self.assertTrue( + request.url.endswith(rq["url_suffix"]), + "url: {} expected suffix: {}".format(request.url, rq["url_suffix"]), + ) def test_client_setup(self): - client = CustomerIO(site_id='site_id', api_key='api_key') + client = CustomerIO(site_id="site_id", api_key="api_key") self.assertEqual(client.host, Regions.US.track_host) - client = CustomerIO(site_id='site_id', api_key='api_key', region=Regions.US) + client = CustomerIO(site_id="site_id", api_key="api_key", region=Regions.US) self.assertEqual(client.host, Regions.US.track_host) - client = CustomerIO(site_id='site_id', api_key='api_key', region=Regions.EU) + client = CustomerIO(site_id="site_id", api_key="api_key", region=Regions.EU) self.assertEqual(client.host, Regions.EU.track_host) # Raises an exception when an invalid region is passed in with self.assertRaises(CustomerIOException): - client = CustomerIO(site_id='site_id', api_key='api_key', region='au') - + CustomerIO(site_id="site_id", api_key="api_key", region="au") def test_client_connection_handling(self): retries = self.cio.retries - # should not raise exception as i should be less than retries and + # should not raise exception as i should be less than retries and # therefore the last request should return a valid response for i in range(retries): self.cio.identify(str(i), fail_count=i) @@ -71,88 +75,125 @@ def test_client_connection_handling(self): with self.assertRaises(CustomerIOException): self.cio.identify(retries, fail_count=retries) - def test_identify_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1', - 'body': {"name": "john", "email": "john@test.com"}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1", + "body": {"name": "john", "email": "john@test.com"}, + }, + ) + ) - self.cio.identify(id=1, name='john', email='john@test.com') + self.cio.identify(id=1, name="john", email="john@test.com") with self.assertRaises(TypeError): self.cio.identify(random_attr="some_value") - def test_track_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/events', - 'body': {"data": {"email": "john@test.com"}, "name": "sign_up"}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/events", + "body": {"data": {"email": "john@test.com"}, "name": "sign_up"}, + }, + ) + ) - self.cio.track(customer_id=1, name='sign_up', email='john@test.com') + self.cio.track(customer_id=1, name="sign_up", email="john@test.com") with self.assertRaises(TypeError): self.cio.track(random_attr="some_value") - def test_track_anonymous_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/events', - 'body': {"data": {"email": "john@test.com"}, "name": "sign_up", "anonymous_id": 123}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/events", + "body": { + "data": {"email": "john@test.com"}, + "name": "sign_up", + "anonymous_id": 123, + }, + }, + ) + ) - self.cio.track_anonymous(anonymous_id=123, name='sign_up', email='john@test.com') + self.cio.track_anonymous(anonymous_id=123, name="sign_up", email="john@test.com") def test_pageview_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/events', - 'body': {"data": {"referer": "category_1"}, "type": "page", "name": "product_1"}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/events", + "body": { + "data": {"referer": "category_1"}, + "type": "page", + "name": "product_1", + }, + }, + ) + ) - self.cio.pageview(customer_id=1, page='product_1', referer='category_1') + self.cio.pageview(customer_id=1, page="product_1", referer="category_1") with self.assertRaises(TypeError): self.cio.pageview(random_attr="some_value") - def test_delete_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'DELETE', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1', - 'body': {}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "DELETE", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1", + "body": {}, + }, + ) + ) self.cio.delete(customer_id=1) with self.assertRaises(TypeError): self.cio.delete(random_attr="some_value") - def test_backfill_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/events', - 'body': {"timestamp": 1234567890, "data": {"email": "john@test.com"}, "name": "signup"}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/events", + "body": { + "timestamp": 1234567890, + "data": {"email": "john@test.com"}, + "name": "signup", + }, + }, + ) + ) - self.cio.backfill(customer_id=1, name='signup', timestamp=1234567890, email='john@test.com') + self.cio.backfill(customer_id=1, name="signup", timestamp=1234567890, email="john@test.com") with self.assertRaises(TypeError): self.cio.backfill(random_attr="some_value") @@ -160,100 +201,138 @@ def test_backfill_call(self): def test_base_url(self): test_cases = [ # host, port, prefix, result - (None, None, None, 'https://track.customer.io/api/v1'), - (None, None, 'v2', 'https://track.customer.io/v2'), - (None, None, '/v2/', 'https://track.customer.io/v2'), - ('sub.domain.com', 1337, '/v2/', 'https://sub.domain.com:1337/v2'), - ('/sub.domain.com/', 1337, '/v2/', 'https://sub.domain.com:1337/v2'), - ('http://sub.domain.com/', 1337, '/v2/', 'https://sub.domain.com:1337/v2'), + (None, None, None, "https://track.customer.io/api/v1"), + (None, None, "v2", "https://track.customer.io/v2"), + (None, None, "/v2/", "https://track.customer.io/v2"), + ("sub.domain.com", 1337, "/v2/", "https://sub.domain.com:1337/v2"), + ("/sub.domain.com/", 1337, "/v2/", "https://sub.domain.com:1337/v2"), + ("http://sub.domain.com/", 1337, "/v2/", "https://sub.domain.com:1337/v2"), ] for host, port, prefix, result in test_cases: cio = CustomerIO(host=host, port=port, url_prefix=prefix) self.assertEqual(cio.base_url, result) - def test_device_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices', - 'body': {"device": {"id": "device_1", "platform":"ios"}} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices", + "body": {"device": {"id": "device_1", "platform": "ios"}}, + }, + ) + ) self.cio.add_device(customer_id=1, device_id="device_1", platform="ios") with self.assertRaises(TypeError): self.cio.add_device(random_attr="some_value") def test_device_call_last_used(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices', - 'body': {"device": {"id": "device_2", "platform": "android", "last_used": 1234567890}} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices", + "body": { + "device": {"id": "device_2", "platform": "android", "last_used": 1234567890} + }, + }, + ) + ) - self.cio.add_device(customer_id=1, device_id="device_2", platform="android", last_used=1234567890) + self.cio.add_device( + customer_id=1, device_id="device_2", platform="android", last_used=1234567890 + ) def test_device_call_valid_platform(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices', - 'body': {"device": {"id": "device_3", "platform": "notsupported"}} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices", + "body": {"device": {"id": "device_3", "platform": "notsupported"}}, + }, + ) + ) with self.assertRaises(CustomerIOException): self.cio.add_device(customer_id=1, device_id="device_3", platform=None) - + def test_device_call_has_customer_id(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices', - 'body': {"device": {"id": "device_4", "platform": "ios"}} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices", + "body": {"device": {"id": "device_4", "platform": "ios"}}, + }, + ) + ) with self.assertRaises(CustomerIOException): self.cio.add_device(customer_id="", device_id="device_4", platform="ios") - + def test_device_call_has_device_id(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'PUT', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices', - 'body': {"device": {"id": "device_5", "platform": "ios"}} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "PUT", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices", + "body": {"device": {"id": "device_5", "platform": "ios"}}, + }, + ) + ) with self.assertRaises(CustomerIOException): self.cio.add_device(customer_id=1, device_id="", platform="ios") def test_device_delete_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'DELETE', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/devices/device_1', - 'body': {} - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "DELETE", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/devices/device_1", + "body": {}, + }, + ) + ) self.cio.delete_device(customer_id=1, device_id="device_1") with self.assertRaises(TypeError): self.cio.delete_device(random_attr="some_value") - + def test_suppress_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/suppress', - 'body': {}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/suppress", + "body": {}, + }, + ) + ) self.cio.suppress(customer_id=1) @@ -261,13 +340,18 @@ def test_suppress_call(self): self.cio.suppress(None) def test_unsuppress_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/customers/1/unsuppress', - 'body': {}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/customers/1/unsuppress", + "body": {}, + }, + ) + ) self.cio.unsuppress(customer_id=1) @@ -276,76 +360,115 @@ def test_unsuppress_call(self): def test_sanitize(self): from datetime import timezone + data_in = dict(dt=datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc)) data_out = self.cio._sanitize(data_in) self.assertEqual(data_out, dict(dt=1234567890)) def test_ids_are_encoded_in_url(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'url_suffix': '/customers/1/unsuppress', - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "url_suffix": "/customers/1/unsuppress", + }, + ) + ) self.cio.unsuppress(customer_id=1) - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'url_suffix': '/customers/1%2F', - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "url_suffix": "/customers/1%2F", + }, + ) + ) self.cio.identify(id="1/") - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'url_suffix': '/customers/1%20/events', - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "url_suffix": "/customers/1%20/events", + }, + ) + ) self.cio.track(customer_id="1 ", name="test") - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'url_suffix': '/customers/1%2F/devices/2%20', - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "url_suffix": "/customers/1%2F/devices/2%20", + }, + ) + ) self.cio.delete_device(customer_id="1/", device_id="2 ") def test_merge_customers_call(self): - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/merge_customers', - 'body': {'primary': {ID: 'CIO123'}, 'secondary': {EMAIL: 'person1@company.com'}}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/merge_customers", + "body": { + "primary": {ID: "CIO123"}, + "secondary": {EMAIL: "person1@company.com"}, + }, + }, + ) + ) self.cio.merge_customers(ID, "CIO123", EMAIL, "person1@company.com") - self.cio.http.hooks=dict(response=partial(self._check_request, rq={ - 'method': 'POST', - 'authorization': _basic_auth_str('siteid', 'apikey'), - 'content_type': 'application/json', - 'url_suffix': '/merge_customers', - 'body': {'primary': {'cio_id': 'CIO456'}, 'secondary': {'id': 'MyCustomId'}}, - })) + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/merge_customers", + "body": {"primary": {"cio_id": "CIO456"}, "secondary": {"id": "MyCustomId"}}, + }, + ) + ) self.cio.merge_customers(CIOID, "CIO456", ID, "MyCustomId") with self.assertRaises(CustomerIOException): - self.cio.merge_customers(primary_id_type=EMAIL, - primary_id="coolperson@cio.com", - secondary_id_type="something", - secondary_id="C123" + self.cio.merge_customers( + primary_id_type=EMAIL, + primary_id="coolperson@cio.com", + secondary_id_type="something", + secondary_id="C123", ) with self.assertRaises(CustomerIOException): - self.cio.merge_customers(primary_id_type="not_valid", - primary_id="coolperson@cio.com", - secondary_id_type="something", - secondary_id="C123" + self.cio.merge_customers( + primary_id_type="not_valid", + primary_id="coolperson@cio.com", + secondary_id_type="something", + secondary_id="C123", ) with self.assertRaises(CustomerIOException): - self.cio.merge_customers(primary_id_type=EMAIL, - primary_id="", - secondary_id_type="something", - secondary_id="C123" + self.cio.merge_customers( + primary_id_type=EMAIL, + primary_id="", + secondary_id_type="something", + secondary_id="C123", ) with self.assertRaises(CustomerIOException): - self.cio.merge_customers(primary_id_type=EMAIL, - primary_id="coolperson@cio.com", - secondary_id_type="something", - secondary_id="" + self.cio.merge_customers( + primary_id_type=EMAIL, + primary_id="coolperson@cio.com", + secondary_id_type="something", + secondary_id="", ) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() From c57a6705940c1b1e5181caedd77704e31734ac1f Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 11:56:55 -0400 Subject: [PATCH 4/6] Exclude tests from source distribution --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1eeef06 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +prune tests From 17f3960e3119b851fe2e1794668f9ffab780e525 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 12:00:23 -0400 Subject: [PATCH 5/6] Avoid duplicate PR CI runs --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 55df217..91adeb6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,8 @@ name: CI on: push: + branches: + - main pull_request: workflow_dispatch: From c77bd64bd2ee4659c43cdb88883e9c706e194e38 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 5 May 2026 13:56:34 -0400 Subject: [PATCH 6/6] Bump Ruff and fix merge markers --- customerio/client_base.py | 5 ----- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/customerio/client_base.py b/customerio/client_base.py index edd3638..1096336 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -108,11 +108,6 @@ def _build_session(self): return session def _close(self): -<<<<<<< ci-fixes if not self.use_connection_pooling and self._current_session is not None: -======= - # if we're not using pooling; clean up the resources. - if (not self.use_connection_pooling and self._current_session is not None): ->>>>>>> main self._current_session.close() self._current_session = None diff --git a/pyproject.toml b/pyproject.toml index 16c2d26..bcd5c7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ [project.optional-dependencies] dev = [ "build>=1.2.2", - "ruff>=0.14.0", + "ruff>=0.15.12", "twine>=6.1.0", ]