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/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..facbe377 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,5 @@ +[bumpversion] +current_version = 1.0.0 +parse = (?P\d+)\.(?P\d+)\.(?P\d+) +serialize = + {major}.{minor}.{patch} 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/.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 index 7ef997ab..0e28f6a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,11 @@ version: 2 updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 - reviewers: - - heitorsampaio + - 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 new file mode 100644 index 00000000..05828703 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,35 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: Lint + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - 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 lint + run: make lint-ci 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/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..b594b8da --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test + +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.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index b856542a..ccadd9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ build .vscode/ .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/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..d800d4b8 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,28 @@ +PYTHON ?= python3 + install: - pip install --edit .[test] + $(PYTHON) -m pip install -e ".[dev]" + +build: + rm -rf build + $(PYTHON) -m build test: - pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out - flake8 --max-complexity=10 --statistics analytics > flake8.out || true + $(PYTHON) -m unittest customerio/analytics/test/*.py -v + +lint: + $(PYTHON) -m pylint --rcfile=.pylintrc --reports=y --exit-zero customerio/analytics + $(PYTHON) -m flake8 --max-complexity=10 --statistics --exit-zero customerio/analytics + +lint-ci: + $(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 -release: - python setup.py sdist bdist_wheel - twine upload dist/* +clean: + rm -rf MANIFEST build dist customerio.egg-info -e2e_test: - .buildscripts/e2e.sh +clean-venv: + rm -rf .venv + mise deps -.PHONY: test release e2e_test +.PHONY: install build test lint lint-ci clean clean-venv diff --git a/README.md b/README.md index 69cefe7c..c82790fe 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,45 @@ -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-cdp-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 +from customerio import analytics analytics.write_key = 'YOUR_WRITE_KEY' + +analytics.track(user_id=4, event='order_complete') ``` -**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! +## Other Regions -## Documentation +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: -Documentation is available at [https://segment.com/libraries/python](https://segment.com/libraries/python). +```python +from customerio import analytics -## License +analytics.write_key = 'YOUR_WRITE_KEY' +analytics.host = 'https://cdp-eu.customer.io' +analytics.track(user_id=4, event='order_complete') ``` -WWWWWW||WWWWWW - W W W||W W W - || - ( OO )__________ - / | \ - /o o| MIT \ - \___/||_||__||_|| * - || || || || - _||_|| _||_|| - (__|__|(__|__| -``` - -(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. +## Documentation -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. +The links below contain more detailed documentation on how to use this library: -[![forthebadge](https://forthebadge.com/images/badges/built-with-love.svg)](https://forthebadge.com) +* [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/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 deleted file mode 100644 index 33b9c26c..00000000 --- a/analytics/consumer.py +++ /dev/null @@ -1,133 +0,0 @@ -import logging -from threading import Thread -import json -import monotonic -import backoff - - -from 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('segment') - - 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 fcfbc0eb..00000000 --- a/analytics/test/client.py +++ /dev/null @@ -1,342 +0,0 @@ -from datetime import date, datetime -import unittest -import time -import mock - -from analytics.version import VERSION -from 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/__init__.py b/customerio/__init__.py new file mode 100644 index 00000000..e69de29b 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 95% rename from segment/analytics/client.py rename to customerio/analytics/client.py index 515da899..c5387fb3 100644 --- a/segment/analytics/client.py +++ b/customerio/analytics/client.py @@ -1,18 +1,17 @@ -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 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 - -import queue +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 ID_TYPES = (numbers.Number, str) @@ -34,8 +33,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, @@ -163,7 +161,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) @@ -271,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/segment/analytics/consumer.py b/customerio/analytics/consumer.py similarity index 96% rename from segment/analytics/consumer.py rename to customerio/analytics/consumer.py index 27586284..8f478b54 100644 --- a/segment/analytics/consumer.py +++ b/customerio/analytics/consumer.py @@ -1,12 +1,12 @@ +import json import logging +from queue import Empty from threading import Thread -import monotonic -import backoff -import json -from segment.analytics.request import post, APIError, DatetimeSerializer +import backoff +import monotonic -from queue import Empty +from customerio.analytics.request import post, APIError, DatetimeSerializer MAX_MSG_SIZE = 32 << 10 @@ -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, @@ -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/segment/analytics/request.py b/customerio/analytics/request.py similarity index 84% rename from segment/analytics/request.py rename to customerio/analytics/request.py index d1901f79..5b0eb99a 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) @@ -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): @@ -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 94% rename from segment/analytics/test/__init__.py rename to customerio/analytics/test/__init__.py index 09bf9b63..d936c6d3 100644 --- a/segment/analytics/test/__init__.py +++ b/customerio/analytics/test/__init__.py @@ -2,14 +2,14 @@ import pkgutil import logging import sys -import analytics -from analytics.client import Client +from customerio.analytics.client import Client +from customerio import analytics def all_names(): for _, modname, _ in pkgutil.iter_modules(__path__): - yield 'analytics.test.' + modname + yield 'customerio.analytics.test.' + modname def all(): diff --git a/segment/analytics/test/client.py b/customerio/analytics/test/client.py similarity index 95% rename from segment/analytics/test/client.py rename to customerio/analytics/test/client.py index f01dc2ce..5fc58350 100644 --- a/segment/analytics/test/client.py +++ b/customerio/analytics/test/client.py @@ -1,10 +1,10 @@ from datetime import date, datetime import unittest +import unittest.mock as mock 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): @@ -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/segment/analytics/test/consumer.py b/customerio/analytics/test/consumer.py similarity index 94% rename from segment/analytics/test/consumer.py rename to customerio/analytics/test/consumer.py index 16d0b213..d10ded2e 100644 --- a/segment/analytics/test/consumer.py +++ b/customerio/analytics/test/consumer.py @@ -1,15 +1,15 @@ -import unittest -import mock -import time import json +import time +import unittest +import unittest.mock as mock try: from queue import Queue except ImportError: from Queue import Queue -from analytics.consumer import Consumer, MAX_MSG_SIZE -from analytics.request import APIError +from customerio.analytics.consumer import Consumer, MAX_MSG_SIZE +from customerio.analytics.request import APIError class TestConsumer(unittest.TestCase): @@ -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/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 86% rename from segment/analytics/test/utils.py rename to customerio/analytics/test/utils.py index 6995e799..73907ce5 100644 --- a/segment/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): @@ -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..926cb3d0 --- /dev/null +++ b/customerio/analytics/version.py @@ -0,0 +1,3 @@ +from importlib.metadata import version + +VERSION = version("customerio_cdp_analytics") 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/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2192448b --- /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 new file mode 100644 index 00000000..6a9d363f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +backoff==2.2.1 +flake8==3.7.9 +monotonic==1.6 +pylint==3.3.3 +python-dateutil==2.8.2 +requests>=2.32.4 \ No newline at end of file diff --git a/segment/analytics/test/module.py b/segment/analytics/test/module.py deleted file mode 100644 index 3901b1c7..00000000 --- a/segment/analytics/test/module.py +++ /dev/null @@ -1,49 +0,0 @@ -import unittest - -import analytics - - -class TestModule(unittest.TestCase): - - # def failed(self): - # self.failed = True - - 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/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..df789d21 100644 --- a/setup.py +++ b/setup.py @@ -1,64 +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 analytics-python module here, since deps may not be installed -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'segment','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. - -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 -''' - -install_requires = [ - "requests~=2.7", - "monotonic~=1.5", - "backoff~=2.1", - "python-dateutil~=2.2" -] - -tests_require = [ - "mock==2.0.0", - "pylint==2.8.0", - "flake8==3.7.9", -] - -setup( - name='segment-analytics-python', - version=VERSION, - url='https://github.com/segmentio/analytics-python', - author='Segment', - author_email='friends@segment.com', - maintainer='Segment', - maintainer_email='friends@segment.com', - test_suite='analytics.test.all', - packages=['segment.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.', - 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.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) +setup(packages=find_packages(include=["customerio", "customerio.*"])) 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)