From 35326654b5b1eb332a919d03ae4b754d3fe127ec Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Fri, 5 May 2023 10:31:39 +0100 Subject: [PATCH 01/10] Fork to Customer.io Data Pipelines --- .buildscripts/e2e.sh | 14 -- .circleci/config.yml | 110 ------------- .github/dependabot.yml | 9 -- .github/workflows/test.yml | 23 +++ HISTORY.md | 152 ------------------ LICENSE | 1 + Makefile | 14 +- README.md | 100 ++---------- RELEASING.md | 9 -- analytics/consumer.py | 4 +- analytics/test/client.py | 4 +- {segment => customerio}/analytics/__init__.py | 4 +- {segment => customerio}/analytics/client.py | 11 +- {segment => customerio}/analytics/consumer.py | 4 +- {segment => customerio}/analytics/request.py | 10 +- .../analytics/test/__init__.py | 3 +- .../analytics/test/client.py | 4 +- .../analytics/test/consumer.py | 4 +- .../analytics/test/module.py | 5 +- .../analytics/test/request.py | 6 +- .../analytics/test/utils.py | 8 +- {segment => customerio}/analytics/utils.py | 2 +- customerio/analytics/version.py | 1 + e2e_test.sh | 5 - segment/analytics/version.py | 1 - setup.py | 28 ++-- simulator.py | 99 ------------ 27 files changed, 83 insertions(+), 552 deletions(-) delete mode 100755 .buildscripts/e2e.sh delete mode 100644 .circleci/config.yml delete mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/test.yml delete mode 100644 HISTORY.md delete mode 100644 RELEASING.md rename {segment => customerio}/analytics/__init__.py (95%) rename {segment => customerio}/analytics/client.py (97%) rename {segment => customerio}/analytics/consumer.py (97%) rename {segment => customerio}/analytics/request.py (88%) rename {segment => customerio}/analytics/test/__init__.py (96%) rename {segment => customerio}/analytics/test/client.py (99%) rename {segment => customerio}/analytics/test/consumer.py (98%) rename {segment => customerio}/analytics/test/module.py (93%) rename {segment => customerio}/analytics/test/request.py (90%) rename {segment => customerio}/analytics/test/utils.py (88%) rename {segment => customerio}/analytics/utils.py (98%) create mode 100644 customerio/analytics/version.py delete mode 100755 e2e_test.sh delete mode 100644 segment/analytics/version.py delete mode 100644 simulator.py diff --git a/.buildscripts/e2e.sh b/.buildscripts/e2e.sh deleted file mode 100755 index 01b6f92a..00000000 --- a/.buildscripts/e2e.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -set -e - -if [ "${RUN_E2E_TESTS}" != "true" ]; then - echo "Skipping end to end tests." -else - echo "Running end to end tests..." - wget https://github.com/segmentio/library-e2e-tester/releases/download/0.2.2/tester_linux_amd64 - chmod +x tester_linux_amd64 - chmod +x e2e_test.sh - ./tester_linux_amd64 -segment-write-key="${SEGMENT_WRITE_KEY}" -webhook-auth-username="${WEBHOOK_AUTH_USERNAME}" -webhook-bucket="${WEBHOOK_BUCKET}" -path='./e2e_test.sh' - echo "End to end tests completed!" -fi diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index bb80c74e..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,110 +0,0 @@ -version: 2 -defaults: - taggedReleasesFilter: &taggedReleasesFilter - tags: - only: /^\d+\.\d+\.\d+((a|b|rc)\d)?$/ # matches 1.2.3, 1.2.3a1, 1.2.3b1, 1.2.3rc1 etc.. -jobs: - build: - docker: - - image: circleci/python:3.8 - steps: - - checkout - - run: pip3 install python-dateutil backoff monotonic - - run: pip3 install --user . - - run: sudo pip3 install pylint==2.8.0 flake8 mock==3.0.5 python-dateutil - - run: make test - - store_artifacts: - path: pylint.out - - store_artifacts: - path: flake8.out - - snyk: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - attach_workspace: { at: . } - - run: pip3 install pipreqs - - run: pip3 install --user appdirs - - run: pipreqs . - - run: pip3 install --user -r requirements.txt - - run: curl -sL https://raw.githubusercontent.com/segmentio/snyk_helpers/master/initialization/snyk.sh | sh - - test_37: &test - docker: - - image: circleci/python:3.7 - steps: - - checkout - - run: pip3 install python-dateutil backoff monotonic - - run: pip3 install --user .[test] - - run: - name: Linting with Flake8 - command: | - git diff origin/master..HEAD analytics | flake8 --diff --max-complexity=10 analytics - - run: make test - - run: make e2e_test - - test_38: - <<: *test - docker: - - image: circleci/python:3.8 - - test_39: - <<: *test - docker: - - image: circleci/python:3.9 - - publish: - docker: - - image: circleci/python:3.9 - steps: - - checkout - - run: sudo pip install twine - - run: make release - -workflows: - version: 2 - build_test_release: - jobs: - - build: - filters: - <<: *taggedReleasesFilter - - test_37: - filters: - <<: *taggedReleasesFilter - - test_38: - filters: - <<: *taggedReleasesFilter - - test_39: - filters: - <<: *taggedReleasesFilter - - publish: - requires: - - build - - test_37 - - test_38 - - test_39 - filters: - <<: *taggedReleasesFilter - branches: - ignore: /.*/ - static_analysis: - jobs: - - build - - snyk: - context: snyk - requires: - - build - scheduled_e2e_test: - triggers: - - schedule: - cron: "0 * * * *" - filters: - branches: - only: - - master - - scheduled_e2e_testing - jobs: - - test_37 - - test_38 - - test_39 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 7ef997ab..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 2 -updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 - reviewers: - - heitorsampaio diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..da2daf6d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Test + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: setup.py + + - run: pip3 install -e '.[test]' + - run: make test diff --git a/HISTORY.md b/HISTORY.md deleted file mode 100644 index 56723743..00000000 --- a/HISTORY.md +++ /dev/null @@ -1,152 +0,0 @@ -# 2.2.2 / 2022-11-29 -- Specifying milliseconds as the isoformat rather than the default microseconds in timestamp - -# 2.2.1 / 2022-06-23 -- Empty Catch fix #217 -- Build Isolation fix #216 -- Removing remaining string_type references - -# 2.2.0 / 2022-03-07 -- Remove Python 2 support -- Remove six package - -# 2.1.0 / 2022-03-04 - -- Raise exception on large message -- Automatically coerce Enum values inside messages -- Handle exceptions in the try catch and log them - - -# 2.0.0 / 2021-10-01 - -- Update package name and namespace name - - -# 1.5.0 / 2021-09-23 -- Update tests with latest dependencies -- Remove unsupported python versions 2.7 & 3.5 - -# 1.4.0 / 2021-07-16 -- Fix the missing `upload_size` parameter - -# 1.3.1 / 2021-05-12 - -- Fix linting code and readme heling basic things. -- Add support for HTTP proxy -- Allows more settings to be configured from singleton - -# 1.3.0-beta1 / 2019-04-27 - -- Add `sync_mode` option ([#147](https://github.com/segmentio/analytics-python/pull/147)) - -# 1.3.0-beta0 / 2018-10-10 - -- Add User-Agent header to messages -- Don't retry sending on client errors except 429 -- Allow user-defined upload interval -- Add `shutdown` function -- Add gzip support -- Add exponential backoff with jitter when retrying -- Add a paramater in Client to configure max retries -- Limit batch upload size to 500KB -- Drop messages greater than 32kb -- Allow user-defined upload size -- Support custom messageId - -# 1.2.9 / 2017-11-28 - -- [Fix](https://github.com/segmentio/analytics-python/pull/102): Stringify non-string userIds and anonymousIds. - -# 1.2.8 / 2017-09-20 - -- [Fix](https://github.com/segmentio/analytics-python/issues/94): Date objects are removed from event properties. -- [Fix](https://github.com/segmentio/analytics-python/pull/98): Fix for regression introduced in version 1.2.4. - -# 1.2.7 / 2017-01-31 - -- [Fix](https://github.com/segmentio/analytics-python/pull/92): Correctly serialize date objects. - -# 1.2.6 / 2016-12-07 - -- dont add messages to the queue if send is false -- drop py32 support - -# 1.2.5 / 2016-07-02 - -- Fix outdated python-dateutil<2 requirement for python2 - dateutil > 2.1 runs is python2 compatible -- Fix a bug introduced in 1.2.4 where we could try to join a thread that was not yet started - -# 1.2.4 / 2016-06-06 - -- Fix race conditions in overflow and flush tests -- Join daemon thread on interpreter exit to prevent value errors -- Capitalize HISTORY.md (#76) -- Quick fix for Decimal to send as a float - -# 1.2.3 / 2016-03-23 - -- relaxing requests dep - -# 1.2.2 / 2016-03-17 - -- Fix environment markers definition -- Use proper way for defining conditional dependencies - -# 1.2.1 / 2016-03-11 - -- fixing requirements.txt - -# 1.2.0 / 2016-03-11 - -- adding versioned requirements.txt file - -# 1.1.0 / 2015-06-23 - -- Adding fixes for handling invalid json types -- Fixing byte/bytearray handling -- Adding `logging.DEBUG` fix for `setLevel` -- Support HTTP keep-alive using a Session connection pool -- Suppport universal wheels -- adding .sentAt -- make it really testable -- fixing overflow test -- removing .io's -- Update README.md -- spacing - -# 1.0.3 / 2014-09-30 - -- adding top level send option - -# 1.0.2 / 2014-09-17 - -- fixing debug logging levels - -# 1.0.1 / 2014-09-08 - -- fixing unicode handling, for write_key and events -- adding six to requirements.txt and install scripts - -# 1.0.0 / 2014-09-05 - -- updating to spec 1.0 -- adding python3 support -- moving to analytics.write_key API -- moving consumer to a separate thread -- adding request retries -- making analytics.flush() syncrhonous -- adding full travis tests - -# 0.4.4 / 2013-11-21 - -- add < python 2.7 compatibility by removing `delta.total_seconds` - -# 0.4.3 / 2013-11-13 - -- added datetime serialization fix (alexlouden) - -# 0.4.2 / 2013-06-26 - -- Added history.d change log -- Merging https://github.com/segmentio/analytics-python/pull/14 to add support for lists and PEP8 fixes. Thanks https://github.com/dfee! -- Fixing #12, adding static public API to analytics.**init** diff --git a/LICENSE b/LICENSE index c29156af..9c8d6e84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2021 Segment (segment.com) +Copyright (c) 2023 Customer.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index cee88eb5..93b13ea5 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,8 @@ install: pip install --edit .[test] test: - pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out - flake8 --max-complexity=10 --statistics analytics > flake8.out || true + pylint --rcfile=.pylintrc --reports=y --exit-zero analytics + flake8 --max-complexity=10 --statistics analytics || true + python -m unittest customerio/analytics/test/*.py analytics/test/*.py -release: - python setup.py sdist bdist_wheel - twine upload dist/* - -e2e_test: - .buildscripts/e2e.sh - -.PHONY: test release e2e_test +.PHONY: install test diff --git a/README.md b/README.md index 69cefe7c..9272e945 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,24 @@ -analytics-python -============== +Customer.io Data Pipelines analytics client for Python. -[![CircleCI](https://circleci.com/gh/segmentio/analytics-python/tree/master.svg?style=svg&circle-token=c0b411a3e21943918294714ad1d75a1cfc718f79)](https://circleci.com/gh/segmentio/analytics-python/tree/master) +## Installation - -analytics-python is a python client for [Segment](https://segment.com) - -
- -

