├── .github └── workflows │ └── test.yml ├── .gitignore ├── BUILD.rst ├── CHANGES.txt ├── LICENSE.txt ├── README.rst ├── demo ├── post_an_event.py └── subprocess_consumer.py ├── docs ├── _static │ └── mixpanel.css ├── conf.py └── index.rst ├── mixpanel └── __init__.py ├── requirements-testing.txt ├── setup.cfg ├── setup.py ├── test_mixpanel.py └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | strategy: 9 | matrix: 10 | python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install -e . 22 | pip install -r requirements-testing.txt 23 | - name: Test with pytest 24 | run: | 25 | pytest test_mixpanel.py 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.egg-info 3 | .tox 4 | build 5 | dist 6 | docs/_build 7 | .idea/ 8 | .cache/ 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /BUILD.rst: -------------------------------------------------------------------------------- 1 | Release process:: 2 | 3 | 1. Document all changes in CHANGES.rst. 4 | 2. Update __version__ in __init__.py. 5 | 3. Update version in docs/conf.py 6 | 4. Tag the version in git. (ex: git tag 4.8.2 && git push --tags) 7 | 5. Create a release in GitHub. https://github.com/mixpanel/mixpanel-python/releases 8 | 6. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) 9 | 7. Publish to PyPI. (see below) 10 | 11 | Run tests:: 12 | 13 | tox 14 | 15 | Publish to PyPI:: 16 | 17 | pip install twine wheel 18 | python setup.py sdist bdist_wheel 19 | twine upload dist/* 20 | 21 | Build docs:: 22 | 23 | pip install sphinx 24 | python setup.py build_sphinx 25 | 26 | Publish docs to GitHub Pages:: 27 | 28 | pip install ghp-import 29 | ghp-import -n -p build/sphinx/html 30 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v4.9.0 2 | * To reduce TLS cert friction, use requests rather than directly using urllib3. 3 | Reinstate TLS cert validation by default. (#103) 4 | * Drop support for Python 3.4 in setup.py and testing matrix. 5 | * Update readme references to mixpanel-utils project. (#100) 6 | 7 | v4.8.4 8 | * Disable urllib3 security warning only if not verifying server certs. (#102) 9 | 10 | v4.8.3 11 | * Do not verify server cert by default. (issue #97) 12 | 13 | v4.8.2 14 | Bugfix release: 15 | * Fix DeprecationWarning in urllib3 when using older argument name. (issue #93) 16 | * Fix creation of urllib3.PoolManager under Python 2 with unicode_literals. (issue #94 - thanks, Hugo Arregui!) 17 | 18 | v4.8.1 19 | A compatibility bugfix -- 4.8.0 broke subclassing compatibility with some 20 | other libraries. 21 | 22 | v4.8.0 23 | * Add api_secret parameter to import_data and merge methods. API secret is the 24 | new preferred auth mechanism; the old API Key still works but is no longer 25 | accessible in the Mixpanel settings UI. (ref: issues #85, #88) 26 | * Add optional verify_cert param to Consumer.__init__ for those having trouble 27 | with server cert validation. (ref: issue #86) 28 | 29 | v4.7.0 30 | * Form $insert_id for track and import calls (if not present) to enable server-side event deduplication. 31 | * Retry API calls upon connection or HTTP 5xx errors. Added new retry options to Consumer classes. 32 | * Replaced urllib2-based HTTP calls with urllib3. This allows connection pooling as well at the aforementioned retries. 33 | * Stop base64 encoding payloads, as Mixpanel APIs now support naked JSON. 34 | * Bug: $time in people operations should be sent in seconds, not milliseconds. 35 | 36 | v4.6.0 37 | * Add `$merge` support. 38 | * Support for overriding API host for, say, making calls to EU APIs. 39 | * Updates to `$alias` documentation. 40 | 41 | v4.5.0 42 | * Add Mixpanel Groups API functionality. 43 | 44 | v4.4.0 45 | * Add `people_remove`. 46 | 47 | v4.3.2 48 | * Fix bug preventing use of `import_data` with a `BufferedConsumer`. 49 | 50 | v4.3.0 51 | * Catch URLError when tracking data. 52 | 53 | v4.2.0 54 | * Add support for customizing JSON serialization. 55 | 56 | v4.1.0 57 | * Add support for Python 3. 58 | * Rename mixpanel.VERSION to mixpanel.__version__. 59 | * Move from `mixpanel-py` to `mixpanel` on PyPI. 60 | * Fix exception handling in `BufferedConsumer`. 61 | * Fix `people_track_charge` calls without properties. 62 | 63 | v4.0.2 64 | * Fix packaging. 65 | 66 | v4.0.1 67 | * Fix mutable default arguments. 68 | * Allow serialization of datetime instances. 69 | 70 | v4.0.0 71 | * Add an optional `request_timeout` to `BufferedConsumer`. 72 | 73 | v3.1.3 74 | * All calls to alias() now run a synchronous request to Mixpanel's 75 | servers on every call. 76 | 77 | v3.0.0 78 | * BACKWARD BREAKING CHANGE: Ignore source IP in events by default 79 | * Allow users to provide "envelope" or "meta" tracking information 80 | 81 | v2.0.1 82 | * New build 83 | * Fix people analytics endpoint 84 | 85 | v2.0.0 86 | * MANY BACKWARD COMPATIBILITY-BREAKING CHANGES. 87 | * Name change- module is now named 'mixpanel' 88 | * Introduction of pluggable Consumers 89 | * Change to Mixpanel constructor- no longer takes a base url 90 | * Addition of sample scripts in demo/ distribution directory 91 | * rename of Mixpanel.track_charge to Mixpanel.people_track_charge 92 | * New test suite 93 | 94 | v1.0.0 95 | * Initial release 96 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2013-2021 Mixpanel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | mixpanel-python 2 | ============================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/mixpanel 5 | :target: https://pypi.org/project/mixpanel 6 | :alt: PyPI 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/mixpanel 9 | :target: https://pypi.org/project/mixpanel 10 | :alt: PyPI - Python Version 11 | 12 | .. image:: https://img.shields.io/pypi/dm/mixpanel 13 | :target: https://pypi.org/project/mixpanel 14 | :alt: PyPI - Downloads 15 | 16 | .. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg 17 | 18 | This is the official Mixpanel Python library. This library allows for 19 | server-side integration of Mixpanel. 20 | 21 | To import, export, transform, or delete your Mixpanel data, please see our 22 | `mixpanel-utils package`_. 23 | 24 | 25 | Installation 26 | ------------ 27 | 28 | The library can be installed using pip:: 29 | 30 | pip install mixpanel 31 | 32 | 33 | Getting Started 34 | --------------- 35 | 36 | Typical usage usually looks like this:: 37 | 38 | from mixpanel import Mixpanel 39 | 40 | mp = Mixpanel(YOUR_TOKEN) 41 | 42 | # tracks an event with certain properties 43 | mp.track(DISTINCT_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) 44 | 45 | # sends an update to a user profile 46 | mp.people_set(DISTINCT_ID, {'$first_name' : 'Ilya', 'favorite pizza': 'margherita'}) 47 | 48 | You can use an instance of the Mixpanel class for sending all of your events 49 | and people updates. 50 | 51 | 52 | Additional Information 53 | ---------------------- 54 | 55 | * `Help Docs`_ 56 | * `Full Documentation`_ 57 | * mixpanel-python-async_; a third party tool for sending data asynchronously 58 | from the tracking python process. 59 | 60 | 61 | .. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master 62 | :target: https://travis-ci.org/mixpanel/mixpanel-python 63 | .. _mixpanel-utils package: https://github.com/mixpanel/mixpanel-utils 64 | .. _Help Docs: https://www.mixpanel.com/help/reference/python 65 | .. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ 66 | .. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async 67 | -------------------------------------------------------------------------------- /demo/post_an_event.py: -------------------------------------------------------------------------------- 1 | from mixpanel import Mixpanel 2 | 3 | def post_event(token): 4 | mixpanel = Mixpanel(token) 5 | mixpanel.track('ID', 'Script run') 6 | 7 | if __name__ == '__main__': 8 | # You'll want to change this to be the token 9 | # from your Mixpanel project. You can find your 10 | # project token in the project settings dialog 11 | # of the Mixpanel web application 12 | demo_token = '0ba349286c780fe53d8b4617d90e2d01' 13 | post_event(demo_token) 14 | -------------------------------------------------------------------------------- /demo/subprocess_consumer.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import random 3 | 4 | from mixpanel import Mixpanel, BufferedConsumer 5 | 6 | ''' 7 | As your application scales, it's likely you'll want to 8 | to detect events in one place and send them somewhere 9 | else. For example, you might write the events to a queue 10 | to be consumed by another process. 11 | 12 | This demo shows how you might do things, using 13 | a custom Consumer to consume events, and a 14 | and a BufferedConsumer to send them to Mixpanel 15 | ''' 16 | 17 | ''' 18 | You can provide custom communication behaviors 19 | by providing your own consumer object to the 20 | Mixpanel constructor. Consumers are expected to 21 | have a single method, 'send', that takes an 22 | endpoint and a json message. 23 | ''' 24 | class QueueWriteConsumer(object): 25 | def __init__(self, queue): 26 | self.queue = queue 27 | 28 | def send(self, endpoint, json_message): 29 | self.queue.put((endpoint, json_message)) 30 | 31 | def do_tracking(project_token, distinct_id, queue): 32 | ''' 33 | This process represents the work process where events 34 | and updates are generated. This might be the service 35 | thread of a web service, or some other process that 36 | is mostly concerned with getting time-sensitive work 37 | done. 38 | ''' 39 | consumer = QueueWriteConsumer(queue) 40 | mp = Mixpanel(project_token, consumer) 41 | for i in xrange(100): 42 | event = 'Tick' 43 | mp.track(distinct_id, event, {'Tick Number': i}) 44 | print 'tick {0}'.format(i) 45 | 46 | queue.put(None) # tell worker we're out of jobs 47 | 48 | def do_sending(queue): 49 | ''' 50 | This process is the analytics worker process- it can 51 | wait on HTTP responses to Mixpanel without blocking 52 | other jobs. This might be a queue consumer process 53 | or just a separate thread from the code that observes 54 | the things you want to measure. 55 | ''' 56 | consumer = BufferedConsumer() 57 | payload = queue.get() 58 | while payload is not None: 59 | consumer.send(*payload) 60 | payload = queue.get() 61 | 62 | consumer.flush() 63 | 64 | if __name__ == '__main__': 65 | # replace token with your real project token 66 | token = '0ba349286c780fe53d8b4617d90e2d01' 67 | distinct_id = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for x in xrange(32)) 68 | 69 | queue = multiprocessing.Queue() 70 | sender = multiprocessing.Process(target=do_sending, args=(queue,)) 71 | tracker = multiprocessing.Process(target=do_tracking, args=(token, distinct_id, queue)) 72 | 73 | sender.start() 74 | tracker.start() 75 | tracker.join() 76 | sender.join() 77 | -------------------------------------------------------------------------------- /docs/_static/mixpanel.css: -------------------------------------------------------------------------------- 1 | @import 'alabaster.css'; 2 | 3 | div.sphinxsidebar h3 { 4 | margin-top: 1em; 5 | } 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | 5 | # If extensions (or modules to document with autodoc) are in another directory, 6 | # add these directories to sys.path here. If the directory is relative to the 7 | # documentation root, use os.path.abspath to make it absolute, like shown here. 8 | sys.path.insert(0, os.path.abspath('..')) 9 | 10 | extensions = [ 11 | 'sphinx.ext.autodoc', 12 | ] 13 | autodoc_member_order = 'bysource' 14 | 15 | templates_path = ['_templates'] 16 | source_suffix = '.rst' 17 | master_doc = 'index' 18 | 19 | # General information about the project. 20 | project = u'mixpanel' 21 | copyright = u' 2021, Mixpanel, Inc.' 22 | author = u'Mixpanel ' 23 | version = release = '4.10.1' 24 | exclude_patterns = ['_build'] 25 | pygments_style = 'sphinx' 26 | 27 | 28 | # -- Options for HTML output ---------------------------------------------- 29 | 30 | # The theme to use for HTML and HTML Help pages. See the documentation for 31 | # a list of builtin themes. 32 | html_theme = 'alabaster' 33 | html_theme_options = { 34 | 'description': 'The official Mixpanel client library for Python.', 35 | 'github_user': 'mixpanel', 36 | 'github_repo': 'mixpanel-python', 37 | 'github_button': False, 38 | 'travis_button': True, 39 | } 40 | 41 | # Custom sidebar templates, maps document names to template names. 42 | html_sidebars = { 43 | '**': [ 44 | 'about.html', 'localtoc.html', 'searchbox.html', 45 | ] 46 | } 47 | 48 | # Add any paths that contain custom static files (such as style sheets) here, 49 | # relative to this directory. They are copied after the builtin static files, 50 | # so a file named "default.css" will overwrite the builtin "default.css". 51 | html_static_path = ['_static'] 52 | html_style = 'mixpanel.css' 53 | 54 | # Add any extra paths that contain custom files (such as robots.txt or 55 | # .htaccess) here, relative to this directory. These files are copied 56 | # directly to the root of the documentation. 57 | # html_extra_path = [] 58 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Mixpanel 2 | =================== 3 | 4 | .. automodule:: mixpanel 5 | 6 | 7 | Primary interface 8 | ----------------- 9 | 10 | .. autoclass:: Mixpanel 11 | :members: 12 | 13 | 14 | Built-in consumers 15 | ------------------ 16 | 17 | A consumer is any object with a ``send`` method which takes two arguments: a 18 | string ``endpoint`` name and a JSON-encoded ``message``. ``send`` is 19 | responsible for appropriately encoding the message and sending it to the named 20 | `Mixpanel API`_ endpoint. 21 | 22 | :class:`~.Mixpanel` instances call their consumer's ``send`` method at the end 23 | of each of their own method calls, after building the JSON message. 24 | 25 | .. _`Mixpanel API`: https://mixpanel.com/help/reference/http 26 | 27 | 28 | .. autoclass:: Consumer 29 | :members: 30 | 31 | .. autoclass:: BufferedConsumer 32 | :members: 33 | 34 | 35 | Exceptions 36 | ---------- 37 | 38 | .. autoexception:: MixpanelException 39 | -------------------------------------------------------------------------------- /mixpanel/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This is the official Mixpanel client library for Python. 3 | 4 | Mixpanel client libraries allow for tracking events and setting properties on 5 | People Analytics profiles from your server-side projects. This is the API 6 | documentation; you may also be interested in the higher-level `usage 7 | documentation`_. If your users are interacting with your application via the 8 | web, you may also be interested in our `JavaScript library`_. 9 | 10 | .. _`JavaScript library`: https://developer.mixpanel.com/docs/javascript 11 | .. _`usage documentation`: https://developer.mixpanel.com/docs/python 12 | 13 | :class:`~.Mixpanel` is the primary class for tracking events and sending People 14 | Analytics updates. :class:`~.Consumer` and :class:`~.BufferedConsumer` allow 15 | callers to customize the IO characteristics of their tracking. 16 | """ 17 | from __future__ import absolute_import, unicode_literals 18 | import datetime 19 | import json 20 | import logging 21 | import time 22 | import uuid 23 | 24 | import requests 25 | from requests.auth import HTTPBasicAuth 26 | import six 27 | from six.moves import range 28 | import urllib3 29 | 30 | __version__ = '4.10.1' 31 | VERSION = __version__ # TODO: remove when bumping major version. 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | class DatetimeSerializer(json.JSONEncoder): 37 | def default(self, obj): 38 | if isinstance(obj, datetime.datetime): 39 | fmt = '%Y-%m-%dT%H:%M:%S' 40 | return obj.strftime(fmt) 41 | 42 | return json.JSONEncoder.default(self, obj) 43 | 44 | 45 | def json_dumps(data, cls=None): 46 | # Separators are specified to eliminate whitespace. 47 | return json.dumps(data, separators=(',', ':'), cls=cls) 48 | 49 | 50 | class Mixpanel(object): 51 | """Instances of Mixpanel are used for all events and profile updates. 52 | 53 | :param str token: your project's Mixpanel token 54 | :param consumer: can be used to alter the behavior of tracking (default 55 | :class:`~.Consumer`) 56 | :param json.JSONEncoder serializer: a JSONEncoder subclass used to handle 57 | JSON serialization (default :class:`~.DatetimeSerializer`) 58 | 59 | See `Built-in consumers`_ for details about the consumer interface. 60 | 61 | .. versionadded:: 4.2.0 62 | The *serializer* parameter. 63 | """ 64 | 65 | def __init__(self, token, consumer=None, serializer=DatetimeSerializer): 66 | self._token = token 67 | self._consumer = consumer or Consumer() 68 | self._serializer = serializer 69 | 70 | def _now(self): 71 | return time.time() 72 | 73 | def _make_insert_id(self): 74 | return uuid.uuid4().hex 75 | 76 | def track(self, distinct_id, event_name, properties=None, meta=None): 77 | """Record an event. 78 | 79 | :param str distinct_id: identifies the user triggering the event 80 | :param str event_name: a name describing the event 81 | :param dict properties: additional data to record; keys should be 82 | strings, and values should be strings, numbers, or booleans 83 | :param dict meta: overrides Mixpanel special properties 84 | 85 | ``properties`` should describe the circumstances of the event, or 86 | aspects of the source or user associated with it. ``meta`` is used 87 | (rarely) to override special values sent in the event object. 88 | """ 89 | all_properties = { 90 | 'token': self._token, 91 | 'distinct_id': distinct_id, 92 | 'time': self._now(), 93 | '$insert_id': self._make_insert_id(), 94 | 'mp_lib': 'python', 95 | '$lib_version': __version__, 96 | } 97 | if properties: 98 | all_properties.update(properties) 99 | event = { 100 | 'event': event_name, 101 | 'properties': all_properties, 102 | } 103 | if meta: 104 | event.update(meta) 105 | self._consumer.send('events', json_dumps(event, cls=self._serializer)) 106 | 107 | def import_data(self, api_key, distinct_id, event_name, timestamp, 108 | properties=None, meta=None, api_secret=None): 109 | """Record an event that occurred more than 5 days in the past. 110 | 111 | :param str api_key: (DEPRECATED) your Mixpanel project's API key 112 | :param str distinct_id: identifies the user triggering the event 113 | :param str event_name: a name describing the event 114 | :param int timestamp: UTC seconds since epoch 115 | :param dict properties: additional data to record; keys should be 116 | strings, and values should be strings, numbers, or booleans 117 | :param dict meta: overrides Mixpanel special properties 118 | :param str api_secret: Your Mixpanel project's API secret. 119 | 120 | .. Important:: 121 | Mixpanel's ``import`` HTTP endpoint requires the project API 122 | secret found in your Mixpanel project's settings. The older API key is 123 | no longer accessible in the Mixpanel UI, but will continue to work. 124 | The api_key parameter will be removed in an upcoming release of 125 | mixpanel-python. 126 | 127 | .. versionadded:: 4.8.0 128 | The *api_secret* parameter. 129 | 130 | To avoid accidentally recording invalid events, the Mixpanel API's 131 | ``track`` endpoint disallows events that occurred too long ago. This 132 | method can be used to import such events. See our online documentation 133 | for `more details 134 | `__. 135 | """ 136 | 137 | if api_secret is None: 138 | logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") 139 | 140 | all_properties = { 141 | 'token': self._token, 142 | 'distinct_id': distinct_id, 143 | 'time': timestamp, 144 | '$insert_id': self._make_insert_id(), 145 | 'mp_lib': 'python', 146 | '$lib_version': __version__, 147 | } 148 | if properties: 149 | all_properties.update(properties) 150 | event = { 151 | 'event': event_name, 152 | 'properties': all_properties, 153 | } 154 | if meta: 155 | event.update(meta) 156 | 157 | self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) 158 | 159 | def alias(self, alias_id, original, meta=None): 160 | """Creates an alias which Mixpanel will use to remap one id to another. 161 | 162 | :param str alias_id: A distinct_id to be merged with the original 163 | distinct_id. Each alias can only map to one distinct_id. 164 | :param str original: A distinct_id to be merged with alias_id. 165 | :param dict meta: overrides Mixpanel special properties 166 | 167 | Immediately creates a one-way mapping between two ``distinct_ids``. 168 | Events triggered by the new id will be associated with the existing 169 | user's profile and behavior. See our online documentation for `more 170 | details 171 | `__. 172 | 173 | .. note:: 174 | Calling this method *always* results in a synchronous HTTP request 175 | to Mixpanel servers, regardless of any custom consumer. 176 | """ 177 | event = { 178 | 'event': '$create_alias', 179 | 'properties': { 180 | 'distinct_id': original, 181 | 'alias': alias_id, 182 | 'token': self._token, 183 | }, 184 | } 185 | if meta: 186 | event.update(meta) 187 | 188 | sync_consumer = Consumer() 189 | sync_consumer.send('events', json_dumps(event, cls=self._serializer)) 190 | 191 | def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): 192 | """ 193 | Merges the two given distinct_ids. 194 | 195 | :param str api_key: (DEPRECATED) Your Mixpanel project's API key. 196 | :param str distinct_id1: The first distinct_id to merge. 197 | :param str distinct_id2: The second (other) distinct_id to merge. 198 | :param dict meta: overrides Mixpanel special properties 199 | :param str api_secret: Your Mixpanel project's API secret. 200 | 201 | .. Important:: 202 | Mixpanel's ``merge`` HTTP endpoint requires the project API 203 | secret found in your Mixpanel project's settings. The older API key is 204 | no longer accessible in the Mixpanel UI, but will continue to work. 205 | The api_key parameter will be removed in an upcoming release of 206 | mixpanel-python. 207 | 208 | .. versionadded:: 4.8.0 209 | The *api_secret* parameter. 210 | 211 | See our online documentation for `more 212 | details 213 | `__. 214 | """ 215 | if api_secret is None: 216 | logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") 217 | 218 | event = { 219 | 'event': '$merge', 220 | 'properties': { 221 | '$distinct_ids': [distinct_id1, distinct_id2], 222 | 'token': self._token, 223 | }, 224 | } 225 | if meta: 226 | event.update(meta) 227 | self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) 228 | 229 | def people_set(self, distinct_id, properties, meta=None): 230 | """Set properties of a people record. 231 | 232 | :param str distinct_id: the profile to update 233 | :param dict properties: properties to set 234 | :param dict meta: overrides Mixpanel special properties 235 | 236 | If the profile does not exist, creates a new profile with these properties. 237 | """ 238 | return self.people_update({ 239 | '$distinct_id': distinct_id, 240 | '$set': properties, 241 | }, meta=meta or {}) 242 | 243 | def people_set_once(self, distinct_id, properties, meta=None): 244 | """Set properties of a people record if they are not already set. 245 | 246 | :param str distinct_id: the profile to update 247 | :param dict properties: properties to set 248 | 249 | Any properties that already exist on the profile will not be 250 | overwritten. If the profile does not exist, creates a new profile with 251 | these properties. 252 | """ 253 | return self.people_update({ 254 | '$distinct_id': distinct_id, 255 | '$set_once': properties, 256 | }, meta=meta or {}) 257 | 258 | def people_increment(self, distinct_id, properties, meta=None): 259 | """Increment/decrement numerical properties of a people record. 260 | 261 | :param str distinct_id: the profile to update 262 | :param dict properties: properties to increment/decrement; values 263 | should be numeric 264 | 265 | Adds numerical values to properties of a people record. Nonexistent 266 | properties on the record default to zero. Negative values in 267 | ``properties`` will decrement the given property. 268 | """ 269 | return self.people_update({ 270 | '$distinct_id': distinct_id, 271 | '$add': properties, 272 | }, meta=meta or {}) 273 | 274 | def people_append(self, distinct_id, properties, meta=None): 275 | """Append to the list associated with a property. 276 | 277 | :param str distinct_id: the profile to update 278 | :param dict properties: properties to append 279 | 280 | Adds items to list-style properties of a people record. Appending to 281 | nonexistent properties results in a list with a single element. For 282 | example:: 283 | 284 | mp.people_append('123', {'Items': 'Super Arm'}) 285 | """ 286 | return self.people_update({ 287 | '$distinct_id': distinct_id, 288 | '$append': properties, 289 | }, meta=meta or {}) 290 | 291 | def people_union(self, distinct_id, properties, meta=None): 292 | """Merge the values of a list associated with a property. 293 | 294 | :param str distinct_id: the profile to update 295 | :param dict properties: properties to merge 296 | 297 | Merges list values in ``properties`` with existing list-style 298 | properties of a people record. Duplicate values are ignored. For 299 | example:: 300 | 301 | mp.people_union('123', {'Items': ['Super Arm', 'Fire Storm']}) 302 | """ 303 | return self.people_update({ 304 | '$distinct_id': distinct_id, 305 | '$union': properties, 306 | }, meta=meta or {}) 307 | 308 | def people_unset(self, distinct_id, properties, meta=None): 309 | """Permanently remove properties from a people record. 310 | 311 | :param str distinct_id: the profile to update 312 | :param list properties: property names to remove 313 | """ 314 | return self.people_update({ 315 | '$distinct_id': distinct_id, 316 | '$unset': properties, 317 | }, meta=meta) 318 | 319 | def people_remove(self, distinct_id, properties, meta=None): 320 | """Permanently remove a value from the list associated with a property. 321 | 322 | :param str distinct_id: the profile to update 323 | :param dict properties: properties to remove 324 | 325 | Removes items from list-style properties of a people record. 326 | For example:: 327 | 328 | mp.people_remove('123', {'Items': 'Super Arm'}) 329 | """ 330 | return self.people_update({ 331 | '$distinct_id': distinct_id, 332 | '$remove': properties, 333 | }, meta=meta or {}) 334 | 335 | def people_delete(self, distinct_id, meta=None): 336 | """Permanently delete a people record. 337 | 338 | :param str distinct_id: the profile to delete 339 | """ 340 | return self.people_update({ 341 | '$distinct_id': distinct_id, 342 | '$delete': "", 343 | }, meta=meta or None) 344 | 345 | def people_track_charge(self, distinct_id, amount, 346 | properties=None, meta=None): 347 | """Track a charge on a people record. 348 | 349 | :param str distinct_id: the profile with which to associate the charge 350 | :param numeric amount: number of dollars charged 351 | :param dict properties: extra properties related to the transaction 352 | 353 | Record that you have charged the current user a certain amount of 354 | money. Charges recorded with this way will appear in the Mixpanel 355 | revenue report. 356 | """ 357 | if properties is None: 358 | properties = {} 359 | properties.update({'$amount': amount}) 360 | return self.people_append( 361 | distinct_id, {'$transactions': properties or {}}, meta=meta or {} 362 | ) 363 | 364 | def people_clear_charges(self, distinct_id, meta=None): 365 | """Permanently clear all charges on a people record. 366 | 367 | :param str distinct_id: the profile whose charges will be cleared 368 | """ 369 | return self.people_unset( 370 | distinct_id, ["$transactions"], meta=meta or {}, 371 | ) 372 | 373 | def people_update(self, message, meta=None): 374 | """Send a generic update to Mixpanel people analytics. 375 | 376 | :param dict message: the message to send 377 | 378 | Callers are responsible for formatting the update message as described 379 | in the `user profiles documentation`_. This method may be useful if you 380 | want to use very new or experimental features of people analytics, but 381 | please use the other ``people_*`` methods where possible. 382 | 383 | .. _`user profiles documentation`: https://developer.mixpanel.com/reference/user-profiles 384 | """ 385 | record = { 386 | '$token': self._token, 387 | '$time': self._now(), 388 | } 389 | record.update(message) 390 | if meta: 391 | record.update(meta) 392 | self._consumer.send('people', json_dumps(record, cls=self._serializer)) 393 | 394 | def group_set(self, group_key, group_id, properties, meta=None): 395 | """Set properties of a group profile. 396 | 397 | :param str group_key: the group key, e.g. 'company' 398 | :param str group_id: the group to update 399 | :param dict properties: properties to set 400 | :param dict meta: overrides Mixpanel special properties. (See also `Mixpanel.people_set`.) 401 | 402 | If the profile does not exist, creates a new profile with these properties. 403 | """ 404 | return self.group_update({ 405 | '$group_key': group_key, 406 | '$group_id': group_id, 407 | '$set': properties, 408 | }, meta=meta or {}) 409 | 410 | def group_set_once(self, group_key, group_id, properties, meta=None): 411 | """Set properties of a group profile if they are not already set. 412 | 413 | :param str group_key: the group key, e.g. 'company' 414 | :param str group_id: the group to update 415 | :param dict properties: properties to set 416 | 417 | Any properties that already exist on the profile will not be 418 | overwritten. If the profile does not exist, creates a new profile with 419 | these properties. 420 | """ 421 | return self.group_update({ 422 | '$group_key': group_key, 423 | '$group_id': group_id, 424 | '$set_once': properties, 425 | }, meta=meta or {}) 426 | 427 | def group_union(self, group_key, group_id, properties, meta=None): 428 | """Merge the values of a list associated with a property. 429 | 430 | :param str group_key: the group key, e.g. 'company' 431 | :param str group_id: the group to update 432 | :param dict properties: properties to merge 433 | 434 | Merges list values in ``properties`` with existing list-style 435 | properties of a group profile. Duplicate values are ignored. For 436 | example:: 437 | 438 | mp.group_union('company', 'Acme Inc.', {'Items': ['Super Arm', 'Fire Storm']}) 439 | """ 440 | return self.group_update({ 441 | '$group_key': group_key, 442 | '$group_id': group_id, 443 | '$union': properties, 444 | }, meta=meta or {}) 445 | 446 | def group_unset(self, group_key, group_id, properties, meta=None): 447 | """Permanently remove properties from a group profile. 448 | 449 | :param str group_key: the group key, e.g. 'company' 450 | :param str group_id: the group to update 451 | :param list properties: property names to remove 452 | """ 453 | return self.group_update({ 454 | '$group_key': group_key, 455 | '$group_id': group_id, 456 | '$unset': properties, 457 | }, meta=meta) 458 | 459 | def group_remove(self, group_key, group_id, properties, meta=None): 460 | """Permanently remove a value from the list associated with a property. 461 | 462 | :param str group_key: the group key, e.g. 'company' 463 | :param str group_id: the group to update 464 | :param dict properties: properties to remove 465 | 466 | Removes items from list-style properties of a group profile. 467 | For example:: 468 | 469 | mp.group_remove('company', 'Acme Inc.', {'Items': 'Super Arm'}) 470 | """ 471 | return self.group_update({ 472 | '$group_key': group_key, 473 | '$group_id': group_id, 474 | '$remove': properties, 475 | }, meta=meta or {}) 476 | 477 | def group_delete(self, group_key, group_id, meta=None): 478 | """Permanently delete a group profile. 479 | 480 | :param str group_key: the group key, e.g. 'company' 481 | :param str group_id: the group to delete 482 | """ 483 | return self.group_update({ 484 | '$group_key': group_key, 485 | '$group_id': group_id, 486 | '$delete': "", 487 | }, meta=meta or None) 488 | 489 | def group_update(self, message, meta=None): 490 | """Send a generic group profile update 491 | 492 | :param dict message: the message to send 493 | 494 | Callers are responsible for formatting the update message as documented 495 | in the `group profiles documentation`_. This method may be useful if you 496 | want to use very new or experimental features, but 497 | please use the other ``group_*`` methods where possible. 498 | 499 | .. _`group profiles documentation`: https://developer.mixpanel.com/reference/group-profiles 500 | """ 501 | record = { 502 | '$token': self._token, 503 | '$time': self._now(), 504 | } 505 | record.update(message) 506 | if meta: 507 | record.update(meta) 508 | self._consumer.send('groups', json_dumps(record, cls=self._serializer)) 509 | 510 | 511 | class MixpanelException(Exception): 512 | """Raised by consumers when unable to send messages. 513 | 514 | This could be caused by a network outage or interruption, or by an invalid 515 | endpoint passed to :meth:`.Consumer.send`. 516 | """ 517 | pass 518 | 519 | 520 | class Consumer(object): 521 | """ 522 | A consumer that sends an HTTP request directly to the Mixpanel service, one 523 | per call to :meth:`~.send`. 524 | 525 | :param str events_url: override the default events API endpoint 526 | :param str people_url: override the default people API endpoint 527 | :param str import_url: override the default import API endpoint 528 | :param int request_timeout: connection timeout in seconds 529 | :param str groups_url: override the default groups API endpoint 530 | :param str api_host: the Mixpanel API domain where all requests should be 531 | issued (unless overridden by above URLs). 532 | :param int retry_limit: number of times to retry each retry in case of 533 | connection or HTTP 5xx error; 0 to fail after first attempt. 534 | :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., 535 | sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). 536 | :param bool verify_cert: whether to verify the server certificate. 537 | 538 | .. versionadded:: 4.6.0 539 | The *api_host* parameter. 540 | .. versionadded:: 4.8.0 541 | The *verify_cert* parameter. 542 | """ 543 | 544 | def __init__(self, events_url=None, people_url=None, import_url=None, 545 | request_timeout=None, groups_url=None, api_host="api.mixpanel.com", 546 | retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): 547 | # TODO: With next major version, make the above args kwarg-only, and reorder them. 548 | self._endpoints = { 549 | 'events': events_url or 'https://{}/track'.format(api_host), 550 | 'people': people_url or 'https://{}/engage'.format(api_host), 551 | 'groups': groups_url or 'https://{}/groups'.format(api_host), 552 | 'imports': import_url or 'https://{}/import'.format(api_host), 553 | } 554 | 555 | self._verify_cert = verify_cert 556 | self._request_timeout = request_timeout 557 | 558 | # Work around renamed argument in urllib3. 559 | if hasattr(urllib3.util.Retry.DEFAULT, "allowed_methods"): 560 | methods_arg = "allowed_methods" 561 | else: 562 | methods_arg = "method_whitelist" 563 | 564 | retry_args = { 565 | "total": retry_limit, 566 | "backoff_factor": retry_backoff_factor, 567 | "status_forcelist": set(range(500, 600)), 568 | methods_arg: {"POST"}, 569 | } 570 | adapter = requests.adapters.HTTPAdapter( 571 | max_retries=urllib3.Retry(**retry_args), 572 | ) 573 | 574 | self._session = requests.Session() 575 | self._session.mount('https://', adapter) 576 | 577 | def send(self, endpoint, json_message, api_key=None, api_secret=None): 578 | """Immediately record an event or a profile update. 579 | 580 | :param endpoint: the Mixpanel API endpoint appropriate for the message 581 | :type endpoint: "events" | "people" | "groups" | "imports" 582 | :param str json_message: a JSON message formatted for the endpoint 583 | :param str api_key: your Mixpanel project's API key 584 | :param str api_secret: your Mixpanel project's API secret 585 | :raises MixpanelException: if the endpoint doesn't exist, the server is 586 | unreachable, or the message cannot be processed 587 | 588 | .. versionadded:: 4.8.0 589 | The *api_secret* parameter. 590 | """ 591 | if endpoint not in self._endpoints: 592 | raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) 593 | 594 | self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) 595 | 596 | def _write_request(self, request_url, json_message, api_key=None, api_secret=None): 597 | if isinstance(api_key, tuple): 598 | # For compatibility with subclassers, allow the auth details to be 599 | # packed into the existing api_key param. 600 | api_key, api_secret = api_key 601 | 602 | params = { 603 | 'data': json_message, 604 | 'verbose': 1, 605 | 'ip': 0, 606 | } 607 | if api_key: 608 | params['api_key'] = api_key 609 | 610 | basic_auth = None 611 | if api_secret is not None: 612 | basic_auth = HTTPBasicAuth(api_secret, '') 613 | 614 | try: 615 | response = self._session.post( 616 | request_url, 617 | data=params, 618 | auth=basic_auth, 619 | timeout=self._request_timeout, 620 | verify=self._verify_cert, 621 | ) 622 | except Exception as e: 623 | six.raise_from(MixpanelException(e), e) 624 | 625 | try: 626 | response_dict = response.json() 627 | except ValueError: 628 | raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response.text)) 629 | 630 | if response_dict['status'] != 1: 631 | raise MixpanelException('Mixpanel error: {0}'.format(response_dict['error'])) 632 | 633 | return True # <- TODO: remove return val with major release. 634 | 635 | 636 | class BufferedConsumer(object): 637 | """ 638 | A consumer that maintains per-endpoint buffers of messages and then sends 639 | them in batches. This can save bandwidth and reduce the total amount of 640 | time required to post your events to Mixpanel. 641 | 642 | :param int max_size: number of :meth:`~.send` calls for a given endpoint to 643 | buffer before flushing automatically 644 | :param str events_url: override the default events API endpoint 645 | :param str people_url: override the default people API endpoint 646 | :param str import_url: override the default import API endpoint 647 | :param int request_timeout: connection timeout in seconds 648 | :param str groups_url: override the default groups API endpoint 649 | :param str api_host: the Mixpanel API domain where all requests should be 650 | issued (unless overridden by above URLs). 651 | :param int retry_limit: number of times to retry each retry in case of 652 | connection or HTTP 5xx error; 0 to fail after first attempt. 653 | :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., 654 | sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). 655 | :param bool verify_cert: whether to verify the server certificate. 656 | 657 | .. versionadded:: 4.6.0 658 | The *api_host* parameter. 659 | .. versionadded:: 4.8.0 660 | The *verify_cert* parameter. 661 | 662 | .. note:: 663 | Because :class:`~.BufferedConsumer` holds events, you need to call 664 | :meth:`~.flush` when you're sure you're done sending them—for example, 665 | just before your program exits. Calls to :meth:`~.flush` will send all 666 | remaining unsent events being held by the instance. 667 | """ 668 | def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, 669 | request_timeout=None, groups_url=None, api_host="api.mixpanel.com", 670 | retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): 671 | self._consumer = Consumer(events_url, people_url, import_url, request_timeout, 672 | groups_url, api_host, retry_limit, retry_backoff_factor, verify_cert) 673 | self._buffers = { 674 | 'events': [], 675 | 'people': [], 676 | 'groups': [], 677 | 'imports': [], 678 | } 679 | self._max_size = min(50, max_size) 680 | self._api_key = None 681 | 682 | def send(self, endpoint, json_message, api_key=None, api_secret=None): 683 | """Record an event or profile update. 684 | 685 | Internally, adds the message to a buffer, and then flushes the buffer 686 | if it has reached the configured maximum size. Note that exceptions 687 | raised may have been caused by a message buffered by an earlier call to 688 | :meth:`~.send`. 689 | 690 | :param endpoint: the Mixpanel API endpoint appropriate for the message 691 | :type endpoint: "events" | "people" | "groups" | "imports" 692 | :param str json_message: a JSON message formatted for the endpoint 693 | :param str api_key: your Mixpanel project's API key 694 | :param str api_secret: your Mixpanel project's API secret 695 | :raises MixpanelException: if the endpoint doesn't exist, the server is 696 | unreachable, or any buffered message cannot be processed 697 | 698 | .. versionadded:: 4.3.2 699 | The *api_key* parameter. 700 | """ 701 | if endpoint not in self._buffers: 702 | raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) 703 | 704 | if not isinstance(api_key, tuple): 705 | api_key = (api_key, api_secret) 706 | 707 | buf = self._buffers[endpoint] 708 | buf.append(json_message) 709 | # Fixme: Don't stick these in the instance. 710 | self._api_key = api_key 711 | self._api_secret = api_secret 712 | if len(buf) >= self._max_size: 713 | self._flush_endpoint(endpoint) 714 | 715 | def flush(self): 716 | """Immediately send all buffered messages to Mixpanel. 717 | 718 | :raises MixpanelException: if the server is unreachable or any buffered 719 | message cannot be processed 720 | """ 721 | for endpoint in self._buffers.keys(): 722 | self._flush_endpoint(endpoint) 723 | 724 | def _flush_endpoint(self, endpoint): 725 | buf = self._buffers[endpoint] 726 | 727 | while buf: 728 | batch = buf[:self._max_size] 729 | batch_json = '[{0}]'.format(','.join(batch)) 730 | try: 731 | self._consumer.send(endpoint, batch_json, api_key=self._api_key) 732 | except MixpanelException as orig_e: 733 | mp_e = MixpanelException(orig_e) 734 | mp_e.message = batch_json 735 | mp_e.endpoint = endpoint 736 | six.raise_from(mp_e, orig_e) 737 | buf = buf[self._max_size:] 738 | self._buffers[endpoint] = buf 739 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | pytest~=4.6.11 ; python_version<='3.4' 2 | pytest~=5.4.3 ; python_version>='3.5' and python_version<'3.7' 3 | pytest~=7.1.2 ; python_version>='3.7' 4 | responses~=0.13.3 5 | more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. 6 | typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | import re 4 | from setuptools import setup, find_packages 5 | 6 | def read(*paths): 7 | filename = path.join(path.abspath(path.dirname(__file__)), *paths) 8 | with open(filename, encoding='utf-8') as f: 9 | return f.read() 10 | 11 | def find_version(*paths): 12 | contents = read(*paths) 13 | match = re.search(r'^__version__ = [\'"]([^\'"]+)[\'"]', contents, re.M) 14 | if not match: 15 | raise RuntimeError('Unable to find version string.') 16 | return match.group(1) 17 | 18 | setup( 19 | name='mixpanel', 20 | version=find_version('mixpanel', '__init__.py'), 21 | description='Official Mixpanel library for Python', 22 | long_description=read('README.rst'), 23 | url='https://github.com/mixpanel/mixpanel-python', 24 | author='Mixpanel, Inc.', 25 | author_email='dev@mixpanel.com', 26 | license='Apache', 27 | python_requires='>=2.7, !=3.4.*', 28 | install_requires=[ 29 | 'six>=1.9.0', 30 | 'requests>=2.4.2', 31 | 'urllib3', 32 | ], 33 | classifiers=[ 34 | 'License :: OSI Approved :: Apache Software License', 35 | 'Operating System :: OS Independent', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.5', 40 | 'Programming Language :: Python :: 3.6', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Programming Language :: Python :: 3.10', 45 | 'Programming Language :: Python :: 3.11', 46 | 'Programming Language :: Python :: 3.12', 47 | ], 48 | keywords='mixpanel analytics', 49 | packages=find_packages(), 50 | ) 51 | -------------------------------------------------------------------------------- /test_mixpanel.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | import datetime 3 | import decimal 4 | import json 5 | import time 6 | 7 | import pytest 8 | import responses 9 | import six 10 | from six.moves import range, urllib 11 | 12 | 13 | import mixpanel 14 | 15 | 16 | class LogConsumer(object): 17 | def __init__(self): 18 | self.log = [] 19 | 20 | def send(self, endpoint, event, api_key=None, api_secret=None): 21 | entry = [endpoint, json.loads(event)] 22 | if api_key != (None, None): 23 | if api_key: 24 | entry.append(api_key) 25 | if api_secret: 26 | entry.append(api_secret) 27 | self.log.append(tuple(entry)) 28 | 29 | def clear(self): 30 | self.log = [] 31 | 32 | 33 | class TestMixpanelBase: 34 | TOKEN = '12345' 35 | 36 | def setup_method(self, method): 37 | self.consumer = LogConsumer() 38 | self.mp = mixpanel.Mixpanel(self.TOKEN, consumer=self.consumer) 39 | self.mp._now = lambda: 1000.1 40 | self.mp._make_insert_id = lambda: "abcdefg" 41 | 42 | 43 | class TestMixpanelTracking(TestMixpanelBase): 44 | 45 | def test_track(self): 46 | self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) 47 | assert self.consumer.log == [( 48 | 'events', { 49 | 'event': 'button press', 50 | 'properties': { 51 | 'token': self.TOKEN, 52 | 'size': 'big', 53 | 'color': 'blue', 54 | 'distinct_id': 'ID', 55 | 'time': self.mp._now(), 56 | '$insert_id': 'abc123', 57 | 'mp_lib': 'python', 58 | '$lib_version': mixpanel.__version__, 59 | } 60 | } 61 | )] 62 | 63 | def test_track_makes_insert_id(self): 64 | self.mp.track('ID', 'button press', {'size': 'big'}) 65 | props = self.consumer.log[0][1]["properties"] 66 | assert "$insert_id" in props 67 | assert isinstance(props["$insert_id"], six.text_type) 68 | assert len(props["$insert_id"]) > 0 69 | 70 | def test_track_empty(self): 71 | self.mp.track('person_xyz', 'login', {}) 72 | assert self.consumer.log == [( 73 | 'events', { 74 | 'event': 'login', 75 | 'properties': { 76 | 'token': self.TOKEN, 77 | 'distinct_id': 'person_xyz', 78 | 'time': self.mp._now(), 79 | '$insert_id': self.mp._make_insert_id(), 80 | 'mp_lib': 'python', 81 | '$lib_version': mixpanel.__version__, 82 | }, 83 | }, 84 | )] 85 | 86 | def test_import_data(self): 87 | timestamp = time.time() 88 | self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, 89 | {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, 90 | api_secret='MY_SECRET') 91 | assert self.consumer.log == [( 92 | 'imports', { 93 | 'event': 'button press', 94 | 'properties': { 95 | 'token': self.TOKEN, 96 | 'size': 'big', 97 | 'color': 'blue', 98 | 'distinct_id': 'ID', 99 | 'time': timestamp, 100 | '$insert_id': 'abc123', 101 | 'mp_lib': 'python', 102 | '$lib_version': mixpanel.__version__, 103 | }, 104 | }, 105 | ('MY_API_KEY', 'MY_SECRET'), 106 | )] 107 | 108 | def test_track_meta(self): 109 | self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, 110 | meta={'ip': 0}) 111 | assert self.consumer.log == [( 112 | 'events', { 113 | 'event': 'button press', 114 | 'properties': { 115 | 'token': self.TOKEN, 116 | 'size': 'big', 117 | 'color': 'blue', 118 | 'distinct_id': 'ID', 119 | 'time': self.mp._now(), 120 | '$insert_id': 'abc123', 121 | 'mp_lib': 'python', 122 | '$lib_version': mixpanel.__version__, 123 | }, 124 | 'ip': 0, 125 | } 126 | )] 127 | 128 | 129 | class TestMixpanelPeople(TestMixpanelBase): 130 | 131 | def test_people_set(self): 132 | self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) 133 | assert self.consumer.log == [( 134 | 'people', { 135 | '$time': self.mp._now(), 136 | '$token': self.TOKEN, 137 | '$distinct_id': 'amq', 138 | '$set': { 139 | 'birth month': 'october', 140 | 'favorite color': 'purple', 141 | }, 142 | } 143 | )] 144 | 145 | def test_people_set_once(self): 146 | self.mp.people_set_once('amq', {'birth month': 'october', 'favorite color': 'purple'}) 147 | assert self.consumer.log == [( 148 | 'people', { 149 | '$time': self.mp._now(), 150 | '$token': self.TOKEN, 151 | '$distinct_id': 'amq', 152 | '$set_once': { 153 | 'birth month': 'october', 154 | 'favorite color': 'purple', 155 | }, 156 | } 157 | )] 158 | 159 | def test_people_increment(self): 160 | self.mp.people_increment('amq', {'Albums Released': 1}) 161 | assert self.consumer.log == [( 162 | 'people', { 163 | '$time': self.mp._now(), 164 | '$token': self.TOKEN, 165 | '$distinct_id': 'amq', 166 | '$add': { 167 | 'Albums Released': 1, 168 | }, 169 | } 170 | )] 171 | 172 | def test_people_append(self): 173 | self.mp.people_append('amq', {'birth month': 'october', 'favorite color': 'purple'}) 174 | assert self.consumer.log == [( 175 | 'people', { 176 | '$time': self.mp._now(), 177 | '$token': self.TOKEN, 178 | '$distinct_id': 'amq', 179 | '$append': { 180 | 'birth month': 'october', 181 | 'favorite color': 'purple', 182 | }, 183 | } 184 | )] 185 | 186 | def test_people_union(self): 187 | self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) 188 | assert self.consumer.log == [( 189 | 'people', { 190 | '$time': self.mp._now(), 191 | '$token': self.TOKEN, 192 | '$distinct_id': 'amq', 193 | '$union': { 194 | 'Albums': ['Diamond Dogs'], 195 | }, 196 | } 197 | )] 198 | 199 | def test_people_unset(self): 200 | self.mp.people_unset('amq', ['Albums', 'Singles']) 201 | assert self.consumer.log == [( 202 | 'people', { 203 | '$time': self.mp._now(), 204 | '$token': self.TOKEN, 205 | '$distinct_id': 'amq', 206 | '$unset': ['Albums', 'Singles'], 207 | } 208 | )] 209 | 210 | def test_people_remove(self): 211 | self.mp.people_remove('amq', {'Albums': 'Diamond Dogs'}) 212 | assert self.consumer.log == [( 213 | 'people', { 214 | '$time': self.mp._now(), 215 | '$token': self.TOKEN, 216 | '$distinct_id': 'amq', 217 | '$remove': {'Albums': 'Diamond Dogs'}, 218 | } 219 | )] 220 | 221 | def test_people_track_charge(self): 222 | self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) 223 | assert self.consumer.log == [( 224 | 'people', { 225 | '$time': self.mp._now(), 226 | '$token': self.TOKEN, 227 | '$distinct_id': 'amq', 228 | '$append': { 229 | '$transactions': { 230 | '$time': '2013-04-01T09:02:00', 231 | '$amount': 12.65, 232 | }, 233 | }, 234 | } 235 | )] 236 | 237 | def test_people_track_charge_without_properties(self): 238 | self.mp.people_track_charge('amq', 12.65) 239 | assert self.consumer.log == [( 240 | 'people', { 241 | '$time': self.mp._now(), 242 | '$token': self.TOKEN, 243 | '$distinct_id': 'amq', 244 | '$append': { 245 | '$transactions': { 246 | '$amount': 12.65, 247 | }, 248 | }, 249 | } 250 | )] 251 | 252 | def test_people_clear_charges(self): 253 | self.mp.people_clear_charges('amq') 254 | assert self.consumer.log == [( 255 | 'people', { 256 | '$time': self.mp._now(), 257 | '$token': self.TOKEN, 258 | '$distinct_id': 'amq', 259 | '$unset': ['$transactions'], 260 | } 261 | )] 262 | 263 | def test_people_set_created_date_string(self): 264 | created = '2014-02-14T01:02:03' 265 | self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) 266 | assert self.consumer.log == [( 267 | 'people', { 268 | '$time': self.mp._now(), 269 | '$token': self.TOKEN, 270 | '$distinct_id': 'amq', 271 | '$set': { 272 | '$created': created, 273 | 'favorite color': 'purple', 274 | }, 275 | } 276 | )] 277 | 278 | def test_people_set_created_date_datetime(self): 279 | created = datetime.datetime(2014, 2, 14, 1, 2, 3) 280 | self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) 281 | assert self.consumer.log == [( 282 | 'people', { 283 | '$time': self.mp._now(), 284 | '$token': self.TOKEN, 285 | '$distinct_id': 'amq', 286 | '$set': { 287 | '$created': '2014-02-14T01:02:03', 288 | 'favorite color': 'purple', 289 | }, 290 | } 291 | )] 292 | 293 | def test_people_meta(self): 294 | self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, 295 | meta={'$ip': 0, '$ignore_time': True}) 296 | assert self.consumer.log == [( 297 | 'people', { 298 | '$time': self.mp._now(), 299 | '$token': self.TOKEN, 300 | '$distinct_id': 'amq', 301 | '$set': { 302 | 'birth month': 'october', 303 | 'favorite color': 'purple', 304 | }, 305 | '$ip': 0, 306 | '$ignore_time': True, 307 | } 308 | )] 309 | 310 | 311 | class TestMixpanelIdentity(TestMixpanelBase): 312 | 313 | def test_alias(self): 314 | # More complicated since alias() forces a synchronous call. 315 | 316 | with responses.RequestsMock() as rsps: 317 | rsps.add( 318 | responses.POST, 319 | 'https://api.mixpanel.com/track', 320 | json={"status": 1, "error": None}, 321 | status=200, 322 | ) 323 | 324 | self.mp.alias('ALIAS', 'ORIGINAL ID') 325 | 326 | assert self.consumer.log == [] 327 | call = rsps.calls[0] 328 | assert call.request.method == "POST" 329 | assert call.request.url == "https://api.mixpanel.com/track" 330 | posted_data = dict(urllib.parse.parse_qsl(six.ensure_str(call.request.body))) 331 | assert json.loads(posted_data["data"]) == {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} 332 | 333 | def test_merge(self): 334 | self.mp.merge('my_good_api_key', 'd1', 'd2') 335 | assert self.consumer.log == [( 336 | 'imports', 337 | { 338 | 'event': '$merge', 339 | 'properties': { 340 | '$distinct_ids': ['d1', 'd2'], 341 | 'token': self.TOKEN, 342 | } 343 | }, 344 | ('my_good_api_key', None), 345 | )] 346 | 347 | self.consumer.clear() 348 | 349 | self.mp.merge('my_good_api_key', 'd1', 'd2', api_secret='my_secret') 350 | assert self.consumer.log == [( 351 | 'imports', 352 | { 353 | 'event': '$merge', 354 | 'properties': { 355 | '$distinct_ids': ['d1', 'd2'], 356 | 'token': self.TOKEN, 357 | } 358 | }, 359 | ('my_good_api_key', 'my_secret'), 360 | )] 361 | 362 | 363 | class TestMixpanelGroups(TestMixpanelBase): 364 | 365 | def test_group_set(self): 366 | self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) 367 | assert self.consumer.log == [( 368 | 'groups', { 369 | '$time': self.mp._now(), 370 | '$token': self.TOKEN, 371 | '$group_key': 'company', 372 | '$group_id': 'amq', 373 | '$set': { 374 | 'birth month': 'october', 375 | 'favorite color': 'purple', 376 | }, 377 | } 378 | )] 379 | 380 | def test_group_set_once(self): 381 | self.mp.group_set_once('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) 382 | assert self.consumer.log == [( 383 | 'groups', { 384 | '$time': self.mp._now(), 385 | '$token': self.TOKEN, 386 | '$group_key': 'company', 387 | '$group_id': 'amq', 388 | '$set_once': { 389 | 'birth month': 'october', 390 | 'favorite color': 'purple', 391 | }, 392 | } 393 | )] 394 | 395 | def test_group_union(self): 396 | self.mp.group_union('company', 'amq', {'Albums': ['Diamond Dogs']}) 397 | assert self.consumer.log == [( 398 | 'groups', { 399 | '$time': self.mp._now(), 400 | '$token': self.TOKEN, 401 | '$group_key': 'company', 402 | '$group_id': 'amq', 403 | '$union': { 404 | 'Albums': ['Diamond Dogs'], 405 | }, 406 | } 407 | )] 408 | 409 | def test_group_unset(self): 410 | self.mp.group_unset('company', 'amq', ['Albums', 'Singles']) 411 | assert self.consumer.log == [( 412 | 'groups', { 413 | '$time': self.mp._now(), 414 | '$token': self.TOKEN, 415 | '$group_key': 'company', 416 | '$group_id': 'amq', 417 | '$unset': ['Albums', 'Singles'], 418 | } 419 | )] 420 | 421 | def test_group_remove(self): 422 | self.mp.group_remove('company', 'amq', {'Albums': 'Diamond Dogs'}) 423 | assert self.consumer.log == [( 424 | 'groups', { 425 | '$time': self.mp._now(), 426 | '$token': self.TOKEN, 427 | '$group_key': 'company', 428 | '$group_id': 'amq', 429 | '$remove': {'Albums': 'Diamond Dogs'}, 430 | } 431 | )] 432 | 433 | def test_custom_json_serializer(self): 434 | decimal_string = '12.05' 435 | with pytest.raises(TypeError) as excinfo: 436 | self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) 437 | assert "not JSON serializable" in str(excinfo.value) 438 | 439 | class CustomSerializer(mixpanel.DatetimeSerializer): 440 | def default(self, obj): 441 | if isinstance(obj, decimal.Decimal): 442 | return obj.to_eng_string() 443 | 444 | self.mp._serializer = CustomSerializer 445 | self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string), '$insert_id': 'abc123'}) 446 | assert self.consumer.log == [( 447 | 'events', { 448 | 'event': 'button press', 449 | 'properties': { 450 | 'token': self.TOKEN, 451 | 'size': decimal_string, 452 | 'distinct_id': 'ID', 453 | 'time': self.mp._now(), 454 | '$insert_id': 'abc123', 455 | 'mp_lib': 'python', 456 | '$lib_version': mixpanel.__version__, 457 | } 458 | } 459 | )] 460 | 461 | 462 | class TestConsumer: 463 | @classmethod 464 | def setup_class(cls): 465 | cls.consumer = mixpanel.Consumer(request_timeout=30) 466 | 467 | def test_send_events(self): 468 | with responses.RequestsMock() as rsps: 469 | rsps.add( 470 | responses.POST, 471 | 'https://api.mixpanel.com/track', 472 | json={"status": 1, "error": None}, 473 | status=200, 474 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 475 | ) 476 | self.consumer.send('events', '{"foo":"bar"}') 477 | 478 | def test_send_people(self): 479 | with responses.RequestsMock() as rsps: 480 | rsps.add( 481 | responses.POST, 482 | 'https://api.mixpanel.com/engage', 483 | json={"status": 1, "error": None}, 484 | status=200, 485 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 486 | ) 487 | self.consumer.send('people', '{"foo":"bar"}') 488 | 489 | def test_server_success(self): 490 | with responses.RequestsMock() as rsps: 491 | rsps.add( 492 | responses.POST, 493 | 'https://api.mixpanel.com/track', 494 | json={"status": 1, "error": None}, 495 | status=200, 496 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 497 | ) 498 | self.consumer.send('events', '{"foo":"bar"}') 499 | 500 | def test_server_invalid_data(self): 501 | with responses.RequestsMock() as rsps: 502 | error_msg = "bad data" 503 | rsps.add( 504 | responses.POST, 505 | 'https://api.mixpanel.com/track', 506 | json={"status": 0, "error": error_msg}, 507 | status=200, 508 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'})], 509 | ) 510 | 511 | with pytest.raises(mixpanel.MixpanelException) as exc: 512 | self.consumer.send('events', '{INVALID "foo":"bar"}') 513 | assert error_msg in str(exc) 514 | 515 | def test_server_unauthorized(self): 516 | with responses.RequestsMock() as rsps: 517 | rsps.add( 518 | responses.POST, 519 | 'https://api.mixpanel.com/track', 520 | json={"status": 0, "error": "unauthed"}, 521 | status=401, 522 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 523 | ) 524 | with pytest.raises(mixpanel.MixpanelException) as exc: 525 | self.consumer.send('events', '{"foo":"bar"}') 526 | assert "unauthed" in str(exc) 527 | 528 | def test_server_forbidden(self): 529 | with responses.RequestsMock() as rsps: 530 | rsps.add( 531 | responses.POST, 532 | 'https://api.mixpanel.com/track', 533 | json={"status": 0, "error": "forbade"}, 534 | status=403, 535 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 536 | ) 537 | with pytest.raises(mixpanel.MixpanelException) as exc: 538 | self.consumer.send('events', '{"foo":"bar"}') 539 | assert "forbade" in str(exc) 540 | 541 | def test_server_5xx(self): 542 | with responses.RequestsMock() as rsps: 543 | rsps.add( 544 | responses.POST, 545 | 'https://api.mixpanel.com/track', 546 | body="Internal server error", 547 | status=500, 548 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 549 | ) 550 | with pytest.raises(mixpanel.MixpanelException) as exc: 551 | self.consumer.send('events', '{"foo":"bar"}') 552 | 553 | def test_consumer_override_api_host(self): 554 | consumer = mixpanel.Consumer(api_host="api-zoltan.mixpanel.com") 555 | 556 | with responses.RequestsMock() as rsps: 557 | rsps.add( 558 | responses.POST, 559 | 'https://api-zoltan.mixpanel.com/track', 560 | json={"status": 1, "error": None}, 561 | status=200, 562 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 563 | ) 564 | consumer.send('events', '{"foo":"bar"}') 565 | 566 | with responses.RequestsMock() as rsps: 567 | rsps.add( 568 | responses.POST, 569 | 'https://api-zoltan.mixpanel.com/engage', 570 | json={"status": 1, "error": None}, 571 | status=200, 572 | match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], 573 | ) 574 | consumer.send('people', '{"foo":"bar"}') 575 | 576 | def test_unknown_endpoint(self): 577 | with pytest.raises(mixpanel.MixpanelException): 578 | self.consumer.send('unknown', '1') 579 | 580 | 581 | class TestBufferedConsumer: 582 | @classmethod 583 | def setup_class(cls): 584 | cls.MAX_LENGTH = 10 585 | cls.consumer = mixpanel.BufferedConsumer(cls.MAX_LENGTH) 586 | cls.consumer._consumer = LogConsumer() 587 | cls.log = cls.consumer._consumer.log 588 | 589 | def setup_method(self): 590 | del self.log[:] 591 | 592 | def test_buffer_hold_and_flush(self): 593 | self.consumer.send('events', '"Event"') 594 | assert len(self.log) == 0 595 | self.consumer.flush() 596 | assert self.log == [('events', ['Event'])] 597 | 598 | def test_buffer_fills_up(self): 599 | for i in range(self.MAX_LENGTH - 1): 600 | self.consumer.send('events', '"Event"') 601 | assert len(self.log) == 0 602 | 603 | self.consumer.send('events', '"Last Event"') 604 | assert len(self.log) == 1 605 | assert self.log == [('events', [ 606 | 'Event', 'Event', 'Event', 'Event', 'Event', 607 | 'Event', 'Event', 'Event', 'Event', 'Last Event', 608 | ])] 609 | 610 | def test_unknown_endpoint_raises_on_send(self): 611 | # Ensure the exception isn't hidden until a flush. 612 | with pytest.raises(mixpanel.MixpanelException): 613 | self.consumer.send('unknown', '1') 614 | 615 | def test_useful_reraise_in_flush_endpoint(self): 616 | with responses.RequestsMock() as rsps: 617 | rsps.add( 618 | responses.POST, 619 | 'https://api.mixpanel.com/track', 620 | json={"status": 0, "error": "arbitrary error"}, 621 | status=200, 622 | ) 623 | 624 | broken_json = '{broken JSON' 625 | consumer = mixpanel.BufferedConsumer(2) 626 | consumer.send('events', broken_json) 627 | 628 | with pytest.raises(mixpanel.MixpanelException) as excinfo: 629 | consumer.flush() 630 | assert excinfo.value.message == '[%s]' % broken_json 631 | assert excinfo.value.endpoint == 'events' 632 | 633 | def test_send_remembers_api_key(self): 634 | self.consumer.send('imports', '"Event"', api_key='MY_API_KEY') 635 | assert len(self.log) == 0 636 | self.consumer.flush() 637 | assert self.log == [('imports', ['Event'], ('MY_API_KEY', None))] 638 | 639 | def test_send_remembers_api_secret(self): 640 | self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') 641 | assert len(self.log) == 0 642 | self.consumer.flush() 643 | assert self.log == [('imports', ['Event'], (None, 'ZZZZZZ'))] 644 | 645 | 646 | 647 | 648 | class TestFunctional: 649 | @classmethod 650 | def setup_class(cls): 651 | cls.TOKEN = '12345' 652 | cls.mp = mixpanel.Mixpanel(cls.TOKEN) 653 | cls.mp._now = lambda: 1000 654 | 655 | def test_track_functional(self): 656 | with responses.RequestsMock() as rsps: 657 | rsps.add( 658 | responses.POST, 659 | 'https://api.mixpanel.com/track', 660 | json={"status": 1, "error": None}, 661 | status=200, 662 | ) 663 | 664 | self.mp.track('player1', 'button_press', {'size': 'big', 'color': 'blue', '$insert_id': 'xyz1200'}) 665 | 666 | body = six.ensure_str(rsps.calls[0].request.body) 667 | wrapper = dict(urllib.parse.parse_qsl(body)) 668 | data = json.loads(wrapper["data"]) 669 | del wrapper["data"] 670 | 671 | assert {"ip": "0", "verbose": "1"} == wrapper 672 | expected_data = {'event': 'button_press', 'properties': {'size': 'big', 'color': 'blue', 'mp_lib': 'python', 'token': '12345', 'distinct_id': 'player1', '$lib_version': mixpanel.__version__, 'time': 1000, '$insert_id': 'xyz1200'}} 673 | assert expected_data == data 674 | 675 | def test_people_set_functional(self): 676 | with responses.RequestsMock() as rsps: 677 | rsps.add( 678 | responses.POST, 679 | 'https://api.mixpanel.com/engage', 680 | json={"status": 1, "error": None}, 681 | status=200, 682 | ) 683 | 684 | self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) 685 | body = six.ensure_str(rsps.calls[0].request.body) 686 | wrapper = dict(urllib.parse.parse_qsl(body)) 687 | data = json.loads(wrapper["data"]) 688 | del wrapper["data"] 689 | 690 | assert {"ip": "0", "verbose": "1"} == wrapper 691 | expected_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} 692 | assert expected_data == data 693 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py35, py36, py37, py38, py39, py310, py311, py312 3 | 4 | [testenv] 5 | deps = -rrequirements-testing.txt 6 | commands = py.test {posargs} 7 | --------------------------------------------------------------------------------