You can't fix what you can't measure

-
- -Analytics helps you measure your users, product, and business. It unlocks insights into your app's funnel, core business metrics, and whether you have a product-market fit. - -## 🚀 How to get started -1. **Collect analytics data** from your app(s). - - The top 200 Segment companies collect data from 5+ source types (web, mobile, server, CRM, etc.). -2. **Send the data to analytics tools** (for example, Google Analytics, Amplitude, Mixpanel). - - Over 250+ Segment companies send data to eight categories of destinations such as analytics tools, warehouses, email marketing, and remarketing systems, session recording, and more. -3. **Explore your data** by creating metrics (for example, new signups, retention cohorts, and revenue generation). - - The best Segment companies use retention cohorts to measure product-market fit. Netflix has 70% paid retention after 12 months, 30% after 7 years. - -[Segment](https://segment.com) collects analytics data and allows you to send it to more than 250 apps (such as Google Analytics, Mixpanel, Optimizely, Facebook Ads, Slack, Sentry) just by flipping a switch. You only need one Segment code snippet, and you can turn integrations on and off at will, with no additional code. [Sign up with Segment today](https://app.segment.com/signup). - -### 🤔 Why? -1. **Power all your analytics apps with the same data**. Instead of writing code to integrate all of your tools individually, send data to Segment, once. - -2. **Install tracking for the last time**. We're the last integration you'll ever need to write. You only need to instrument Segment once. Reduce all of your tracking code and advertising tags into a single set of API calls. - -3. **Send data from anywhere**. Send Segment data from any device, and we'll transform and send it on to any tool. - -4. **Query your data in SQL**. Slice, dice, and analyze your data in detail with Segment SQL. We'll transform and load your customer behavioral data directly from your apps into Amazon Redshift, Google BigQuery, or Postgres. Save weeks of engineering time by not having to invent your data warehouse and ETL pipeline. - - For example, you can capture data on any app: - ```python - analytics.track('Order Completed', { price: 99.84 }) - ``` - Then, query the resulting data in SQL: - ```sql - select * from app.order_completed - order by price desc - ``` - -## 👨‍💻 Getting Started - -Install `segment-analytics-python` using pip: +Using pip: ```bash -pip3 install segment-analytics-python +pip3 install customerio-analytics ``` -or you can clone this repo: +or you can install directly from this repo: ```bash -git clone https://github.com/segmentio/analytics-python.git - -cd analytics-python - -sudo python3 setup.py install +pip3 install git+http://github.com/customerio/cdp-analytics-python ``` -Now inside your app, you'll want to **set your** `write_key` before making any analytics calls: +## Usage ```python -import segment.analytics as analytics +import customerio.analytics as analytics analytics.write_key = 'YOUR_WRITE_KEY' -``` -**Note** If you need to send data to multiple Segment sources, you can initialize a new Client for each `write_key` - -### 🚀 Startup Program -
- -
-If you are part of a new startup (<$5M raised, <2 years since founding), we just launched a new startup program for you. You can get a Segment Team plan (up to $25,000 value in Segment credits) for free up to 2 years — apply here! - -## Documentation -Documentation is available at [https://segment.com/libraries/python](https://segment.com/libraries/python). - -## License - -``` -WWWWWW||WWWWWW - W W W||W W W - || - ( OO )__________ - / | \ - /o o| MIT \ - \___/||_||__||_|| * - || || || || - _||_|| _||_|| - (__|__|(__|__| +analytics.track(user_id=4, event='order_complete') ``` - -(The MIT License) - -Copyright (c) 2013 Segment Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -[![forthebadge](https://forthebadge.com/images/badges/built-with-love.svg)](https://forthebadge.com) diff --git a/RELEASING.md b/RELEASING.md deleted file mode 100644 index e141a4ae..00000000 --- a/RELEASING.md +++ /dev/null @@ -1,9 +0,0 @@ -Releasing -========= - -1. Update `VERSION` in `segment/analytics/version.py` to the new version. -2. Update the `HISTORY.md` for the impending release. -3. `git commit -am "Release X.Y.Z."` (where X.Y.Z is the new version) -4. `git tag -a X.Y.Z -m "Version X.Y.Z"` (where X.Y.Z is the new version). -5. `git push && git push --tags` -6. `make release`. diff --git a/analytics/consumer.py b/analytics/consumer.py index 33b9c26c..a49f616a 100644 --- a/analytics/consumer.py +++ b/analytics/consumer.py @@ -5,7 +5,7 @@ import backoff -from analytics.request import post, APIError, DatetimeSerializer +from customerio.analytics.request import post, APIError, DatetimeSerializer from queue import Empty @@ -18,7 +18,7 @@ class Consumer(Thread): """Consumes the messages from the client's queue.""" - log = logging.getLogger('segment') + log = logging.getLogger('customerio') def __init__(self, queue, write_key, upload_size=100, host=None, on_error=None, upload_interval=0.5, gzip=False, retries=10, diff --git a/analytics/test/client.py b/analytics/test/client.py index fcfbc0eb..80ceaa3d 100644 --- a/analytics/test/client.py +++ b/analytics/test/client.py @@ -3,8 +3,8 @@ import time import mock -from analytics.version import VERSION -from analytics.client import Client +from customerio.analytics.version import VERSION +from customerio.analytics.client import Client class TestClient(unittest.TestCase): diff --git a/segment/analytics/__init__.py b/customerio/analytics/__init__.py similarity index 95% rename from segment/analytics/__init__.py rename to customerio/analytics/__init__.py index 230769b5..f99e4f57 100644 --- a/segment/analytics/__init__.py +++ b/customerio/analytics/__init__.py @@ -1,6 +1,6 @@ -from segment.analytics.version import VERSION -from segment.analytics.client import Client +from customerio.analytics.version import VERSION +from customerio.analytics.client import Client __version__ = VERSION diff --git a/segment/analytics/client.py b/customerio/analytics/client.py similarity index 97% rename from segment/analytics/client.py rename to customerio/analytics/client.py index 515da899..e063820e 100644 --- a/segment/analytics/client.py +++ b/customerio/analytics/client.py @@ -7,10 +7,10 @@ from dateutil.tz import tzutc -from segment.analytics.utils import guess_timezone, clean -from segment.analytics.consumer import Consumer, MAX_MSG_SIZE -from segment.analytics.request import post, DatetimeSerializer -from segment.analytics.version import VERSION +from customerio.analytics.utils import guess_timezone, clean +from customerio.analytics.consumer import Consumer, MAX_MSG_SIZE +from customerio.analytics.request import post, DatetimeSerializer +from customerio.analytics.version import VERSION import queue @@ -34,8 +34,7 @@ class DefaultConfig(object): upload_interval = 0.5 upload_size = 100 - """Create a new Segment client.""" - log = logging.getLogger('segment') + log = logging.getLogger('customerio') def __init__(self, write_key=DefaultConfig.write_key, diff --git a/segment/analytics/consumer.py b/customerio/analytics/consumer.py similarity index 97% rename from segment/analytics/consumer.py rename to customerio/analytics/consumer.py index 27586284..d9b05479 100644 --- a/segment/analytics/consumer.py +++ b/customerio/analytics/consumer.py @@ -4,7 +4,7 @@ import backoff import json -from segment.analytics.request import post, APIError, DatetimeSerializer +from customerio.analytics.request import post, APIError, DatetimeSerializer from queue import Empty @@ -17,7 +17,7 @@ class Consumer(Thread): """Consumes the messages from the client's queue.""" - log = logging.getLogger('segment') + log = logging.getLogger('customerio') def __init__(self, queue, write_key, upload_size=100, host=None, on_error=None, upload_interval=0.5, gzip=False, retries=10, diff --git a/segment/analytics/request.py b/customerio/analytics/request.py similarity index 88% rename from segment/analytics/request.py rename to customerio/analytics/request.py index d1901f79..0b92f4cf 100644 --- a/segment/analytics/request.py +++ b/customerio/analytics/request.py @@ -7,18 +7,18 @@ from requests.auth import HTTPBasicAuth from requests import sessions -from segment.analytics.version import VERSION -from segment.analytics.utils import remove_trailing_slash +from customerio.analytics.version import VERSION +from customerio.analytics.utils import remove_trailing_slash _session = sessions.Session() def post(write_key, host=None, gzip=False, timeout=15, proxies=None, **kwargs): """Post the `kwargs` to the API""" - log = logging.getLogger('segment') + log = logging.getLogger('customerio') body = kwargs body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat() - url = remove_trailing_slash(host or 'https://api.segment.io') + '/v1/batch' + url = remove_trailing_slash(host or 'https://cdp.customer.io') + '/v1/batch' auth = HTTPBasicAuth(write_key, '') data = json.dumps(body, cls=DatetimeSerializer) log.debug('making request: %s', data) @@ -68,7 +68,7 @@ def __init__(self, status, code, message): self.code = code def __str__(self): - msg = "[Segment] {0}: {1} ({2})" + msg = "[customer.io] {0}: {1} ({2})" return msg.format(self.code, self.message, self.status) diff --git a/segment/analytics/test/__init__.py b/customerio/analytics/test/__init__.py similarity index 96% rename from segment/analytics/test/__init__.py rename to customerio/analytics/test/__init__.py index 09bf9b63..5b1c7fd9 100644 --- a/segment/analytics/test/__init__.py +++ b/customerio/analytics/test/__init__.py @@ -4,7 +4,8 @@ import sys import analytics -from analytics.client import Client +from customerio.analytics.client import Client +from customerio import analytics def all_names(): diff --git a/segment/analytics/test/client.py b/customerio/analytics/test/client.py similarity index 99% rename from segment/analytics/test/client.py rename to customerio/analytics/test/client.py index f01dc2ce..000776d7 100644 --- a/segment/analytics/test/client.py +++ b/customerio/analytics/test/client.py @@ -3,8 +3,8 @@ import time import mock -from analytics.version import VERSION -from analytics.client import Client +from customerio.analytics.version import VERSION +from customerio.analytics.client import Client class TestClient(unittest.TestCase): diff --git a/segment/analytics/test/consumer.py b/customerio/analytics/test/consumer.py similarity index 98% rename from segment/analytics/test/consumer.py rename to customerio/analytics/test/consumer.py index 16d0b213..cf62acc4 100644 --- a/segment/analytics/test/consumer.py +++ b/customerio/analytics/test/consumer.py @@ -8,8 +8,8 @@ except ImportError: from Queue import Queue -from analytics.consumer import Consumer, MAX_MSG_SIZE -from analytics.request import APIError +from analytics import Consumer, MAX_MSG_SIZE, request +from customerio.analytics.request import APIError class TestConsumer(unittest.TestCase): diff --git a/segment/analytics/test/module.py b/customerio/analytics/test/module.py similarity index 93% rename from segment/analytics/test/module.py rename to customerio/analytics/test/module.py index 3901b1c7..0a3849e4 100644 --- a/segment/analytics/test/module.py +++ b/customerio/analytics/test/module.py @@ -1,13 +1,10 @@ import unittest -import analytics +from customerio.analytics import analytics class TestModule(unittest.TestCase): - # def failed(self): - # self.failed = True - def setUp(self): self.failed = False analytics.write_key = 'testsecret' diff --git a/segment/analytics/test/request.py b/customerio/analytics/test/request.py similarity index 90% rename from segment/analytics/test/request.py rename to customerio/analytics/test/request.py index 3420deca..003caa1e 100644 --- a/segment/analytics/test/request.py +++ b/customerio/analytics/test/request.py @@ -3,7 +3,7 @@ import json import requests -from analytics.request import post, DatetimeSerializer +from customerio.analytics.request import post, DatetimeSerializer class TestRequests(unittest.TestCase): @@ -18,11 +18,11 @@ def test_valid_request(self): def test_invalid_request_error(self): self.assertRaises(Exception, post, 'testsecret', - 'https://api.segment.io', False, '[{]') + 'https://cdp.customer.io', False, '[{]') def test_invalid_host(self): self.assertRaises(Exception, post, 'testsecret', - 'api.segment.io/', batch=[]) + 'cdp.customer.io/', batch=[]) def test_datetime_serialization(self): data = {'created': datetime(2012, 3, 4, 5, 6, 7, 891011)} diff --git a/segment/analytics/test/utils.py b/customerio/analytics/test/utils.py similarity index 88% rename from segment/analytics/test/utils.py rename to customerio/analytics/test/utils.py index 6995e799..cb9575a2 100644 --- a/segment/analytics/test/utils.py +++ b/customerio/analytics/test/utils.py @@ -67,7 +67,7 @@ def test_clean_fn(self): self.assertEqual(cleaned['fn'], None) def test_remove_slash(self): - self.assertEqual('http://segment.io', - utils.remove_trailing_slash('http://segment.io/')) - self.assertEqual('http://segment.io', - utils.remove_trailing_slash('http://segment.io')) + self.assertEqual('http://customer.io', + utils.remove_trailing_slash('http://customer.io/')) + self.assertEqual('http://customer.io', + utils.remove_trailing_slash('http://customer.io')) diff --git a/segment/analytics/utils.py b/customerio/analytics/utils.py similarity index 98% rename from segment/analytics/utils.py rename to customerio/analytics/utils.py index b51ff6b3..bffcdb87 100644 --- a/segment/analytics/utils.py +++ b/customerio/analytics/utils.py @@ -6,7 +6,7 @@ from datetime import date, datetime from dateutil.tz import tzlocal, tzutc -log = logging.getLogger('segment') +log = logging.getLogger('customerio') def is_naive(dt): diff --git a/customerio/analytics/version.py b/customerio/analytics/version.py new file mode 100644 index 00000000..a4e55ec0 --- /dev/null +++ b/customerio/analytics/version.py @@ -0,0 +1 @@ +VERSION = '0.0.1' diff --git a/e2e_test.sh b/e2e_test.sh deleted file mode 100755 index 3828abe2..00000000 --- a/e2e_test.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -set -e - -python ./simulator.py "$@" \ No newline at end of file diff --git a/segment/analytics/version.py b/segment/analytics/version.py deleted file mode 100644 index 5a099d96..00000000 --- a/segment/analytics/version.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = '2.2.2' diff --git a/setup.py b/setup.py index 11f8ab47..e6aab14b 100644 --- a/setup.py +++ b/setup.py @@ -5,18 +5,14 @@ from setuptools import setup except ImportError: from distutils.core import setup -# Don't import analytics-python module here, since deps may not be installed -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'segment','analytics')) +# Don't import the module here, since deps may not be installed +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'customerio','analytics')) from version import VERSION long_description = ''' -Segment is the simplest way to integrate analytics into your application. -One API allows you to turn on any other analytics service. No more learning -new APIs, repeated code, and wasted development time. +Customer.io Data Pipelines (CDP) is a customer data platform to improve decision-making with real-time updates and deliver personalized experiences. -This is the official python client that wraps the Segment REST API (https://segment.com). - -Documentation and more details at https://github.com/segmentio/analytics-python +This is the official python client that wraps the Customer.io Data Pipelines REST API (https://customer.io/docs/cdp/getting-started/cdp-getting-started/). ''' install_requires = [ @@ -33,22 +29,22 @@ ] setup( - name='segment-analytics-python', + name='customerio_analytics', version=VERSION, - url='https://github.com/segmentio/analytics-python', - author='Segment', - author_email='friends@segment.com', - maintainer='Segment', - maintainer_email='friends@segment.com', + url='https://github.com/customerio/cdp-analytics-python', + author='Customer.io', + author_email='cdp@customer.io', + maintainer='Customer.io', + maintainer_email='cdp@customer.io', test_suite='analytics.test.all', - packages=['segment.analytics', 'analytics.test'], + packages=['customerio.analytics', 'analytics.test'], python_requires='>=3.6.0', license='MIT License', install_requires=install_requires, extras_require={ 'test': tests_require }, - description='The hassle-free way to integrate analytics into any python application.', + description='Customer.io Data Pipelines (CDP) is a customer data platform to improve decision-making with real-time updates and deliver personalized experiences.', long_description=long_description, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/simulator.py b/simulator.py deleted file mode 100644 index df83de67..00000000 --- a/simulator.py +++ /dev/null @@ -1,99 +0,0 @@ -import logging -import argparse -import json -import segment.analytics as analytics - -__name__ = 'simulator.py' -__version__ = '0.0.1' -__description__ = 'scripting simulator' - - -def json_hash(str): - if str: - return json.loads(str) - -# analytics -method= -segment-write-key= [options] - - -parser = argparse.ArgumentParser(description='send a segment message') - -parser.add_argument('--writeKey', help='the Segment writeKey') -parser.add_argument('--type', help='The Segment message type') - -parser.add_argument('--userId', help='the user id to send the event as') -parser.add_argument( - '--anonymousId', help='the anonymous user id to send the event as') -parser.add_argument( - '--context', help='additional context for the event (JSON-encoded)') - -parser.add_argument('--event', help='the event name to send with the event') -parser.add_argument( - '--properties', help='the event properties to send (JSON-encoded)') - -parser.add_argument( - '--name', help='name of the screen or page to send with the message') - -parser.add_argument( - '--traits', help='the identify/group traits to send (JSON-encoded)') - -parser.add_argument('--groupId', help='the group id') - -options = parser.parse_args() - - -def failed(status, msg): - raise Exception(msg) - - -def track(): - analytics.track(options.userId, options.event, anonymous_id=options.anonymousId, - properties=json_hash(options.properties), context=json_hash(options.context)) - - -def page(): - analytics.page(options.userId, name=options.name, anonymous_id=options.anonymousId, - properties=json_hash(options.properties), context=json_hash(options.context)) - - -def screen(): - analytics.screen(options.userId, name=options.name, anonymous_id=options.anonymousId, - properties=json_hash(options.properties), context=json_hash(options.context)) - - -def identify(): - analytics.identify(options.userId, anonymous_id=options.anonymousId, - traits=json_hash(options.traits), context=json_hash(options.context)) - - -def group(): - analytics.group(options.userId, options.groupId, json_hash(options.traits), - json_hash(options.context), anonymous_id=options.anonymousId) - - -def unknown(): - print() - - -analytics.write_key = options.writeKey -analytics.on_error = failed -analytics.debug = True - -log = logging.getLogger('segment') -ch = logging.StreamHandler() -ch.setLevel(logging.DEBUG) -log.addHandler(ch) - -switcher = { - "track": track, - "page": page, - "screen": screen, - "identify": identify, - "group": group -} - -func = switcher.get(options.type) -if func: - func() - analytics.shutdown() -else: - print("Invalid Message Type " + options.type) From b680a477207f60fc9172be78c3e305257bba15f6 Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Fri, 5 May 2023 14:58:32 +0100 Subject: [PATCH 02/10] Release 0.0.1 --- .gitignore | 1 + README.md | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index b856542a..0211f3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build .vscode/ .idea/ .python-version +venv diff --git a/README.md b/README.md index 9272e945..dfd50727 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Using pip: ```bash -pip3 install customerio-analytics +pip3 install customerio-cdp-analytics ``` or you can install directly from this repo: @@ -16,7 +16,7 @@ pip3 install git+http://github.com/customerio/cdp-analytics-python ## Usage ```python -import customerio.analytics as analytics +from customerio_cdp_analytics import analytics analytics.write_key = 'YOUR_WRITE_KEY' diff --git a/setup.py b/setup.py index e6aab14b..1310350a 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ ] setup( - name='customerio_analytics', + name='customerio_cdp_analytics', version=VERSION, url='https://github.com/customerio/cdp-analytics-python', author='Customer.io', From a5c1326513fa264301caf513a26dd56e41bbae73 Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Thu, 3 Aug 2023 10:09:03 +0100 Subject: [PATCH 03/10] Remove userId requirement from group calls (#1) --- Makefile | 6 +- analytics/consumer.py | 133 ---------- analytics/test/client.py | 342 -------------------------- customerio/analytics/client.py | 1 - customerio/analytics/test/__init__.py | 3 +- customerio/analytics/test/client.py | 12 +- customerio/analytics/test/consumer.py | 10 +- customerio/analytics/test/module.py | 46 ---- customerio/analytics/test/utils.py | 2 +- setup.py | 2 +- 10 files changed, 17 insertions(+), 540 deletions(-) delete mode 100644 analytics/consumer.py delete mode 100644 analytics/test/client.py delete mode 100644 customerio/analytics/test/module.py diff --git a/Makefile b/Makefile index 93b13ea5..8ea2abef 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ install: pip install --edit .[test] test: - pylint --rcfile=.pylintrc --reports=y --exit-zero analytics - flake8 --max-complexity=10 --statistics analytics || true - python -m unittest customerio/analytics/test/*.py analytics/test/*.py + pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics + flake8 --max-complexity=10 --statistics customerio/analytics || true + python -m unittest customerio/analytics/test/*.py -v .PHONY: install test diff --git a/analytics/consumer.py b/analytics/consumer.py deleted file mode 100644 index a49f616a..00000000 --- a/analytics/consumer.py +++ /dev/null @@ -1,133 +0,0 @@ -import logging -from threading import Thread -import json -import monotonic -import backoff - - -from customerio.analytics.request import post, APIError, DatetimeSerializer - -from queue import Empty - -MAX_MSG_SIZE = 32 << 10 - -# Our servers only accept batches less than 500KB. Here limit is set slightly -# lower to leave space for extra data that will be added later, eg. "sentAt". -BATCH_SIZE_LIMIT = 475000 - - -class Consumer(Thread): - """Consumes the messages from the client's queue.""" - log = logging.getLogger('customerio') - - def __init__(self, queue, write_key, upload_size=100, host=None, - on_error=None, upload_interval=0.5, gzip=False, retries=10, - timeout=15, proxies=None): - """Create a consumer thread.""" - Thread.__init__(self) - # Make consumer a daemon thread so that it doesn't block program exit - self.daemon = True - self.upload_size = upload_size - self.upload_interval = upload_interval - self.write_key = write_key - self.host = host - self.on_error = on_error - self.queue = queue - self.gzip = gzip - # It's important to set running in the constructor: if we are asked to - # pause immediately after construction, we might set running to True in - # run() *after* we set it to False in pause... and keep running - # forever. - self.running = True - self.retries = retries - self.timeout = timeout - self.proxies = proxies - - def run(self): - """Runs the consumer.""" - self.log.debug('consumer is running...') - while self.running: - self.upload() - - self.log.debug('consumer exited.') - - def pause(self): - """Pause the consumer.""" - self.running = False - - def upload(self): - """Upload the next batch of items, return whether successful.""" - success = False - batch = self.next() - if len(batch) == 0: - return False - - try: - self.request(batch) - success = True - except Exception as e: - self.log.error('error uploading: %s', e) - success = False - if self.on_error: - self.on_error(e, batch) - finally: - # mark items as acknowledged from queue - for _ in batch: - self.queue.task_done() - return success - - def next(self): - """Return the next batch of items to upload.""" - queue = self.queue - items = [] - - start_time = monotonic.monotonic() - total_size = 0 - - while len(items) < self.upload_size: - elapsed = monotonic.monotonic() - start_time - if elapsed >= self.upload_interval: - break - try: - item = queue.get( - block=True, timeout=self.upload_interval - elapsed) - item_size = len(json.dumps( - item, cls=DatetimeSerializer).encode()) - if item_size > MAX_MSG_SIZE: - self.log.error( - 'Item exceeds 32kb limit, dropping. (%s)', str(item)) - continue - items.append(item) - total_size += item_size - if total_size >= BATCH_SIZE_LIMIT: - self.log.debug( - 'hit batch size limit (size: %d)', total_size) - break - except Empty: - break - - return items - - def request(self, batch): - """Attempt to upload the batch and retry before raising an error """ - - def fatal_exception(exc): - if isinstance(exc, APIError): - # retry on server errors and client errors - # with 429 status code (rate limited), - # don't retry on other client errors - return (400 <= exc.status < 500) and exc.status != 429 - else: - # retry on all other errors (eg. network) - return False - - @backoff.on_exception( - backoff.expo, - Exception, - max_tries=self.retries + 1, - giveup=fatal_exception) - def send_request(): - post(self.write_key, self.host, gzip=self.gzip, - timeout=self.timeout, batch=batch, proxies=self.proxies) - - send_request() diff --git a/analytics/test/client.py b/analytics/test/client.py deleted file mode 100644 index 80ceaa3d..00000000 --- a/analytics/test/client.py +++ /dev/null @@ -1,342 +0,0 @@ -from datetime import date, datetime -import unittest -import time -import mock - -from customerio.analytics.version import VERSION -from customerio.analytics.client import Client - - -class TestClient(unittest.TestCase): - - def fail(self): - """Mark the failure handler""" - self.failed = True - - def setUp(self): - self.failed = False - self.client = Client('testsecret', on_error=self.fail) - - def test_requires_write_key(self): - self.assertRaises(AssertionError, Client) - - def test_empty_flush(self): - self.client.flush() - - def test_basic_track(self): - client = self.client - success, msg = client.track('userId', 'python test event') - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['event'], 'python test event') - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertTrue(isinstance(msg['messageId'], str)) - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['properties'], {}) - self.assertEqual(msg['type'], 'track') - - def test_stringifies_user_id(self): - # A large number that loses precision in node: - # node -e "console.log(157963456373623802 + 1)" > 157963456373623800 - client = self.client - success, msg = client.track( - user_id=157963456373623802, event='python test event') - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['userId'], '157963456373623802') - self.assertEqual(msg['anonymousId'], None) - - def test_stringifies_anonymous_id(self): - # A large number that loses precision in node: - # node -e "console.log(157963456373623803 + 1)" > 157963456373623800 - client = self.client - success, msg = client.track( - anonymous_id=157963456373623803, event='python test event') - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['userId'], None) - self.assertEqual(msg['anonymousId'], '157963456373623803') - - def test_advanced_track(self): - client = self.client - success, msg = client.track( - 'userId', 'python test event', {'property': 'value'}, - {'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'anonymousId', - {'Amplitude': True}, 'messageId') - - self.assertTrue(success) - - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') - self.assertEqual(msg['properties'], {'property': 'value'}) - self.assertEqual(msg['integrations'], {'Amplitude': True}) - self.assertEqual(msg['context']['ip'], '192.168.0.1') - self.assertEqual(msg['event'], 'python test event') - self.assertEqual(msg['anonymousId'], 'anonymousId') - self.assertEqual(msg['context']['library'], { - 'name': 'analytics-python', - 'version': VERSION - }) - self.assertEqual(msg['messageId'], 'messageId') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'track') - - def test_basic_identify(self): - client = self.client - success, msg = client.identify('userId', {'trait': 'value'}) - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['traits'], {'trait': 'value'}) - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertTrue(isinstance(msg['messageId'], str)) - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'identify') - - def test_advanced_identify(self): - client = self.client - success, msg = client.identify( - 'userId', {'trait': 'value'}, {'ip': '192.168.0.1'}, - datetime(2014, 9, 3), 'anonymousId', {'Amplitude': True}, - 'messageId') - - self.assertTrue(success) - - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') - self.assertEqual(msg['integrations'], {'Amplitude': True}) - self.assertEqual(msg['context']['ip'], '192.168.0.1') - self.assertEqual(msg['traits'], {'trait': 'value'}) - self.assertEqual(msg['anonymousId'], 'anonymousId') - self.assertEqual(msg['context']['library'], { - 'name': 'analytics-python', - 'version': VERSION - }) - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertEqual(msg['messageId'], 'messageId') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'identify') - - def test_basic_group(self): - client = self.client - success, msg = client.group('userId', 'groupId') - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['groupId'], 'groupId') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'group') - - def test_advanced_group(self): - client = self.client - success, msg = client.group( - 'userId', 'groupId', {'trait': 'value'}, {'ip': '192.168.0.1'}, - datetime(2014, 9, 3), 'anonymousId', {'Amplitude': True}, - 'messageId') - - self.assertTrue(success) - - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') - self.assertEqual(msg['integrations'], {'Amplitude': True}) - self.assertEqual(msg['context']['ip'], '192.168.0.1') - self.assertEqual(msg['traits'], {'trait': 'value'}) - self.assertEqual(msg['anonymousId'], 'anonymousId') - self.assertEqual(msg['context']['library'], { - 'name': 'analytics-python', - 'version': VERSION - }) - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertEqual(msg['messageId'], 'messageId') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'group') - - def test_basic_alias(self): - client = self.client - success, msg = client.alias('previousId', 'userId') - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - self.assertEqual(msg['previousId'], 'previousId') - self.assertEqual(msg['userId'], 'userId') - - def test_basic_page(self): - client = self.client - success, msg = client.page('userId', name='name') - self.assertFalse(self.failed) - client.flush() - self.assertTrue(success) - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'page') - self.assertEqual(msg['name'], 'name') - - def test_advanced_page(self): - client = self.client - success, msg = client.page( - 'userId', 'category', 'name', {'property': 'value'}, - {'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'anonymousId', - {'Amplitude': True}, 'messageId') - - self.assertTrue(success) - - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') - self.assertEqual(msg['integrations'], {'Amplitude': True}) - self.assertEqual(msg['context']['ip'], '192.168.0.1') - self.assertEqual(msg['properties'], {'property': 'value'}) - self.assertEqual(msg['anonymousId'], 'anonymousId') - self.assertEqual(msg['context']['library'], { - 'name': 'analytics-python', - 'version': VERSION - }) - self.assertEqual(msg['category'], 'category') - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertEqual(msg['messageId'], 'messageId') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'page') - self.assertEqual(msg['name'], 'name') - - def test_basic_screen(self): - client = self.client - success, msg = client.screen('userId', name='name') - client.flush() - self.assertTrue(success) - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'screen') - self.assertEqual(msg['name'], 'name') - - def test_advanced_screen(self): - client = self.client - success, msg = client.screen( - 'userId', 'category', 'name', {'property': 'value'}, - {'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'anonymousId', - {'Amplitude': True}, 'messageId') - - self.assertTrue(success) - - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') - self.assertEqual(msg['integrations'], {'Amplitude': True}) - self.assertEqual(msg['context']['ip'], '192.168.0.1') - self.assertEqual(msg['properties'], {'property': 'value'}) - self.assertEqual(msg['anonymousId'], 'anonymousId') - self.assertEqual(msg['context']['library'], { - 'name': 'analytics-python', - 'version': VERSION - }) - self.assertTrue(isinstance(msg['timestamp'], str)) - self.assertEqual(msg['messageId'], 'messageId') - self.assertEqual(msg['category'], 'category') - self.assertEqual(msg['userId'], 'userId') - self.assertEqual(msg['type'], 'screen') - self.assertEqual(msg['name'], 'name') - - def test_flush(self): - client = self.client - # set up the consumer with more requests than a single batch will allow - for _ in range(1000): - _, _ = client.identify('userId', {'trait': 'value'}) - # We can't reliably assert that the queue is non-empty here; that's - # a race condition. We do our best to load it up though. - client.flush() - # Make sure that the client queue is empty after flushing - self.assertTrue(client.queue.empty()) - - def test_shutdown(self): - client = self.client - # set up the consumer with more requests than a single batch will allow - for _ in range(1000): - _, _ = client.identify('userId', {'trait': 'value'}) - client.shutdown() - # we expect two things after shutdown: - # 1. client queue is empty - # 2. consumer thread has stopped - self.assertTrue(client.queue.empty()) - for consumer in client.consumers: - self.assertFalse(consumer.is_alive()) - - def test_synchronous(self): - client = Client('testsecret', sync_mode=True) - - success, _ = client.identify('userId') - self.assertFalse(client.consumers) - self.assertTrue(client.queue.empty()) - self.assertTrue(success) - - def test_overflow(self): - client = Client('testsecret', max_queue_size=1) - # Ensure consumer thread is no longer uploading - client.join() - - for _ in range(10): - client.identify('userId') - - success, _ = client.identify('userId') - # Make sure we are informed that the queue is at capacity - self.assertFalse(success) - - def test_success_on_invalid_write_key(self): - client = Client('bad_key', on_error=self.fail) - client.track('userId', 'event') - client.flush() - self.assertFalse(self.failed) - - def test_numeric_user_id(self): - self.client.track(1234, 'python event') - self.client.flush() - self.assertFalse(self.failed) - - def test_identify_with_date_object(self): - client = self.client - success, msg = client.identify( - 'userId', - { - 'birthdate': date(1981, 2, 2), - }, - ) - client.flush() - self.assertTrue(success) - self.assertFalse(self.failed) - - self.assertEqual(msg['traits'], {'birthdate': date(1981, 2, 2)}) - - def test_gzip(self): - client = Client('testsecret', on_error=self.fail, gzip=True) - for _ in range(10): - client.identify('userId', {'trait': 'value'}) - client.flush() - self.assertFalse(self.failed) - - def test_user_defined_upload_size(self): - client = Client('testsecret', on_error=self.fail, - upload_size=10, upload_interval=3) - - def mock_post_fn(**kwargs): - self.assertEqual(len(kwargs['batch']), 10) - - # the post function should be called 2 times, with a batch size of 10 - # each time. - with mock.patch('analytics.consumer.post', side_effect=mock_post_fn) \ - as mock_post: - for _ in range(20): - client.identify('userId', {'trait': 'value'}) - time.sleep(1) - self.assertEqual(mock_post.call_count, 2) - - def test_user_defined_timeout(self): - client = Client('testsecret', timeout=10) - for consumer in client.consumers: - self.assertEqual(consumer.timeout, 10) - - def test_default_timeout_15(self): - client = Client('testsecret') - for consumer in client.consumers: - self.assertEqual(consumer.timeout, 15) - - def test_proxies(self): - client = Client('testsecret', proxies='203.243.63.16:80') - success, msg = client.identify('userId', {'trait': 'value'}) - self.assertTrue(success) diff --git a/customerio/analytics/client.py b/customerio/analytics/client.py index e063820e..4185d776 100644 --- a/customerio/analytics/client.py +++ b/customerio/analytics/client.py @@ -162,7 +162,6 @@ def group(self, user_id=None, group_id=None, traits=None, context=None, traits = traits or {} context = context or {} integrations = integrations or {} - require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES) require('group_id', group_id, ID_TYPES) require('traits', traits, dict) diff --git a/customerio/analytics/test/__init__.py b/customerio/analytics/test/__init__.py index 5b1c7fd9..d936c6d3 100644 --- a/customerio/analytics/test/__init__.py +++ b/customerio/analytics/test/__init__.py @@ -2,7 +2,6 @@ import pkgutil import logging import sys -import analytics from customerio.analytics.client import Client from customerio import analytics @@ -10,7 +9,7 @@ def all_names(): for _, modname, _ in pkgutil.iter_modules(__path__): - yield 'analytics.test.' + modname + yield 'customerio.analytics.test.' + modname def all(): diff --git a/customerio/analytics/test/client.py b/customerio/analytics/test/client.py index 000776d7..512e136e 100644 --- a/customerio/analytics/test/client.py +++ b/customerio/analytics/test/client.py @@ -72,7 +72,7 @@ def test_advanced_track(self): self.assertTrue(success) - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') + self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00.000+00:00') self.assertEqual(msg['properties'], {'property': 'value'}) self.assertEqual(msg['integrations'], {'Amplitude': True}) self.assertEqual(msg['context']['ip'], '192.168.0.1') @@ -108,7 +108,7 @@ def test_advanced_identify(self): self.assertTrue(success) - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') + self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00.000+00:00') self.assertEqual(msg['integrations'], {'Amplitude': True}) self.assertEqual(msg['context']['ip'], '192.168.0.1') self.assertEqual(msg['traits'], {'trait': 'value'}) @@ -142,7 +142,7 @@ def test_advanced_group(self): self.assertTrue(success) - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') + self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00.000+00:00') self.assertEqual(msg['integrations'], {'Amplitude': True}) self.assertEqual(msg['context']['ip'], '192.168.0.1') self.assertEqual(msg['traits'], {'trait': 'value'}) @@ -184,7 +184,7 @@ def test_advanced_page(self): self.assertTrue(success) - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') + self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00.000+00:00') self.assertEqual(msg['integrations'], {'Amplitude': True}) self.assertEqual(msg['context']['ip'], '192.168.0.1') self.assertEqual(msg['properties'], {'property': 'value'}) @@ -218,7 +218,7 @@ def test_advanced_screen(self): self.assertTrue(success) - self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00') + self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00.000+00:00') self.assertEqual(msg['integrations'], {'Amplitude': True}) self.assertEqual(msg['context']['ip'], '192.168.0.1') self.assertEqual(msg['properties'], {'property': 'value'}) @@ -325,7 +325,7 @@ def mock_post_fn(*args, **kwargs): # the post function should be called 2 times, with a batch size of 10 # each time. - with mock.patch('analytics.consumer.post', side_effect=mock_post_fn) \ + with mock.patch('customerio.analytics.consumer.post', side_effect=mock_post_fn) \ as mock_post: for _ in range(20): client.identify('userId', {'trait': 'value'}) diff --git a/customerio/analytics/test/consumer.py b/customerio/analytics/test/consumer.py index cf62acc4..5ef02fc5 100644 --- a/customerio/analytics/test/consumer.py +++ b/customerio/analytics/test/consumer.py @@ -8,7 +8,7 @@ except ImportError: from Queue import Queue -from analytics import Consumer, MAX_MSG_SIZE, request +from customerio.analytics.consumer import Consumer, MAX_MSG_SIZE from customerio.analytics.request import APIError @@ -59,7 +59,7 @@ def test_upload_interval(self): upload_interval = 0.3 consumer = Consumer(q, 'testsecret', upload_size=10, upload_interval=upload_interval) - with mock.patch('analytics.consumer.post') as mock_post: + with mock.patch('customerio.analytics.consumer.post') as mock_post: consumer.start() for i in range(0, 3): track = { @@ -79,7 +79,7 @@ def test_multiple_uploads_per_interval(self): upload_size = 10 consumer = Consumer(q, 'testsecret', upload_size=upload_size, upload_interval=upload_interval) - with mock.patch('analytics.consumer.post') as mock_post: + with mock.patch('customerio.analytics.consumer.post') as mock_post: consumer.start() for i in range(0, upload_size * 2): track = { @@ -110,7 +110,7 @@ def mock_post(*args, **kwargs): raise expected_exception mock_post.call_count = 0 - with mock.patch('analytics.consumer.post', + with mock.patch('customerio.analytics.consumer.post', mock.Mock(side_effect=mock_post)): track = { 'type': 'track', @@ -190,7 +190,7 @@ def mock_post_fn(_, data, **kwargs): % len(data.encode())) return res - with mock.patch('analytics.request._session.post', + with mock.patch('customerio.analytics.request._session.post', side_effect=mock_post_fn) as mock_post: consumer.start() for _ in range(0, n_msgs + 2): diff --git a/customerio/analytics/test/module.py b/customerio/analytics/test/module.py deleted file mode 100644 index 0a3849e4..00000000 --- a/customerio/analytics/test/module.py +++ /dev/null @@ -1,46 +0,0 @@ -import unittest - -from customerio.analytics import analytics - - -class TestModule(unittest.TestCase): - - def setUp(self): - self.failed = False - analytics.write_key = 'testsecret' - analytics.on_error = self.failed - - def test_no_write_key(self): - analytics.write_key = None - self.assertRaises(Exception, analytics.track) - - def test_no_host(self): - analytics.host = None - self.assertRaises(Exception, analytics.track) - - def test_track(self): - analytics.track('userId', 'python module event') - analytics.flush() - - def test_identify(self): - analytics.identify('userId', {'email': 'user@email.com'}) - analytics.flush() - - def test_group(self): - analytics.group('userId', 'groupId') - analytics.flush() - - def test_alias(self): - analytics.alias('previousId', 'userId') - analytics.flush() - - def test_page(self): - analytics.page('userId') - analytics.flush() - - def test_screen(self): - analytics.screen('userId') - analytics.flush() - - def test_flush(self): - analytics.flush() diff --git a/customerio/analytics/test/utils.py b/customerio/analytics/test/utils.py index cb9575a2..73907ce5 100644 --- a/customerio/analytics/test/utils.py +++ b/customerio/analytics/test/utils.py @@ -4,7 +4,7 @@ from dateutil.tz import tzutc -from analytics import utils +from customerio.analytics import utils class TestUtils(unittest.TestCase): diff --git a/setup.py b/setup.py index 1310350a..143a9ab5 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ maintainer='Customer.io', maintainer_email='cdp@customer.io', test_suite='analytics.test.all', - packages=['customerio.analytics', 'analytics.test'], + packages=['customerio.analytics'], python_requires='>=3.6.0', license='MIT License', install_requires=install_requires, From a57f6bb617b9dc37550fcd5b432c9bb897119ac8 Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Thu, 3 Aug 2023 10:14:28 +0100 Subject: [PATCH 04/10] Release 0.0.2 --- customerio/analytics/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/customerio/analytics/version.py b/customerio/analytics/version.py index a4e55ec0..bc784a3b 100644 --- a/customerio/analytics/version.py +++ b/customerio/analytics/version.py @@ -1 +1 @@ -VERSION = '0.0.1' +VERSION = '0.0.2' From fa5ca4ce642468675186163ec8e91613b4c9112e Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Thu, 3 Aug 2023 10:53:01 +0100 Subject: [PATCH 05/10] Add release action (#2) --- .bumpversion.cfg | 5 ++++ .github/workflows/release.yml | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .bumpversion.cfg create mode 100644 .github/workflows/release.yml diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..6ad311a9 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,5 @@ +[bumpversion] +current_version = 0.0.2 +parse = (?P\d+)\.(?P\d+)\.(?P\d+) +serialize = + {major}.{minor}.{patch} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..871263d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + type: choice + description: The new version to build and publish + required: true + default: 'patch' + options: + - major + - minor + - patch + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + cache: 'pip' + cache-dependency-path: setup.py + + - name: Install dependencies + run: | + python -m pip install bump2version twine wheel + + - name: Bump version + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + bumpversion --commit ${{ github.event.inputs.version }} customerio/analytics/version.py + + - name: Publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* From 41203eed889d8c7b3869a612da47f971b38fa65e Mon Sep 17 00:00:00 2001 From: Nuno Sousa Date: Thu, 2 Nov 2023 11:58:41 +0000 Subject: [PATCH 06/10] Add host docs to README (#3) --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dfd50727..b21aa40c 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,30 @@ pip3 install git+http://github.com/customerio/cdp-analytics-python ## Usage ```python -from customerio_cdp_analytics import analytics +from customerio import analytics analytics.write_key = 'YOUR_WRITE_KEY' analytics.track(user_id=4, event='order_complete') ``` + +## Other Regions + +If you're using a [different data center](https://customer.io/docs/accounts-and-workspaces/data-centers/) such as our EU region, you can specify an alternate endpoint: + +```python +from customerio import analytics + +analytics.write_key = 'YOUR_WRITE_KEY' +analytics.host = 'https://cdp-eu.customer.io' + +analytics.track(user_id=4, event='order_complete') +``` + +## Documentation + +The links below contain more detailed documentation on how to use this library: + +* [Documentation](https://customer.io/docs/cdp/sources/connections/servers/python/) +* [Specs](https://customer.io/docs/cdp/sources/source-spec/source-events/) +* [PyPi](https://pypi.org/project/customerio-cdp-analytics/) From dccaa90c0bfcb31d123b170a06044b70cf4e4ec8 Mon Sep 17 00:00:00 2001 From: Rich Dawe <142925814+richdawe-cio@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:23:45 +0100 Subject: [PATCH 07/10] Release 1.0.0: remove support for Python 3.6, 3.7; fix linting errors (#5) * Bump version to 1.0.0; drop support for Python 3.6, 3.7 because of requests requirement * Test with more recent Python 3.x versions * Fix pylint errors: missing __init__.py, import ordering, exception handling Add customerio/__init__.py so pylint can resolve the package hierarchy. Fix raise-missing-from, return-in-finally, and import ordering warnings. Fix RuntimeError formatting bug where %-placeholders were not interpolated. * mise venv for local development * Prefer using pip from venv to uv * Split linting out from tests * Add lint-ci which fails only on linting errors * Latest doc URLs --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .bumpversion.cfg | 2 +- .github/workflows/lint.yml | 19 +++++++++++++++++++ .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 7 ++++--- .gitignore | 1 + .mise.toml | 20 ++++++++++++++++++++ Makefile | 16 +++++++++++++--- README.md | 6 +++--- customerio/__init__.py | 0 customerio/analytics/client.py | 17 +++++++++-------- customerio/analytics/consumer.py | 10 +++++----- customerio/analytics/request.py | 4 ++-- customerio/analytics/test/consumer.py | 5 +++-- customerio/analytics/version.py | 2 +- requirements.txt | 7 +++++++ setup.py | 19 +++++++++++-------- 16 files changed, 101 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .mise.toml create mode 100644 customerio/__init__.py create mode 100644 requirements.txt diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6ad311a9..facbe377 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.0.2 +current_version = 1.0.0 parse = (?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..c2a97a51 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,19 @@ +name: Lint + +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + cache: 'pip' + cache-dependency-path: setup.py + + - run: pip3 install -e '.[test]' + - run: make lint-ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 871263d5..1f28c5c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,9 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: '3.x' cache: 'pip' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da2daf6d..adb4a249 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,13 +7,14 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.gitignore b/.gitignore index 0211f3ae..ccadd9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build .idea/ .python-version venv +.venv diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..826f9b46 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,20 @@ +[env] +MISE_FETCH_REMOTE_VERSIONS_TIMEOUT = "30s" + +_.python.venv = { + path = ".venv", + create = true, +} + +[settings] +python.uv_venv_auto = false +python.venv_stdlib = true + +[tools] +python = "3.11" + +[tasks.test] +run = 'make install test' + +[tasks.lint] +run = 'make install lint' diff --git a/Makefile b/Makefile index 8ea2abef..0d6122af 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,18 @@ install: pip install --edit .[test] test: - pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics - flake8 --max-complexity=10 --statistics customerio/analytics || true python -m unittest customerio/analytics/test/*.py -v -.PHONY: install test +lint: + pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics + flake8 --max-complexity=10 --statistics --exit-zero customerio/analytics + +lint-ci: + pylint --rcfile=.pylintrc --exit-zero --fail-on=E customerio/analytics + flake8 --max-complexity=10 --max-line-length=100 --statistics customerio/analytics + +clean: + rm -rf .venv + mise deps + +.PHONY: install test lint lint-ci clean diff --git a/README.md b/README.md index b21aa40c..7c2f61b4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Customer.io Data Pipelines analytics client for Python. +# Customer.io Data Pipelines analytics client for Python. ## Installation @@ -40,6 +40,6 @@ analytics.track(user_id=4, event='order_complete') The links below contain more detailed documentation on how to use this library: -* [Documentation](https://customer.io/docs/cdp/sources/connections/servers/python/) -* [Specs](https://customer.io/docs/cdp/sources/source-spec/source-events/) +* [Documentation](https://docs.customer.io/integrations/data-in/connections/servers/python/) +* [Specs](https://docs.customer.io/integrations/data-in/source-spec/incoming-data/) * [PyPi](https://pypi.org/project/customerio-cdp-analytics/) diff --git a/customerio/__init__.py b/customerio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/customerio/analytics/client.py b/customerio/analytics/client.py index 4185d776..c5387fb3 100644 --- a/customerio/analytics/client.py +++ b/customerio/analytics/client.py @@ -1,19 +1,18 @@ -from datetime import datetime -from uuid import uuid4 -import logging -import numbers import atexit import json +import logging +import numbers +import queue +from datetime import datetime +from uuid import uuid4 from dateutil.tz import tzutc -from customerio.analytics.utils import guess_timezone, clean from customerio.analytics.consumer import Consumer, MAX_MSG_SIZE from customerio.analytics.request import post, DatetimeSerializer +from customerio.analytics.utils import guess_timezone, clean from customerio.analytics.version import VERSION -import queue - ID_TYPES = (numbers.Number, str) @@ -269,7 +268,9 @@ def _enqueue(self, msg): # Check message size. msg_size = len(json.dumps(msg, cls=DatetimeSerializer).encode()) if msg_size > MAX_MSG_SIZE: - raise RuntimeError('Message exceeds %skb limit. (%s)', str(int(MAX_MSG_SIZE / 1024)), str(msg)) + raise RuntimeError( + 'Message exceeds %dkb limit. (%s)' % (MAX_MSG_SIZE // 1024, msg) + ) # if send is False, return msg as if it was successfully queued if not self.send: diff --git a/customerio/analytics/consumer.py b/customerio/analytics/consumer.py index d9b05479..8f478b54 100644 --- a/customerio/analytics/consumer.py +++ b/customerio/analytics/consumer.py @@ -1,13 +1,13 @@ +import json import logging +from queue import Empty from threading import Thread -import monotonic + import backoff -import json +import monotonic from customerio.analytics.request import post, APIError, DatetimeSerializer -from queue import Empty - MAX_MSG_SIZE = 32 << 10 # Our servers only accept batches less than 500KB. Here limit is set slightly @@ -73,7 +73,7 @@ def upload(self): # mark items as acknowledged from queue for _ in batch: self.queue.task_done() - return success + return success def next(self): """Return the next batch of items to upload.""" diff --git a/customerio/analytics/request.py b/customerio/analytics/request.py index 0b92f4cf..5b0eb99a 100644 --- a/customerio/analytics/request.py +++ b/customerio/analytics/request.py @@ -56,8 +56,8 @@ def post(write_key, host=None, gzip=False, timeout=15, proxies=None, **kwargs): payload = res.json() log.debug('received response: %s', payload) raise APIError(res.status_code, payload['code'], payload['message']) - except ValueError: - raise APIError(res.status_code, 'unknown', res.text) + except ValueError as exc: + raise APIError(res.status_code, 'unknown', res.text) from exc class APIError(Exception): diff --git a/customerio/analytics/test/consumer.py b/customerio/analytics/test/consumer.py index 5ef02fc5..057f6338 100644 --- a/customerio/analytics/test/consumer.py +++ b/customerio/analytics/test/consumer.py @@ -1,7 +1,8 @@ +import json +import time import unittest + import mock -import time -import json try: from queue import Queue diff --git a/customerio/analytics/version.py b/customerio/analytics/version.py index bc784a3b..1e5a6058 100644 --- a/customerio/analytics/version.py +++ b/customerio/analytics/version.py @@ -1 +1 @@ -VERSION = '0.0.2' +VERSION = '1.0.0' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..86364777 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +backoff==2.2.1 +flake8==3.7.9 +monotonic==1.6 +mock==2.0.0 +pylint==3.3.3 +python-dateutil==2.8.2 +requests>=2.32.4 \ No newline at end of file diff --git a/setup.py b/setup.py index 143a9ab5..e2463395 100644 --- a/setup.py +++ b/setup.py @@ -16,15 +16,15 @@ ''' install_requires = [ - "requests~=2.7", - "monotonic~=1.5", - "backoff~=2.1", - "python-dateutil~=2.2" + "requests>=2.32.4", + "monotonic~=1.6", + "backoff~=2.2", + "python-dateutil~=2.8" ] tests_require = [ "mock==2.0.0", - "pylint==2.8.0", + "pylint>=3.2.0", "flake8==3.7.9", ] @@ -38,7 +38,7 @@ maintainer_email='cdp@customer.io', test_suite='analytics.test.all', packages=['customerio.analytics'], - python_requires='>=3.6.0', + python_requires='>=3.8.0', license='MIT License', install_requires=install_requires, extras_require={ @@ -52,9 +52,12 @@ "License :: OSI Approved :: MIT 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", + "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", ], ) From 60e2b1067301c02cf6eab999bb2690a39f774ae2 Mon Sep 17 00:00:00 2001 From: Rich Dawe <142925814+richdawe-cio@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:31:26 +0100 Subject: [PATCH 08/10] Modernize Python CI and packaging (#7) * Dependabot * Modernise GH workflows * Switch to pyproject.toml * Switch to core unittest.mock * Remove support for Python 3.8 * Try to avoid Bugbot drip on code reviews * Read runtime version from package metadata instead of hardcoding * Upgrade flake8 to fix Python 3.14 compatibility flake8 3.7.9's pyflakes dependency uses ast.Str, which was removed in Python 3.14, causing "module 'ast' has no attribute 'Str'" in CI. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .cursor/BUGBOT.md | 10 +++++ .github/dependabot.yml | 11 +++++ .github/workflows/build.yml | 34 ++++++++++++++ .github/workflows/lint.yml | 24 +++++++--- .github/workflows/test.yml | 24 +++++++--- Makefile | 23 +++++++--- README.md | 2 +- customerio/analytics/test/client.py | 2 +- customerio/analytics/test/consumer.py | 3 +- customerio/analytics/version.py | 4 +- pyproject.toml | 56 +++++++++++++++++++++++ requirements.txt | 1 - setup.py | 64 +-------------------------- 13 files changed, 173 insertions(+), 85 deletions(-) create mode 100644 .cursor/BUGBOT.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 pyproject.toml diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 00000000..a28e413e --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,10 @@ +# Bugbot Review Rules + +## Core Principle: Comprehensive First-Pass Reviews + +**CRITICAL INSTRUCTION**: You must perform a complete, exhaustive analysis on the FIRST review of any changeset. Do NOT hold back observations or defer issues to later reviews. All feedback must be provided upfront. + +## Expected Behavior + +Your first review should be comprehensive enough that subsequent reviews only need to address newly changed code. The goal is to eliminate "surprise" feedback on code that was part of the initial changeset but somehow escaped earlier review. + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0e28f6a9 --- /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/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..f27988c3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - 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/.github/workflows/lint.yml b/.github/workflows/lint.yml index c2a97a51..75fe04e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,9 +1,19 @@ name: Lint -on: [push] +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: lint: + name: Lint + runs-on: ubuntu-latest steps: @@ -11,9 +21,13 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.14" cache: 'pip' - cache-dependency-path: setup.py + cache-dependency-path: pyproject.toml - - run: pip3 install -e '.[test]' - - run: make lint-ci + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Run lint + run: make lint-ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index adb4a249..a626f658 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,15 +1,25 @@ name: Test -on: [push] +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: test: + name: Test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] steps: - uses: actions/checkout@v6 @@ -18,7 +28,11 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: setup.py + cache-dependency-path: pyproject.toml - - run: pip3 install -e '.[test]' - - run: make test + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . + - name: Run tests + run: make test diff --git a/Makefile b/Makefile index 0d6122af..d800d4b8 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,28 @@ +PYTHON ?= python3 + install: - pip install --edit .[test] + $(PYTHON) -m pip install -e ".[dev]" + +build: + rm -rf build + $(PYTHON) -m build test: - python -m unittest customerio/analytics/test/*.py -v + $(PYTHON) -m unittest customerio/analytics/test/*.py -v lint: - pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics - flake8 --max-complexity=10 --statistics --exit-zero customerio/analytics + $(PYTHON) -m pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics + $(PYTHON) -m flake8 --max-complexity=10 --statistics --exit-zero customerio/analytics lint-ci: - pylint --rcfile=.pylintrc --exit-zero --fail-on=E customerio/analytics - flake8 --max-complexity=10 --max-line-length=100 --statistics customerio/analytics + $(PYTHON) -m pylint --rcfile=.pylintrc --exit-zero --fail-on=E customerio/analytics + $(PYTHON) -m flake8 --max-complexity=10 --max-line-length=100 --statistics customerio/analytics clean: + rm -rf MANIFEST build dist customerio.egg-info + +clean-venv: rm -rf .venv mise deps -.PHONY: install test lint lint-ci clean +.PHONY: install build test lint lint-ci clean clean-venv diff --git a/README.md b/README.md index 7c2f61b4..c82790fe 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ analytics.track(user_id=4, event='order_complete') ## Other Regions -If you're using a [different data center](https://customer.io/docs/accounts-and-workspaces/data-centers/) such as our EU region, you can specify an alternate endpoint: +If you're using a [different data center](https://docs.customer.io/accounts/settings/data-centers/) such as our EU region, you can specify an alternate endpoint: ```python from customerio import analytics diff --git a/customerio/analytics/test/client.py b/customerio/analytics/test/client.py index 512e136e..5fc58350 100644 --- a/customerio/analytics/test/client.py +++ b/customerio/analytics/test/client.py @@ -1,7 +1,7 @@ from datetime import date, datetime import unittest +import unittest.mock as mock import time -import mock from customerio.analytics.version import VERSION from customerio.analytics.client import Client diff --git a/customerio/analytics/test/consumer.py b/customerio/analytics/test/consumer.py index 057f6338..d10ded2e 100644 --- a/customerio/analytics/test/consumer.py +++ b/customerio/analytics/test/consumer.py @@ -1,8 +1,7 @@ import json import time import unittest - -import mock +import unittest.mock as mock try: from queue import Queue diff --git a/customerio/analytics/version.py b/customerio/analytics/version.py index 1e5a6058..926cb3d0 100644 --- a/customerio/analytics/version.py +++ b/customerio/analytics/version.py @@ -1 +1,3 @@ -VERSION = '1.0.0' +from importlib.metadata import version + +VERSION = version("customerio_cdp_analytics") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6025c610 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "customerio_cdp_analytics" +dynamic = ["version"] +# Version is derived from git tags via setuptools-scm (e.g. tag v3.0.0 → version 3.0.0) +description = "Customer.io Data Pipelines (CDP) Python bindings." +readme = "README.md" +requires-python = ">=3.9.0" +license = "MIT" +license-files = ["LICENSE"] +authors = [ + { name = "Peaberry Software Inc.", email = "support@customerio.com" }, +] +dependencies = [ + "requests>=2.32.4", + "monotonic~=1.6", + "backoff~=2.2", + "python-dateutil~=2.8", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "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", + "twine>=6.1.0", + "pylint>=3.2.0", + "flake8>=7.1.0", +] + +[project.urls] +Homepage = "https://github.com/customerio/cdp-analytics-python" +Releases = "https://github.com/customerio/cdp-analytics-python/releases" +Issues = "https://github.com/customerio/cdp-analytics-python/issues" + +[tool.setuptools_scm] +version_scheme = "guess-next-dev" +local_scheme = "no-local-version" + +[tool.setuptools.packages.find] +include = ["customerio*"] diff --git a/requirements.txt b/requirements.txt index 86364777..6a9d363f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ backoff==2.2.1 flake8==3.7.9 monotonic==1.6 -mock==2.0.0 pylint==3.3.3 python-dateutil==2.8.2 requests>=2.32.4 \ No newline at end of file diff --git a/setup.py b/setup.py index e2463395..df789d21 100644 --- a/setup.py +++ b/setup.py @@ -1,63 +1,3 @@ -import os -import sys +from setuptools import find_packages, setup -try: - from setuptools import setup -except ImportError: - from distutils.core import setup -# Don't import the module here, since deps may not be installed -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'customerio','analytics')) -from version import VERSION - -long_description = ''' -Customer.io Data Pipelines (CDP) is a customer data platform to improve decision-making with real-time updates and deliver personalized experiences. - -This is the official python client that wraps the Customer.io Data Pipelines REST API (https://customer.io/docs/cdp/getting-started/cdp-getting-started/). -''' - -install_requires = [ - "requests>=2.32.4", - "monotonic~=1.6", - "backoff~=2.2", - "python-dateutil~=2.8" -] - -tests_require = [ - "mock==2.0.0", - "pylint>=3.2.0", - "flake8==3.7.9", -] - -setup( - name='customerio_cdp_analytics', - version=VERSION, - url='https://github.com/customerio/cdp-analytics-python', - author='Customer.io', - author_email='cdp@customer.io', - maintainer='Customer.io', - maintainer_email='cdp@customer.io', - test_suite='analytics.test.all', - packages=['customerio.analytics'], - python_requires='>=3.8.0', - license='MIT License', - install_requires=install_requires, - extras_require={ - 'test': tests_require - }, - description='Customer.io Data Pipelines (CDP) is a customer data platform to improve decision-making with real-time updates and deliver personalized experiences.', - long_description=long_description, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "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", - ], -) +setup(packages=find_packages(include=["customerio", "customerio.*"])) From 113745c1f7a24c8a413e4a0c22004ac474a6e6c8 Mon Sep 17 00:00:00 2001 From: Rich Dawe <142925814+richdawe-cio@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:11:42 +0100 Subject: [PATCH 09/10] Add automated PyPI publish workflow (#8) --- .github/workflows/lint.yml | 4 +++- .github/workflows/publish.yml | 37 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 45 ----------------------------------- .github/workflows/test.yml | 4 +++- 4 files changed, 43 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 75fe04e5..05828703 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..02db557b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Build and publish + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - 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 build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1f28c5c0..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - version: - type: choice - description: The new version to build and publish - required: true - default: 'patch' - options: - - major - - minor - - patch - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 - with: - python-version: '3.x' - cache: 'pip' - cache-dependency-path: setup.py - - - name: Install dependencies - run: | - python -m pip install bump2version twine wheel - - - name: Bump version - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - bumpversion --commit ${{ github.event.inputs.version }} customerio/analytics/version.py - - - name: Publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a626f658..b594b8da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,9 @@ jobs: python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/setup-python@v6 with: From 115e3c21cdbfad1d907883c10897b0fcf7995a4b Mon Sep 17 00:00:00 2001 From: Richard Dawe Date: Fri, 26 Jun 2026 15:29:54 +0100 Subject: [PATCH 10/10] Project name must match PyPi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6025c610..2192448b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=77", "setuptools-scm>=8", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "customerio_cdp_analytics" +name = "customerio-cdp-analytics" dynamic = ["version"] # Version is derived from git tags via setuptools-scm (e.g. tag v3.0.0 → version 3.0.0) description = "Customer.io Data Pipelines (CDP) Python bindings."