From d86ac5ceb09d81f71024ab11d2cf6b8422298c6a Mon Sep 17 00:00:00 2001 From: Joe Bowers Date: Fri, 16 Jan 2015 18:20:31 +0000 Subject: [PATCH 001/208] Version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b59b5f1..9c3f141 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='mixpanel-py', - version='3.2.0', + version='3.2.1', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', packages=['mixpanel'], From 55c1c835467e6d705573398374a752f60d8aa6b4 Mon Sep 17 00:00:00 2001 From: Steve W Date: Wed, 4 Feb 2015 11:44:11 -0800 Subject: [PATCH 002/208] Adding the ability to import through BufferedConsumer --- mixpanel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 2fb2385..ef5a52e 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -379,8 +379,8 @@ class BufferedConsumer(object): when you're sure you're done sending them. calls to flush() will send all remaining unsent events being held by the BufferedConsumer. """ - def __init__(self, max_size=50, events_url=None, people_url=None, request_timeout=None): - self._consumer = Consumer(events_url, people_url, request_timeout) + def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None): + self._consumer = Consumer(events_url, people_url, import_url, request_timeout) self._buffers = { 'events': [], 'people': [], From ebbeba68cc7cbec6b061dee7f3ab99ed64d7152b Mon Sep 17 00:00:00 2001 From: Joe Bowers Date: Thu, 5 Feb 2015 19:17:44 +0000 Subject: [PATCH 003/208] Version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9c3f141..e0aac03 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='mixpanel-py', - version='3.2.1', + version='4.0.0', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', packages=['mixpanel'], From f7648aed2888524a8548d4707dc4da3ff573c8cb Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 5 Mar 2015 13:15:23 -0800 Subject: [PATCH 004/208] Rename README for GitHub formatting --- README.txt => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README.txt => README.md (100%) diff --git a/README.txt b/README.md similarity index 100% rename from README.txt rename to README.md From 0b269c1bee85dc942bb1c42b5ad26d161b279c1b Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 5 Mar 2015 13:22:18 -0800 Subject: [PATCH 005/208] Reflect README name change in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e0aac03..472022a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ packages=['mixpanel'], url='https://github.com/mixpanel/mixpanel-python', description='Official Mixpanel library for Python', - long_description=open('README.txt').read(), + long_description=open('README.md').read(), classifiers=[ 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', From 321df208c9ff437719e71223017dbc985dfaead3 Mon Sep 17 00:00:00 2001 From: Joe Jevnik Date: Wed, 18 Feb 2015 16:01:13 -0500 Subject: [PATCH 006/208] MAINT: Moves the module docstring for __init__.py This makes it the actual __doc__ for the module. --- mixpanel/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index ef5a52e..ed87ff4 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -1,9 +1,3 @@ -import base64 -import json -import time -import urllib -import urllib2 - """ The mixpanel package allows you to easily track events and update people properties from your python application. @@ -14,6 +8,11 @@ The Consumer and BufferedConsumer classes allow callers to customize the IO characteristics of their tracking. """ +import base64 +import json +import time +import urllib +import urllib2 VERSION = '3.2.0' From 6d14bafdc872ef21137333d2ab4b58d8f9342354 Mon Sep 17 00:00:00 2001 From: Joe Jevnik Date: Wed, 18 Feb 2015 16:02:38 -0500 Subject: [PATCH 007/208] MAINT: Removes the mutable default arguments. --- mixpanel/__init__.py | 68 ++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index ed87ff4..90c6616 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -39,7 +39,7 @@ def __init__(self, token, consumer=None): def _now(self): return time.time() - def track(self, distinct_id, event_name, properties={}, meta={}): + def track(self, distinct_id, event_name, properties=None, meta=None): """ Notes that an event has occurred, along with a distinct_id representing the source of that event (for example, a user id), @@ -64,15 +64,18 @@ def track(self, distinct_id, event_name, properties={}, meta={}): 'mp_lib': 'python', '$lib_version': VERSION, } - all_properties.update(properties) + if properties: + all_properties.update(properties) event = { 'event': event_name, 'properties': all_properties, } - event.update(meta) + if meta: + event.update(meta) self._consumer.send('events', json.dumps(event, separators=(',', ':'))) - def import_data(self, api_key, distinct_id, event_name, timestamp, properties={}, meta={}): + def import_data(self, api_key, distinct_id, event_name, timestamp, + properties=None, meta=None): """ Allows data older than 5 days old to be sent to MixPanel. @@ -104,15 +107,17 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, properties={} 'mp_lib': 'python', '$lib_version': VERSION, } - all_properties.update(properties) + if properties: + all_properties.update(properties) event = { 'event': event_name, 'properties': all_properties, } - event.update(meta) + if meta: + event.update(meta) self._consumer.send('imports', json.dumps(event, separators=(',', ':')), api_key) - def alias(self, alias_id, original, meta={}): + def alias(self, alias_id, original, meta=None): """ Gives custom alias to a people record. @@ -136,10 +141,11 @@ def alias(self, alias_id, original, meta={}): 'token': self._token, }, } - event.update(meta) + if meta: + event.update(meta) sync_consumer.send('events', json.dumps(event, separators=(',', ':'))) - def people_set(self, distinct_id, properties, meta={}): + def people_set(self, distinct_id, properties, meta=None): """ Set properties of a people record. @@ -152,9 +158,9 @@ def people_set(self, distinct_id, properties, meta={}): return self.people_update({ '$distinct_id': distinct_id, '$set': properties, - }, meta=meta) + }, meta=meta or {}) - def people_set_once(self, distinct_id, properties, meta={}): + def people_set_once(self, distinct_id, properties, meta=None): """ Set immutable properties of a people record. @@ -167,9 +173,9 @@ def people_set_once(self, distinct_id, properties, meta={}): return self.people_update({ '$distinct_id': distinct_id, '$set_once': properties, - }, meta=meta) + }, meta=meta or {}) - def people_increment(self, distinct_id, properties, meta={}): + def people_increment(self, distinct_id, properties, meta=None): """ Increments/decrements numerical properties of people record. @@ -182,9 +188,9 @@ def people_increment(self, distinct_id, properties, meta={}): return self.people_update({ '$distinct_id': distinct_id, '$add': properties, - }, meta=meta) + }, meta=meta or {}) - def people_append(self, distinct_id, properties, meta={}): + def people_append(self, distinct_id, properties, meta=None): """ Appends to the list associated with a property. @@ -198,9 +204,9 @@ def people_append(self, distinct_id, properties, meta={}): return self.people_update({ '$distinct_id': distinct_id, '$append': properties, - }, meta=meta) + }, meta=meta or {}) - def people_union(self, distinct_id, properties, meta={}): + def people_union(self, distinct_id, properties, meta=None): """ Merges the values for a list associated with a property. @@ -208,14 +214,14 @@ def people_union(self, distinct_id, properties, meta={}): the request are merged with the existing list on the user profile, ignoring duplicate list values. Example: - mp.people_union('12345', { "Items purchased": ["socks", "shirts"] } ) + mp.people_union('12345', {"Items purchased": ["socks", "shirts"]}) """ return self.people_update({ '$distinct_id': distinct_id, '$union': properties, - }, meta=meta) + }, meta=meta or {}) - def people_unset(self, distinct_id, properties, meta={}): + def people_unset(self, distinct_id, properties, meta=None): """ Removes properties from a profile. @@ -229,7 +235,7 @@ def people_unset(self, distinct_id, properties, meta={}): '$unset': properties, }, meta=meta) - def people_delete(self, distinct_id, meta={}): + def people_delete(self, distinct_id, meta=None): """ Permanently deletes a profile. @@ -241,9 +247,10 @@ def people_delete(self, distinct_id, meta={}): return self.people_update({ '$distinct_id': distinct_id, '$delete': "", - }, meta=meta) + }, meta=meta or None) - def people_track_charge(self, distinct_id, amount, properties={}, meta={}): + def people_track_charge(self, distinct_id, amount, + properties=None, meta=None): """ Tracks a charge to a user. @@ -258,9 +265,11 @@ def people_track_charge(self, distinct_id, amount, properties={}, meta={}): mp.people_track_charge('1234', 50, {'$time': "2013-04-01T09:02:00"}) """ properties.update({'$amount': amount}) - return self.people_append(distinct_id, {'$transactions': properties}, meta=meta) + return self.people_append( + distinct_id, {'$transactions': properties or {}}, meta=meta or {} + ) - def people_clear_charges(self, distinct_id, meta={}): + def people_clear_charges(self, distinct_id, meta=None): """ Clears all charges from a user. @@ -269,9 +278,11 @@ def people_clear_charges(self, distinct_id, meta={}): #clear all charges from user '1234' mp.people_clear_charges('1234') """ - return self.people_unset(distinct_id, ["$transactions"], meta=meta) + return self.people_unset( + distinct_id, ["$transactions"], meta=meta or {}, + ) - def people_update(self, message, meta={}): + def people_update(self, message, meta=None): """ Send a generic update to Mixpanel people analytics. @@ -288,7 +299,8 @@ def people_update(self, message, meta={}): '$time': int(self._now() * 1000), } record.update(message) - record.update(meta) + if meta: + record.update(meta) self._consumer.send('people', json.dumps(record, separators=(',', ':'))) From 677fb4923726c712d6bb920b51a9a6c6cadd02d9 Mon Sep 17 00:00:00 2001 From: Joe Jevnik Date: Wed, 18 Feb 2015 16:03:39 -0500 Subject: [PATCH 008/208] DOC: Docstring used Ruby terms instead of python. The docstring for track used 'Hash' instead of 'dict' and used the Ruby Hash syntax, which does not work in python. --- mixpanel/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 90c6616..4231abd 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -44,7 +44,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): Notes that an event has occurred, along with a distinct_id representing the source of that event (for example, a user id), an event name describing the event and a set of properties - describing that event. Properties are provided as a Hash with + describing that event. Properties are provided as a dict with string keys and strings, numbers or booleans as values. # Track that user "12345"'s credit card was declined @@ -53,8 +53,8 @@ def track(self, distinct_id, event_name, properties=None, meta=None): # Properties describe the circumstances of the event, # or aspects of the source or user associated with the event mp.track("12345", "Welcome Email Sent", { - 'Email Template' => 'Pretty Pink Welcome', - 'User Sign-up Cohort' => 'July 2013' + 'Email Template': 'Pretty Pink Welcome', + 'User Sign-up Cohort': 'July 2013' }) """ all_properties = { From 9c6aff3f7be5e4d9e56d545c652a5fc58a047b78 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 9 Mar 2015 16:34:51 -0700 Subject: [PATCH 009/208] Conform to PEP8 --- demo/subprocess_consumer.py | 5 ++--- tests.py | 43 ++++++++++++++++++------------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/demo/subprocess_consumer.py b/demo/subprocess_consumer.py index f74f474..4e2fb65 100644 --- a/demo/subprocess_consumer.py +++ b/demo/subprocess_consumer.py @@ -1,4 +1,3 @@ - import multiprocessing import random @@ -41,10 +40,10 @@ def do_tracking(project_token, distinct_id, queue): mp = Mixpanel(project_token, consumer) for i in xrange(100): event = 'Tick' - mp.track(distinct_id, 'Tick', { 'Tick Number': i }) + mp.track(distinct_id, event, {'Tick Number': i}) print 'tick {0}'.format(i) - queue.put(None) # tell worker we're out of jobs + queue.put(None) # tell worker we're out of jobs def do_sending(queue): ''' diff --git a/tests.py b/tests.py index fd9481a..909df4e 100755 --- a/tests.py +++ b/tests.py @@ -29,7 +29,7 @@ def setUp(self): self.TOKEN = '12345' self.consumer = LogConsumer() self.mp = mixpanel.Mixpanel('12345', consumer=self.consumer) - self.mp._now = lambda : 1000.1 + self.mp._now = lambda: 1000.1 def test_track(self): self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}) @@ -70,7 +70,7 @@ def test_import_data(self): def test_track_meta(self): self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}, - meta={'ip': 0}) + meta={'ip': 0}) self.assertEqual(self.consumer.log, [( 'events', { 'event': 'button press', @@ -143,31 +143,31 @@ def test_people_append(self): )]) def test_people_union(self): - self.mp.people_union('amq', {'Albums': [ 'Diamond Dogs'] }) + self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) self.assertEqual(self.consumer.log, [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, '$distinct_id': 'amq', '$union': { - 'Albums': [ 'Diamond Dogs' ], + 'Albums': ['Diamond Dogs'], }, } )]) def test_people_unset(self): - self.mp.people_unset('amq', [ 'Albums', 'Singles' ]) + self.mp.people_unset('amq', ['Albums', 'Singles']) self.assertEqual(self.consumer.log, [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, '$distinct_id': 'amq', - '$unset': [ 'Albums', 'Singles' ], + '$unset': ['Albums', 'Singles'], } )]) def test_people_track_charge(self): - self.mp.people_track_charge('amq', 12.65, { '$time': '2013-04-01T09:02:00' }) + self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) self.assertEqual(self.consumer.log, [( 'people', { '$time': int(self.mp._now() * 1000), @@ -189,27 +189,26 @@ def test_people_clear_charges(self): '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, '$distinct_id': 'amq', - '$unset': [ '$transactions' ], + '$unset': ['$transactions'], } )]) def test_alias(self): mock_response = Mock() mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value = mock_response) as urlopen: - self.mp.alias('ALIAS','ORIGINAL ID') + with patch('urllib2.urlopen', return_value=mock_response) as urlopen: + self.mp.alias('ALIAS', 'ORIGINAL ID') self.assertEqual(self.consumer.log, []) self.assertEqual(urlopen.call_count, 1) - ((request,),_) = urlopen.call_args + ((request,), _) = urlopen.call_args self.assertEqual(request.get_full_url(), 'https://api.mixpanel.com/track') self.assertEqual(request.get_data(), 'ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') - def test_people_meta(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, - meta={'$ip': 0, '$ignore_time': True}) + meta={'$ip': 0, '$ignore_time': True}) self.assertEqual(self.consumer.log, [( 'people', { '$time': int(self.mp._now() * 1000), @@ -232,7 +231,7 @@ def setUp(self): def _assertSends(self, expect_url, expect_data): mock_response = Mock() mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value = mock_response) as urlopen: + with patch('urllib2.urlopen', return_value=mock_response) as urlopen: yield self.assertEqual(urlopen.call_count, 1) @@ -250,7 +249,7 @@ def test_send_events(self): self.consumer.send('events', '"Event"') def test_send_people(self): - with self._assertSends('https://api.mixpanel.com/engage','ip=0&data=IlBlb3BsZSI%3D&verbose=1'): + with self._assertSends('https://api.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1'): self.consumer.send('people', '"People"') class BufferedConsumerTestCase(unittest.TestCase): @@ -261,7 +260,7 @@ def setUp(self): self.mock.read.return_value = '{"status":1, "error": null}' def test_buffer_hold_and_flush(self): - with patch('urllib2.urlopen', return_value = self.mock) as urlopen: + with patch('urllib2.urlopen', return_value=self.mock) as urlopen: self.consumer.send('events', '"Event"') self.assertTrue(not self.mock.called) self.consumer.flush() @@ -277,7 +276,7 @@ def test_buffer_hold_and_flush(self): self.assertIsNone(timeout) def test_buffer_fills_up(self): - with patch('urllib2.urlopen', return_value = self.mock) as urlopen: + with patch('urllib2.urlopen', return_value=self.mock) as urlopen: for i in xrange(self.MAX_LENGTH - 1): self.consumer.send('events', '"Event"') self.assertTrue(not self.mock.called) @@ -285,7 +284,7 @@ def test_buffer_fills_up(self): self.consumer.send('events', '"Last Event"') self.assertEqual(urlopen.call_count, 1) - ((request,),_) = urlopen.call_args + ((request,), _) = urlopen.call_args self.assertEqual(request.get_full_url(), 'https://api.mixpanel.com/track') self.assertEqual(request.get_data(), 'ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') @@ -293,17 +292,17 @@ class FunctionalTestCase(unittest.TestCase): def setUp(self): self.TOKEN = '12345' self.mp = mixpanel.Mixpanel(self.TOKEN) - self.mp._now = lambda : 1000 + self.mp._now = lambda: 1000 @contextlib.contextmanager def _assertRequested(self, expect_url, expect_data): mock_response = Mock() mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value = mock_response) as urlopen: + with patch('urllib2.urlopen', return_value=mock_response) as urlopen: yield self.assertEqual(urlopen.call_count, 1) - ((request,),_) = urlopen.call_args + ((request,), _) = urlopen.call_args self.assertEqual(request.get_full_url(), expect_url) data = urlparse.parse_qs(request.get_data()) self.assertEqual(len(data['data']), 1) @@ -322,7 +321,7 @@ def test_track_functional(self): def test_people_set_functional(self): expect_data = {u'$distinct_id': u'amq', u'$set': {u'birth month': u'october', u'favorite color': u'purple'}, u'$time': 1000000, u'$token': u'12345'} with self._assertRequested('https://api.mixpanel.com/engage', expect_data): - self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) + self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) if __name__ == "__main__": unittest.main() From 2479629c163d37287e0e5f6fa2c707a720f7ae23 Mon Sep 17 00:00:00 2001 From: Alex Louden Date: Mon, 9 Mar 2015 17:08:58 -0700 Subject: [PATCH 010/208] Allow datetime to be serialised to json --- mixpanel/__init__.py | 23 +++++++++++++++++++---- tests.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 4231abd..411bc62 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -9,6 +9,7 @@ customize the IO characteristics of their tracking. """ import base64 +import datetime import json import time import urllib @@ -17,6 +18,20 @@ VERSION = '3.2.0' +class DatetimeSerializer(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.datetime): + fmt = '%Y-%m-%dT%H:%M:%S' + return obj.strftime(fmt) + + return json.JSONEncoder.default(self, obj) + + +def json_dumps(data): + # Separators are specified to eliminate whitespace. + return json.dumps(data, separators=(',', ':'), cls=DatetimeSerializer) + + class Mixpanel(object): """ Use instances of Mixpanel to track events and send Mixpanel @@ -72,7 +87,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): } if meta: event.update(meta) - self._consumer.send('events', json.dumps(event, separators=(',', ':'))) + self._consumer.send('events', json_dumps(event)) def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None): @@ -115,7 +130,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, } if meta: event.update(meta) - self._consumer.send('imports', json.dumps(event, separators=(',', ':')), api_key) + self._consumer.send('imports', json_dumps(event), api_key) def alias(self, alias_id, original, meta=None): """ @@ -143,7 +158,7 @@ def alias(self, alias_id, original, meta=None): } if meta: event.update(meta) - sync_consumer.send('events', json.dumps(event, separators=(',', ':'))) + sync_consumer.send('events', json_dumps(event)) def people_set(self, distinct_id, properties, meta=None): """ @@ -301,7 +316,7 @@ def people_update(self, message, meta=None): record.update(message) if meta: record.update(meta) - self._consumer.send('people', json.dumps(record, separators=(',', ':'))) + self._consumer.send('people', json_dumps(record)) class MixpanelException(Exception): diff --git a/tests.py b/tests.py index 909df4e..27f00a0 100755 --- a/tests.py +++ b/tests.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import base64 import contextlib +import datetime import json import time import unittest @@ -193,6 +194,36 @@ def test_people_clear_charges(self): } )]) + def test_people_set_created_date_string(self): + created = '2014-02-14T01:02:03' + self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) + self.assertEqual(self.consumer.log, [( + 'people', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$distinct_id': 'amq', + '$set': { + '$created': created, + 'favorite color': 'purple', + }, + } + )]) + + def test_people_set_created_date_datetime(self): + created = datetime.datetime(2014, 2, 14, 1, 2, 3) + self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) + self.assertEqual(self.consumer.log, [( + 'people', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$distinct_id': 'amq', + '$set': { + '$created': '2014-02-14T01:02:03', + 'favorite color': 'purple', + }, + } + )]) + def test_alias(self): mock_response = Mock() mock_response.read.return_value = '{"status":1, "error": null}' From 0817f766d14589ddedbb92316a0bd993fc3338a1 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 9 Mar 2015 16:22:00 -0700 Subject: [PATCH 011/208] Release 4.0.1 --- CHANGES.txt | 7 +++++++ mixpanel/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 53f6c17..82aaa54 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,10 @@ +v4.0.1 +* Fix mutable default arguments. +* Allow serialization of datetime instances. + +v4.0.0 +* Add an optional `request_timeout` to `BufferedConsumer`. + v3.1.3 * All calls to alias() now run a synchronous request to Mixpanel's servers on every call. diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 411bc62..cd64efb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -15,7 +15,7 @@ import urllib import urllib2 -VERSION = '3.2.0' +VERSION = '4.0.1' class DatetimeSerializer(json.JSONEncoder): diff --git a/setup.py b/setup.py index 472022a..3350ac9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='mixpanel-py', - version='4.0.0', + version='4.0.1', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', packages=['mixpanel'], From a71eff43e062fdbbf0264b1063807f6a9c7798aa Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 9 Mar 2015 17:50:46 -0700 Subject: [PATCH 012/208] Correct Mixpanel capitalization --- mixpanel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index cd64efb..3b56cd1 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -92,7 +92,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None): """ - Allows data older than 5 days old to be sent to MixPanel. + Allows data older than 5 days old to be sent to Mixpanel. API Notes: https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days @@ -101,7 +101,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, import datetime from your_app.conf import YOUR_MIXPANEL_TOKEN, YOUR_MIXPANEL_API_KEY - mp = MixPanel(YOUR_TOKEN) + mp = Mixpanel(YOUR_TOKEN) # Django queryset to get an old event old_event = SomeEvent.objects.get(create_date__lt=datetime.datetime.now() - datetime.timedelta.days(6)) From 2e43f00ce964813ed7ef93d3503724fb4990a41e Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 10 Mar 2015 08:37:29 -0700 Subject: [PATCH 013/208] Include README in manifest --- CHANGES.txt | 3 +++ MANIFEST.in | 1 + mixpanel/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 MANIFEST.in diff --git a/CHANGES.txt b/CHANGES.txt index 82aaa54..8c788b2 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.0.2 +* Fix packaging. + v4.0.1 * Fix mutable default arguments. * Allow serialization of datetime instances. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bb3ec5f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 3b56cd1..f589218 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -15,7 +15,7 @@ import urllib import urllib2 -VERSION = '4.0.1' +VERSION = '4.0.2' class DatetimeSerializer(json.JSONEncoder): diff --git a/setup.py b/setup.py index 3350ac9..8022839 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='mixpanel-py', - version='4.0.1', + version='4.0.2', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', packages=['mixpanel'], From dd35e9a5ecc1d7f1dd3894755d7d95609363ac6a Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 16 Mar 2015 17:24:03 -0700 Subject: [PATCH 014/208] Replace unittest with py.test --- tests.py => test_mixpanel.py | 157 ++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 77 deletions(-) rename tests.py => test_mixpanel.py (75%) mode change 100755 => 100644 diff --git a/tests.py b/test_mixpanel.py old mode 100755 new mode 100644 similarity index 75% rename from tests.py rename to test_mixpanel.py index 27f00a0..0760721 --- a/tests.py +++ b/test_mixpanel.py @@ -1,21 +1,17 @@ -#!/usr/bin/env python import base64 import contextlib import datetime import json import time -import unittest import urlparse -try: - from mock import Mock, patch -except ImportError: - print 'mixpanel-python requires the mock package to run the test suite' - raise +from mock import Mock, patch import mixpanel + class LogConsumer(object): + def __init__(self): self.log = [] @@ -25,16 +21,18 @@ def send(self, endpoint, event, api_key=None): else: self.log.append((endpoint, json.loads(event))) -class MixpanelTestCase(unittest.TestCase): - def setUp(self): - self.TOKEN = '12345' + +class TestMixpanel: + TOKEN = '12345' + + def setup_method(self, method): self.consumer = LogConsumer() self.mp = mixpanel.Mixpanel('12345', consumer=self.consumer) self.mp._now = lambda: 1000.1 def test_track(self): self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'events', { 'event': 'button press', 'properties': { @@ -47,13 +45,13 @@ def test_track(self): '$lib_version': mixpanel.VERSION, } } - )]) + )] def test_import_data(self): " Unit test for the `import_data` method. " timestamp = time.time() self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'imports', { 'event': 'button press', 'properties': { @@ -67,12 +65,12 @@ def test_import_data(self): }, }, 'MY_API_KEY' - )]) + )] def test_track_meta(self): self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}, meta={'ip': 0}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'events', { 'event': 'button press', 'properties': { @@ -86,11 +84,11 @@ def test_track_meta(self): }, 'ip': 0, } - )]) + )] def test_people_set(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -100,11 +98,11 @@ def test_people_set(self): 'favorite color': 'purple', }, } - )]) + )] def test_people_set_once(self): self.mp.people_set_once('amq', {'birth month': 'october', 'favorite color': 'purple'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -114,11 +112,11 @@ def test_people_set_once(self): 'favorite color': 'purple', }, } - )]) + )] def test_people_increment(self): self.mp.people_increment('amq', {'Albums Released': 1}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -127,11 +125,11 @@ def test_people_increment(self): 'Albums Released': 1, }, } - )]) + )] def test_people_append(self): self.mp.people_append('amq', {'birth month': 'october', 'favorite color': 'purple'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -141,11 +139,11 @@ def test_people_append(self): 'favorite color': 'purple', }, } - )]) + )] def test_people_union(self): self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -154,22 +152,22 @@ def test_people_union(self): 'Albums': ['Diamond Dogs'], }, } - )]) + )] def test_people_unset(self): self.mp.people_unset('amq', ['Albums', 'Singles']) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['Albums', 'Singles'], } - )]) + )] def test_people_track_charge(self): self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -181,23 +179,23 @@ def test_people_track_charge(self): }, }, } - )]) + )] def test_people_clear_charges(self): self.mp.people_clear_charges('amq') - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['$transactions'], } - )]) + )] def test_people_set_created_date_string(self): created = '2014-02-14T01:02:03' self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -207,12 +205,12 @@ def test_people_set_created_date_string(self): 'favorite color': 'purple', }, } - )]) + )] def test_people_set_created_date_datetime(self): created = datetime.datetime(2014, 2, 14, 1, 2, 3) self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -222,25 +220,24 @@ def test_people_set_created_date_datetime(self): 'favorite color': 'purple', }, } - )]) + )] def test_alias(self): mock_response = Mock() mock_response.read.return_value = '{"status":1, "error": null}' with patch('urllib2.urlopen', return_value=mock_response) as urlopen: self.mp.alias('ALIAS', 'ORIGINAL ID') - self.assertEqual(self.consumer.log, []) - - self.assertEqual(urlopen.call_count, 1) + assert self.consumer.log == [] + assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args - self.assertEqual(request.get_full_url(), 'https://api.mixpanel.com/track') - self.assertEqual(request.get_data(), 'ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') + assert request.get_full_url() == 'https://api.mixpanel.com/track' + assert request.get_data() == 'ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1' def test_people_meta(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, meta={'$ip': 0, '$ignore_time': True}) - self.assertEqual(self.consumer.log, [( + assert self.consumer.log == [( 'people', { '$time': int(self.mp._now() * 1000), '$token': self.TOKEN, @@ -252,11 +249,14 @@ def test_people_meta(self): '$ip': 0, '$ignore_time': True, } - )]) + )] + -class ConsumerTestCase(unittest.TestCase): - def setUp(self): - self.consumer = mixpanel.Consumer(request_timeout=30) +class TestConsumer: + + @classmethod + def setup_class(cls): + cls.consumer = mixpanel.Consumer(request_timeout=30) @contextlib.contextmanager def _assertSends(self, expect_url, expect_data): @@ -265,15 +265,15 @@ def _assertSends(self, expect_url, expect_data): with patch('urllib2.urlopen', return_value=mock_response) as urlopen: yield - self.assertEqual(urlopen.call_count, 1) + assert urlopen.call_count == 1 (call_args, kwargs) = urlopen.call_args (request,) = call_args timeout = kwargs.get('timeout', None) - self.assertEqual(request.get_full_url(), expect_url) - self.assertEqual(request.get_data(), expect_data) - self.assertEqual(timeout, self.consumer._request_timeout) + assert request.get_full_url() == expect_url + assert request.get_data() == expect_data + assert timeout == self.consumer._request_timeout def test_send_events(self): with self._assertSends('https://api.mixpanel.com/track', 'ip=0&data=IkV2ZW50Ig%3D%3D&verbose=1'): @@ -283,47 +283,53 @@ def test_send_people(self): with self._assertSends('https://api.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1'): self.consumer.send('people', '"People"') -class BufferedConsumerTestCase(unittest.TestCase): - def setUp(self): - self.MAX_LENGTH = 10 - self.consumer = mixpanel.BufferedConsumer(self.MAX_LENGTH) - self.mock = Mock() - self.mock.read.return_value = '{"status":1, "error": null}' + +class TestBufferedConsumer: + + @classmethod + def setup_class(cls): + cls.MAX_LENGTH = 10 + cls.consumer = mixpanel.BufferedConsumer(cls.MAX_LENGTH) + cls.mock = Mock() + cls.mock.read.return_value = '{"status":1, "error": null}' def test_buffer_hold_and_flush(self): with patch('urllib2.urlopen', return_value=self.mock) as urlopen: self.consumer.send('events', '"Event"') - self.assertTrue(not self.mock.called) + assert not self.mock.called self.consumer.flush() - self.assertEqual(urlopen.call_count, 1) + assert urlopen.call_count == 1 (call_args, kwargs) = urlopen.call_args (request,) = call_args timeout = kwargs.get('timeout', None) - self.assertEqual(request.get_full_url(), 'https://api.mixpanel.com/track') - self.assertEqual(request.get_data(), 'ip=0&data=WyJFdmVudCJd&verbose=1') - self.assertIsNone(timeout) + assert request.get_full_url() == 'https://api.mixpanel.com/track' + assert request.get_data() == 'ip=0&data=WyJFdmVudCJd&verbose=1' + assert timeout is None def test_buffer_fills_up(self): with patch('urllib2.urlopen', return_value=self.mock) as urlopen: for i in xrange(self.MAX_LENGTH - 1): self.consumer.send('events', '"Event"') - self.assertTrue(not self.mock.called) + assert not self.mock.called self.consumer.send('events', '"Last Event"') - self.assertEqual(urlopen.call_count, 1) + assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args - self.assertEqual(request.get_full_url(), 'https://api.mixpanel.com/track') - self.assertEqual(request.get_data(), 'ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') + assert request.get_full_url() == 'https://api.mixpanel.com/track' + assert request.get_data() == 'ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1' -class FunctionalTestCase(unittest.TestCase): - def setUp(self): - self.TOKEN = '12345' - self.mp = mixpanel.Mixpanel(self.TOKEN) - self.mp._now = lambda: 1000 + +class TestFunctional: + + @classmethod + def setup_class(cls): + cls.TOKEN = '12345' + cls.mp = mixpanel.Mixpanel(cls.TOKEN) + cls.mp._now = lambda: 1000 @contextlib.contextmanager def _assertRequested(self, expect_url, expect_data): @@ -332,15 +338,15 @@ def _assertRequested(self, expect_url, expect_data): with patch('urllib2.urlopen', return_value=mock_response) as urlopen: yield - self.assertEqual(urlopen.call_count, 1) + assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args - self.assertEqual(request.get_full_url(), expect_url) + assert request.get_full_url() == expect_url data = urlparse.parse_qs(request.get_data()) - self.assertEqual(len(data['data']), 1) + assert len(data['data']) == 1 payload_encoded = data['data'][0] payload_json = base64.b64decode(payload_encoded) payload = json.loads(payload_json) - self.assertEqual(payload, expect_data) + assert payload == expect_data def test_track_functional(self): # XXX this includes $lib_version, which means the test breaks @@ -353,6 +359,3 @@ def test_people_set_functional(self): expect_data = {u'$distinct_id': u'amq', u'$set': {u'birth month': u'october', u'favorite color': u'purple'}, u'$time': 1000000, u'$token': u'12345'} with self._assertRequested('https://api.mixpanel.com/engage', expect_data): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) - -if __name__ == "__main__": - unittest.main() From 628dfa74f8aef5f97b475211ee0bbef69b2dd126 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 16 Mar 2015 17:49:14 -0700 Subject: [PATCH 015/208] Make query-string tests more resilient Previously we were checking for strict equality, but we don't actually care about either item order in the query string itself or key order in the b64-encoded data dict. --- test_mixpanel.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index 0760721..5416c49 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,4 +1,5 @@ import base64 +import cgi import contextlib import datetime import json @@ -22,6 +23,16 @@ def send(self, endpoint, event, api_key=None): self.log.append((endpoint, json.loads(event))) +# Convert a query string with base64 data into a dict for safe comparison. +def qs(s): + blob = cgi.parse_qs(s) + if 'data' in blob: + if len(blob['data']) != 1: + pytest.fail('found multi-item data: %s' % blob['data']) + blob['data'] = json.loads(base64.b64decode(blob['data'][0])) + return blob + + class TestMixpanel: TOKEN = '12345' @@ -48,7 +59,6 @@ def test_track(self): )] def test_import_data(self): - " Unit test for the `import_data` method. " timestamp = time.time() self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue'}) assert self.consumer.log == [( @@ -232,7 +242,8 @@ def test_alias(self): ((request,), _) = urlopen.call_args assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert request.get_data() == 'ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1' + assert qs(request.get_data()) == \ + qs('ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') def test_people_meta(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, @@ -272,7 +283,7 @@ def _assertSends(self, expect_url, expect_data): timeout = kwargs.get('timeout', None) assert request.get_full_url() == expect_url - assert request.get_data() == expect_data + assert qs(request.get_data()) == qs(expect_data) assert timeout == self.consumer._request_timeout def test_send_events(self): @@ -306,7 +317,7 @@ def test_buffer_hold_and_flush(self): timeout = kwargs.get('timeout', None) assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert request.get_data() == 'ip=0&data=WyJFdmVudCJd&verbose=1' + assert qs(request.get_data()) == qs('ip=0&data=WyJFdmVudCJd&verbose=1') assert timeout is None def test_buffer_fills_up(self): @@ -320,7 +331,8 @@ def test_buffer_fills_up(self): assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert request.get_data() == 'ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1' + assert qs(request.get_data()) == \ + qs('ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') class TestFunctional: From 489c39f73f80171000b3bbee484c85aba488b68e Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 16 Mar 2015 17:53:27 -0700 Subject: [PATCH 016/208] Add tox --- .gitignore | 3 ++- requirements-testing.txt | 2 ++ tox.ini | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 requirements-testing.txt create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index 7fdea58..936005b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -*.pyc +*.py[cod] *.egg-info +.tox diff --git a/requirements-testing.txt b/requirements-testing.txt new file mode 100644 index 0000000..057c09e --- /dev/null +++ b/requirements-testing.txt @@ -0,0 +1,2 @@ +mock==1.0.1 +pytest==2.6.4 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..97f5716 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ +[tox] +envlist = py26, py27 + +[testenv] +deps = -rrequirements-testing.txt +commands = py.test {posargs} From d08d12a6ec78fcb829d9cb7236f13c12a69da683 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 16 Mar 2015 18:59:59 -0700 Subject: [PATCH 017/208] VERSION -> __version__ --- mixpanel/__init__.py | 6 +++--- test_mixpanel.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index f589218..5fc4de2 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -15,7 +15,7 @@ import urllib import urllib2 -VERSION = '4.0.2' +__version__ = '4.0.2' class DatetimeSerializer(json.JSONEncoder): @@ -77,7 +77,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): 'distinct_id': distinct_id, 'time': int(self._now()), 'mp_lib': 'python', - '$lib_version': VERSION, + '$lib_version': __version__, } if properties: all_properties.update(properties) @@ -120,7 +120,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, 'distinct_id': distinct_id, 'time': int(timestamp), 'mp_lib': 'python', - '$lib_version': VERSION, + '$lib_version': __version__, } if properties: all_properties.update(properties) diff --git a/test_mixpanel.py b/test_mixpanel.py index 5416c49..5307ee1 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -53,7 +53,7 @@ def test_track(self): 'distinct_id': 'ID', 'time': int(self.mp._now()), 'mp_lib': 'python', - '$lib_version': mixpanel.VERSION, + '$lib_version': mixpanel.__version__, } } )] @@ -71,7 +71,7 @@ def test_import_data(self): 'distinct_id': 'ID', 'time': int(timestamp), 'mp_lib': 'python', - '$lib_version': mixpanel.VERSION, + '$lib_version': mixpanel.__version__, }, }, 'MY_API_KEY' @@ -90,7 +90,7 @@ def test_track_meta(self): 'distinct_id': 'ID', 'time': int(self.mp._now()), 'mp_lib': 'python', - '$lib_version': mixpanel.VERSION, + '$lib_version': mixpanel.__version__, }, 'ip': 0, } @@ -363,7 +363,7 @@ def _assertRequested(self, expect_url, expect_data): def test_track_functional(self): # XXX this includes $lib_version, which means the test breaks # every time we release. - expect_data = {u'event': {u'color': u'blue', u'size': u'big'}, u'properties': {u'mp_lib': u'python', u'token': u'12345', u'distinct_id': u'button press', u'$lib_version': unicode(mixpanel.VERSION), u'time': 1000}} + expect_data = {u'event': {u'color': u'blue', u'size': u'big'}, u'properties': {u'mp_lib': u'python', u'token': u'12345', u'distinct_id': u'button press', u'$lib_version': unicode(mixpanel.__version__), u'time': 1000}} with self._assertRequested('https://api.mixpanel.com/track', expect_data): self.mp.track('button press', {'size': 'big', 'color': 'blue'}) From e8bd59015b3f4d6a9e3e17c8eaebd08c23078463 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 16 Mar 2015 19:00:23 -0700 Subject: [PATCH 018/208] Clean up setup.py --- setup.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index 8022839..b139edd 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,28 @@ -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from codecs import open +from os import path +from setuptools import setup, find_packages + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() setup( name='mixpanel-py', version='4.0.2', + description='Official Mixpanel library for Python', + long_description=long_description, + url='https://github.com/mixpanel/mixpanel-python', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', - packages=['mixpanel'], - url='https://github.com/mixpanel/mixpanel-python', - description='Official Mixpanel library for Python', - long_description=open('README.md').read(), + license='Apache', + classifiers=[ 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2 :: Only', - ] + ], + + keywords='mixpanel analytics', + packages=find_packages(), ) From fc72f939c2e3ed634840a5acb6610a14df975c7a Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 11:30:02 -0700 Subject: [PATCH 019/208] DRY up version string in setup.py --- setup.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index b139edd..53c1a12 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,25 @@ from codecs import open from os import path +import re from setuptools import setup, find_packages -here = path.abspath(path.dirname(__file__)) +def read(*paths): + filename = path.join(path.abspath(path.dirname(__file__)), *paths) + with open(filename, encoding='utf-8') as f: + return f.read() -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() +def find_version(*paths): + contents = read(*paths) + match = re.search(r'^__version__ = [\'"]([^\'"]+)[\'"]', contents, re.M) + if not match: + raise RuntimeError('Unable to find version string.') + return match.group(1) setup( name='mixpanel-py', - version='4.0.2', + version=find_version('mixpanel', '__init__.py'), description='Official Mixpanel library for Python', - long_description=long_description, + long_description=read('README.md'), url='https://github.com/mixpanel/mixpanel-python', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', From 1850e7f98d88a096a9e2bd5a272b0efe453318aa Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 11:55:01 -0700 Subject: [PATCH 020/208] Include VERSION for backward compat --- mixpanel/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 5fc4de2..a7dc217 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -16,6 +16,7 @@ import urllib2 __version__ = '4.0.2' +VERSION = __version__ # TODO: remove when bumping major version. class DatetimeSerializer(json.JSONEncoder): From d2dd302d569892201b774ecf3d5bcb7a1fa0941f Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 11:58:19 -0700 Subject: [PATCH 021/208] Add .travis.yml --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a5e3ac5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - "2.6" + - "2.7" +install: + - "pip install -r requirements-testing.txt" +script: py.test From c723e0785ca557493fffbfa14e20220d627546bd Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 15:25:52 -0700 Subject: [PATCH 022/208] Correct typo --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index a7dc217..43232e1 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -322,7 +322,7 @@ def people_update(self, message, meta=None): class MixpanelException(Exception): """ - MixpanelExceptions will be thrown if the server can't recieve + MixpanelExceptions will be thrown if the server can't receive our events or updates for some reason- for example, if we can't connect to the Internet. """ From 58c656fafab7e12336067301068b34214dc6c28c Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 16:54:05 -0700 Subject: [PATCH 023/208] README md -> rst; fixes #34 --- MANIFEST.in | 1 - README.md | 36 ------------------------------------ README.rst | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- 4 files changed, 52 insertions(+), 38 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 README.md create mode 100644 README.rst diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index bb3ec5f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include README.md diff --git a/README.md b/README.md deleted file mode 100644 index ee65458..0000000 --- a/README.md +++ /dev/null @@ -1,36 +0,0 @@ -mixpanel-python -=============== -This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. - -Installation ------------- -The library can be installed using pip: - - pip install mixpanel-py - -Getting Started ---------------- -Typical usage usually looks like this: - - #!/usr/bin/env python - from mixpanel import Mixpanel - - mp = Mixpanel(YOUR_TOKEN) - - # tracks an event with certain properties - mp.track(USER_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) - - # sends an update to a user profile - mp.people_set(USER_ID, {'$first_name' : 'Amy', 'favorite color': 'red'}) - -You can use an instance of the Mixpanel class for sending all of your events and people updates. - -Additional Information ----------------------- -[Help Docs](https://www.mixpanel.com/help/reference/python) - -[Full Documentation](http://mixpanel.github.io/mixpanel-python/) - -[mixpanel-python-asyc](https://github.com/jessepollak/mixpanel-python-async) a third party tool for sending data asynchronously from the tracking python process. - -[mixpanel-py3](https://github.com/MyGGaN/mixpanel-python) a fork of this library that supports Python 3, and some additional features, maintained by Fredrik Svensson diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..0f4da21 --- /dev/null +++ b/README.rst @@ -0,0 +1,51 @@ +mixpanel-python |travis-badge| +============================== + +This is the official Mixpanel Python library. This library allows for +server-side integration of Mixpanel. + + +Installation +------------ + +The library can be installed using pip:: + + pip install mixpanel-py + + +Getting Started +--------------- + +Typical usage usually looks like this:: + + from mixpanel import Mixpanel + + mp = Mixpanel(YOUR_TOKEN) + + # tracks an event with certain properties + mp.track(USER_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) + + # sends an update to a user profile + mp.people_set(USER_ID, {'$first_name' : 'Amy', 'favorite color': 'red'}) + +You can use an instance of the Mixpanel class for sending all of your events +and people updates. + + +Additional Information +---------------------- + +* `Help Docs`_ +* `Full Documentation`_ +* mixpanel-python-async_; a third party tool for sending data asynchronously + from the tracking python process. +* mixpanel-py3_; a fork of this library that supports Python 3, and some + additional features, maintained by Fredrik Svensson. + + +.. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-ruby.svg?branch=master + :target: https://travis-ci.org/mixpanel/mixpanel-ruby +.. _Help Docs: https://www.mixpanel.com/help/reference/python +.. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ +.. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async +.. _mixpanel-py3: https://github.com/MyGGaN/mixpanel-python diff --git a/setup.py b/setup.py index 53c1a12..9f79ccc 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def find_version(*paths): name='mixpanel-py', version=find_version('mixpanel', '__init__.py'), description='Official Mixpanel library for Python', - long_description=read('README.md'), + long_description=read('README.rst'), url='https://github.com/mixpanel/mixpanel-python', author='Mixpanel, Inc.', author_email='dev@mixpanel.com', From 3100c012d3c2eaebce2b3e60f2253ab5f84ebe6d Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 17 Mar 2015 16:56:03 -0700 Subject: [PATCH 024/208] Fix typo in README --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0f4da21..f597221 100644 --- a/README.rst +++ b/README.rst @@ -43,8 +43,8 @@ Additional Information additional features, maintained by Fredrik Svensson. -.. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-ruby.svg?branch=master - :target: https://travis-ci.org/mixpanel/mixpanel-ruby +.. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master + :target: https://travis-ci.org/mixpanel/mixpanel-python .. _Help Docs: https://www.mixpanel.com/help/reference/python .. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ .. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async From 0cec30ab109a0804a41d4a1dea7ab550e3f8ca25 Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Wed, 18 Mar 2015 18:46:51 +0000 Subject: [PATCH 025/208] Fix missing import in unit tests pytest was not imported, causing a NameError when trying to report a test failure in qs() --- test_mixpanel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test_mixpanel.py b/test_mixpanel.py index 5307ee1..982815b 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -7,6 +7,7 @@ import urlparse from mock import Mock, patch +import pytest import mixpanel From 340ebc0ac63f4ccd6e84513c21616b57da41a7ad Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 6 Apr 2015 18:53:42 -0700 Subject: [PATCH 026/208] Remove outdated comment --- test_mixpanel.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index 982815b..66fbcac 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -362,8 +362,6 @@ def _assertRequested(self, expect_url, expect_data): assert payload == expect_data def test_track_functional(self): - # XXX this includes $lib_version, which means the test breaks - # every time we release. expect_data = {u'event': {u'color': u'blue', u'size': u'big'}, u'properties': {u'mp_lib': u'python', u'token': u'12345', u'distinct_id': u'button press', u'$lib_version': unicode(mixpanel.__version__), u'time': 1000}} with self._assertRequested('https://api.mixpanel.com/track', expect_data): self.mp.track('button press', {'size': 'big', 'color': 'blue'}) From f903ead833916e682e685afb2fd693d641cd68db Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 6 Apr 2015 17:19:54 -0700 Subject: [PATCH 027/208] Add Python3 support --- .travis.yml | 3 +++ mixpanel/__init__.py | 22 ++++++++++--------- setup.py | 4 +++- test_mixpanel.py | 50 ++++++++++++++++++++++++-------------------- tox.ini | 2 +- 5 files changed, 46 insertions(+), 35 deletions(-) diff --git a/.travis.yml b/.travis.yml index a5e3ac5..cd186d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,9 @@ language: python python: - "2.6" - "2.7" + - "3.3" + - "3.4" install: + - "pip install ." - "pip install -r requirements-testing.txt" script: py.test diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 43232e1..313a9cc 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -8,12 +8,14 @@ The Consumer and BufferedConsumer classes allow callers to customize the IO characteristics of their tracking. """ +from __future__ import absolute_import, unicode_literals import base64 import datetime import json import time -import urllib -import urllib2 + +import six +from six.moves import urllib __version__ = '4.0.2' VERSION = __version__ # TODO: remove when bumping major version. @@ -366,27 +368,27 @@ def send(self, endpoint, json_message, api_key=None): def _write_request(self, request_url, json_message, api_key=None): data = { - 'data': base64.b64encode(json_message), + 'data': base64.b64encode(json_message.encode('utf8')), 'verbose': 1, 'ip': 0, } if api_key: data.update({'api_key': api_key}) - encoded_data = urllib.urlencode(data) + encoded_data = urllib.parse.urlencode(data).encode('utf8') try: - request = urllib2.Request(request_url, encoded_data) + request = urllib.request.Request(request_url, encoded_data) # Note: We don't send timeout=None here, because the timeout in urllib2 defaults to # an internal socket timeout, not None. if self._request_timeout is not None: - response = urllib2.urlopen(request, timeout=self._request_timeout).read() + response = urllib.request.urlopen(request, timeout=self._request_timeout).read() else: - response = urllib2.urlopen(request).read() - except urllib2.HTTPError as e: - raise MixpanelException(e) + response = urllib.request.urlopen(request).read() + except urllib.error.HTTPError as e: + raise six.raise_from(MixpanelException(e), e) try: - response = json.loads(response) + response = json.loads(response.decode('utf8')) except ValueError: raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response)) diff --git a/setup.py b/setup.py index 9f79ccc..4f8100b 100644 --- a/setup.py +++ b/setup.py @@ -24,11 +24,13 @@ def find_version(*paths): author='Mixpanel, Inc.', author_email='dev@mixpanel.com', license='Apache', + install_requires=['six'], classifiers=[ 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2 :: Only', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', ], keywords='mixpanel analytics', diff --git a/test_mixpanel.py b/test_mixpanel.py index 66fbcac..cb21790 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,13 +1,15 @@ +from __future__ import absolute_import, unicode_literals import base64 import cgi import contextlib import datetime import json import time -import urlparse from mock import Mock, patch import pytest +import six +from six.moves import range, urllib import mixpanel @@ -26,11 +28,13 @@ def send(self, endpoint, event, api_key=None): # Convert a query string with base64 data into a dict for safe comparison. def qs(s): + if isinstance(s, six.binary_type): + s = s.decode('utf8') blob = cgi.parse_qs(s) - if 'data' in blob: - if len(blob['data']) != 1: - pytest.fail('found multi-item data: %s' % blob['data']) - blob['data'] = json.loads(base64.b64decode(blob['data'][0])) + if len(blob['data']) != 1: + pytest.fail('found multi-item data: %s' % blob['data']) + json_bytes = base64.b64decode(blob['data'][0]) + blob['data'] = json.loads(json_bytes.decode('utf8')) return blob @@ -235,15 +239,15 @@ def test_people_set_created_date_datetime(self): def test_alias(self): mock_response = Mock() - mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value=mock_response) as urlopen: + mock_response.read.return_value = six.b('{"status":1, "error": null}') + with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: self.mp.alias('ALIAS', 'ORIGINAL ID') assert self.consumer.log == [] assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.get_data()) == \ + assert qs(request.data) == \ qs('ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') def test_people_meta(self): @@ -273,8 +277,8 @@ def setup_class(cls): @contextlib.contextmanager def _assertSends(self, expect_url, expect_data): mock_response = Mock() - mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value=mock_response) as urlopen: + mock_response.read.return_value = six.b('{"status":1, "error": null}') + with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: yield assert urlopen.call_count == 1 @@ -284,7 +288,7 @@ def _assertSends(self, expect_url, expect_data): timeout = kwargs.get('timeout', None) assert request.get_full_url() == expect_url - assert qs(request.get_data()) == qs(expect_data) + assert qs(request.data) == qs(expect_data) assert timeout == self.consumer._request_timeout def test_send_events(self): @@ -303,10 +307,10 @@ def setup_class(cls): cls.MAX_LENGTH = 10 cls.consumer = mixpanel.BufferedConsumer(cls.MAX_LENGTH) cls.mock = Mock() - cls.mock.read.return_value = '{"status":1, "error": null}' + cls.mock.read.return_value = six.b('{"status":1, "error": null}') def test_buffer_hold_and_flush(self): - with patch('urllib2.urlopen', return_value=self.mock) as urlopen: + with patch('six.moves.urllib.request.urlopen', return_value=self.mock) as urlopen: self.consumer.send('events', '"Event"') assert not self.mock.called self.consumer.flush() @@ -318,12 +322,12 @@ def test_buffer_hold_and_flush(self): timeout = kwargs.get('timeout', None) assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.get_data()) == qs('ip=0&data=WyJFdmVudCJd&verbose=1') + assert qs(request.data) == qs('ip=0&data=WyJFdmVudCJd&verbose=1') assert timeout is None def test_buffer_fills_up(self): - with patch('urllib2.urlopen', return_value=self.mock) as urlopen: - for i in xrange(self.MAX_LENGTH - 1): + with patch('six.moves.urllib.request.urlopen', return_value=self.mock) as urlopen: + for i in range(self.MAX_LENGTH - 1): self.consumer.send('events', '"Event"') assert not self.mock.called @@ -332,7 +336,7 @@ def test_buffer_fills_up(self): assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.get_data()) == \ + assert qs(request.data) == \ qs('ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') @@ -347,26 +351,26 @@ def setup_class(cls): @contextlib.contextmanager def _assertRequested(self, expect_url, expect_data): mock_response = Mock() - mock_response.read.return_value = '{"status":1, "error": null}' - with patch('urllib2.urlopen', return_value=mock_response) as urlopen: + mock_response.read.return_value = six.b('{"status":1, "error": null}') + with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: yield assert urlopen.call_count == 1 ((request,), _) = urlopen.call_args assert request.get_full_url() == expect_url - data = urlparse.parse_qs(request.get_data()) + data = urllib.parse.parse_qs(request.data.decode('utf8')) assert len(data['data']) == 1 payload_encoded = data['data'][0] - payload_json = base64.b64decode(payload_encoded) + payload_json = base64.b64decode(payload_encoded).decode('utf8') payload = json.loads(payload_json) assert payload == expect_data def test_track_functional(self): - expect_data = {u'event': {u'color': u'blue', u'size': u'big'}, u'properties': {u'mp_lib': u'python', u'token': u'12345', u'distinct_id': u'button press', u'$lib_version': unicode(mixpanel.__version__), u'time': 1000}} + expect_data = {'event': {'color': 'blue', 'size': 'big'}, 'properties': {'mp_lib': 'python', 'token': '12345', 'distinct_id': 'button press', '$lib_version': mixpanel.__version__, 'time': 1000}} with self._assertRequested('https://api.mixpanel.com/track', expect_data): self.mp.track('button press', {'size': 'big', 'color': 'blue'}) def test_people_set_functional(self): - expect_data = {u'$distinct_id': u'amq', u'$set': {u'birth month': u'october', u'favorite color': u'purple'}, u'$time': 1000000, u'$token': u'12345'} + expect_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000000, '$token': '12345'} with self._assertRequested('https://api.mixpanel.com/engage', expect_data): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) diff --git a/tox.ini b/tox.ini index 97f5716..10bbae7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27 +envlist = py26, py27, py34 [testenv] deps = -rrequirements-testing.txt From c1aa9fb16afcc06d9313b925a8c49ba2145b189e Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 16 Apr 2015 14:30:48 -0700 Subject: [PATCH 028/208] Rename mixpanel-py -> mixpanel --- README.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index f597221..7153848 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,7 @@ Installation The library can be installed using pip:: - pip install mixpanel-py + pip install mixpanel Getting Started diff --git a/setup.py b/setup.py index 9f79ccc..50837a8 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ def find_version(*paths): return match.group(1) setup( - name='mixpanel-py', + name='mixpanel', version=find_version('mixpanel', '__init__.py'), description='Official Mixpanel library for Python', long_description=read('README.rst'), From fd7640c893f9a26ed0fc718dd6344e9ec127aa54 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 16 Apr 2015 15:22:35 -0700 Subject: [PATCH 029/208] Add setup.cfg for universal wheel support --- .gitignore | 2 ++ setup.cfg | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index 936005b..136f21d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.py[cod] *.egg-info .tox +build +dist diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5e40900 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 From 48d30154340ce56e02de9cca751c3c2bed560f02 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 16 Apr 2015 15:25:03 -0700 Subject: [PATCH 030/208] Update description of mixpanel-py3 in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7153848..38f6246 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,7 @@ Additional Information * `Full Documentation`_ * mixpanel-python-async_; a third party tool for sending data asynchronously from the tracking python process. -* mixpanel-py3_; a fork of this library that supports Python 3, and some +* mixpanel-py3_; a Python 3-only fork of this library that includes some additional features, maintained by Fredrik Svensson. From 861b8c25b746a11bf788b3d4507a3d881edaeded Mon Sep 17 00:00:00 2001 From: Michael Stewart Date: Thu, 30 Apr 2015 11:48:57 -0700 Subject: [PATCH 031/208] Fix minor bug is error message --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 313a9cc..1bae45e 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -364,7 +364,7 @@ def send(self, endpoint, json_message, api_key=None): if endpoint in self._endpoints: self._write_request(self._endpoints[endpoint], json_message, api_key) else: - raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(self._endpoints.keys())) + raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) def _write_request(self, request_url, json_message, api_key=None): data = { From f725491be99d016d330ea8013b0f4a883b41670b Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 6 May 2015 14:07:29 -0700 Subject: [PATCH 032/208] Fix and add tests for unknown consumer endpoints --- mixpanel/__init__.py | 2 +- test_mixpanel.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 1bae45e..b0bc31e 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -434,7 +434,7 @@ def send(self, endpoint, json_message): :raises: MixpanelException """ if endpoint not in self._buffers: - raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(self._buffers.keys())) + raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) buf = self._buffers[endpoint] buf.append(json_message) diff --git a/test_mixpanel.py b/test_mixpanel.py index cb21790..7839309 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -299,6 +299,10 @@ def test_send_people(self): with self._assertSends('https://api.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1'): self.consumer.send('people', '"People"') + def test_unknown_endpoint(self): + with pytest.raises(mixpanel.MixpanelException): + self.consumer.send('unknown', '1') + class TestBufferedConsumer: @@ -339,6 +343,10 @@ def test_buffer_fills_up(self): assert qs(request.data) == \ qs('ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') + def test_unknown_endpoint(self): + with pytest.raises(mixpanel.MixpanelException): + self.consumer.send('unknown', '1') + class TestFunctional: From 6e95b1ffd3e605f6f4abe2a827fa6974d3aee1c0 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 7 Apr 2015 16:35:36 -0700 Subject: [PATCH 033/208] Add Sphinx documentation --- .gitignore | 1 + docs/_static/mixpanel.css | 5 ++++ docs/conf.py | 57 +++++++++++++++++++++++++++++++++++++++ docs/index.rst | 27 +++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 docs/_static/mixpanel.css create mode 100644 docs/conf.py create mode 100644 docs/index.rst diff --git a/.gitignore b/.gitignore index 136f21d..d235539 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .tox build dist +docs/_build diff --git a/docs/_static/mixpanel.css b/docs/_static/mixpanel.css new file mode 100644 index 0000000..e7ec692 --- /dev/null +++ b/docs/_static/mixpanel.css @@ -0,0 +1,5 @@ +@import 'alabaster.css'; + +div.sphinxsidebar h3 { + margin-top: 1em; +} diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..0e4e7dc --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath('..')) + +extensions = [ + 'sphinx.ext.autodoc', +] +autodoc_member_order = 'bysource' + +templates_path = ['_templates'] +source_suffix = '.rst' +master_doc = 'index' + +# General information about the project. +project = u'mixpanel' +copyright = u' 2015, Mixpanel, Inc' +author = u'Mixpanel ' +version = release = '4.0.2' +exclude_patterns = ['_build'] +pygments_style = 'sphinx' + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'alabaster' +html_theme_options = { + 'description': 'The official Mixpanel client library for Python.', + 'github_user': 'mixpanel', + 'github_repo': 'mixpanel-python', + 'github_button': False, + 'travis_button': True, +} + +# Custom sidebar templates, maps document names to template names. +html_sidebars = { + '**': [ + 'about.html', 'localtoc.html', 'searchbox.html', + ] +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] +html_style = 'mixpanel.css' + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# html_extra_path = [] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..1367872 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,27 @@ +Welcome to Mixpanel +=================== + +.. automodule:: mixpanel + + +Primary interface +----------------- + +.. autoclass:: Mixpanel + :members: + + +Built-in consumers +------------------ + +.. autoclass:: Consumer + :members: + +.. autoclass:: BufferedConsumer + :members: + + +Exceptions +---------- + +.. autoexception:: MixpanelException From d417b4f940acca768455f8368b28c5b2c3e66ec3 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 6 May 2015 11:24:59 -0700 Subject: [PATCH 034/208] Update documentation for Sphinx --- docs/index.rst | 11 ++ mixpanel/__init__.py | 376 ++++++++++++++++++++----------------------- 2 files changed, 187 insertions(+), 200 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 1367872..489e91b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,17 @@ Primary interface Built-in consumers ------------------ +A consumer is any object with a ``send`` method which takes two arguments: a +string ``endpoint`` name and a JSON-encoded ``message``. ``send`` is +responsible for appropriately encoding the message and sending it to the named +`Mixpanel API`_ endpoint. + +:class:`~.Mixpanel` instances call their consumer's ``send`` method at the end +of each of their own method calls, after building the JSON message. + +.. _`Mixpanel API`: https://mixpanel.com/help/reference/http + + .. autoclass:: Consumer :members: diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index b0bc31e..e883595 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -1,12 +1,18 @@ -""" -The mixpanel package allows you to easily track events and -update people properties from your python application. +# -*- coding: utf-8 -*- +"""This is the official Mixpanel client library for Python. + +Mixpanel client libraries allow for tracking events and setting properties on +People Analytics profiles from your server-side projects. This is the API +documentation; you may also be interested in the higher-level `usage +documentation`_. If your users are interacting with your application via the +web, you may also be interested in our `JavaScript library`_. -The Mixpanel class is the primary class for tracking events and -sending people analytics updates. +.. _`Javascript library`: https://mixpanel.com/help/reference/javascript +.. _`usage documentation`: https://mixpanel.com/help/reference/python -The Consumer and BufferedConsumer classes allow callers to -customize the IO characteristics of their tracking. +:class:`~.Mixpanel` is the primary class for tracking events and sending People +Analytics updates. :class:`~.Consumer` and :class:`~.BufferedConsumer` allow +callers to customize the IO characteristics of their tracking. """ from __future__ import absolute_import, unicode_literals import base64 @@ -36,21 +42,16 @@ def json_dumps(data): class Mixpanel(object): - """ - Use instances of Mixpanel to track events and send Mixpanel - profile updates from your python code. + """Instances of Mixpanel are used for all events and profile updates. + + :param str token: your project's Mixpanel token + :param consumer: can be used to alter the behavior of tracking (default + :class:`~.Consumer`) + + See `Built-in consumers`_ for details about the consumer interface. """ def __init__(self, token, consumer=None): - """ - Creates a new Mixpanel object, which can be used for all tracking. - - To use mixpanel, create a new Mixpanel object using your - token. Takes in a user token and an optional Consumer (or - anything else with a send() method). If no consumer is - provided, Mixpanel will use the default Consumer, which - communicates one synchronous request for every message. - """ self._token = token self._consumer = consumer or Consumer() @@ -58,22 +59,17 @@ def _now(self): return time.time() def track(self, distinct_id, event_name, properties=None, meta=None): - """ - Notes that an event has occurred, along with a distinct_id - representing the source of that event (for example, a user id), - an event name describing the event and a set of properties - describing that event. Properties are provided as a dict with - string keys and strings, numbers or booleans as values. - - # Track that user "12345"'s credit card was declined - mp.track("12345", "Credit Card Declined") - - # Properties describe the circumstances of the event, - # or aspects of the source or user associated with the event - mp.track("12345", "Welcome Email Sent", { - 'Email Template': 'Pretty Pink Welcome', - 'User Sign-up Cohort': 'July 2013' - }) + """Record an event. + + :param str distinct_id: identifies the user triggering the event + :param str event_name: a name describing the event + :param dict properties: additional data to record; keys should be + strings, and values should be strings, numbers, or booleans + :param dict meta: overrides Mixpanel special properties + + ``properties`` should describe the circumstances of the event, or + aspects of the source or user associated with it. ``meta`` is used + (rarely) to override special values sent in the event object. """ all_properties = { 'token': self._token, @@ -94,29 +90,21 @@ def track(self, distinct_id, event_name, properties=None, meta=None): def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None): - """ - Allows data older than 5 days old to be sent to Mixpanel. - - API Notes: - https://mixpanel.com/docs/api-documentation/importing-events-older-than-31-days - - Usage: - import datetime - from your_app.conf import YOUR_MIXPANEL_TOKEN, YOUR_MIXPANEL_API_KEY - - mp = Mixpanel(YOUR_TOKEN) - - # Django queryset to get an old event - old_event = SomeEvent.objects.get(create_date__lt=datetime.datetime.now() - datetime.timedelta.days(6)) - mp.import_data( - YOUR_MIXPANEL_API_KEY, # These requests require your API key as an extra layer of security - old_event.id, - 'Some Event', - old_event.timestamp, - { - ... your custom properties and meta ... - } - ) + """Record an event that occured more than 5 days in the past. + + :param str api_key: your Mixpanel project's API key + :param str distinct_id: identifies the user triggering the event + :param str event_name: a name describing the event + :param int timestamp: UTC seconds since epoch + :param dict properties: additional data to record; keys should be + strings, and values should be strings, numbers, or booleans + :param dict meta: overrides Mixpanel special properties + + To avoid accidentally recording invalid events, the Mixpanel API's + ``track`` endpoint disallows events that occurred too long ago. This + method can be used to import such events. See our online documentation + for `more details + `__. """ all_properties = { 'token': self._token, @@ -136,19 +124,21 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, self._consumer.send('imports', json_dumps(event), api_key) def alias(self, alias_id, original, meta=None): - """ - Gives custom alias to a people record. - - Calling this method always results in a synchronous HTTP - request to Mixpanel servers. Unlike other methods, this method - will ignore any consumer object provided to the Mixpanel - object on construction. - - Alias sends an update to our servers linking an existing distinct_id - with a new id, so that events and profile updates associated with the - new id will be associated with the existing user's profile and behavior. - Example: - mp.alias('amy@mixpanel.com', '13793') + """Apply a custom alias to a people record. + + :param str alias_id: the new distinct_id + :param str original: the previous distinct_id + :param dict meta: overrides Mixpanel special properties + + Immediately creates a one-way mapping between two ``distinct_ids``. + Events triggered by the new id will be associated with the existing + user's profile and behavior. See our online documentation for `more + details + `__. + + .. note:: + Calling this method *always* results in a synchronous HTTP request + to Mixpanel servers, regardless of any custom consumer. """ sync_consumer = Consumer() event = { @@ -164,14 +154,15 @@ def alias(self, alias_id, original, meta=None): sync_consumer.send('events', json_dumps(event)) def people_set(self, distinct_id, properties, meta=None): - """ - Set properties of a people record. + """Set properties of a people record. - Sets properties of a people record given in JSON object. If the profile - does not exist, creates new profile with these properties. - Example: - mp.people_set('12345', {'Address': '1313 Mockingbird Lane', - 'Birthday': '1948-01-01'}) + :param str distinct_id: the profile to update + :param dict properties: properties to set + :param dict meta: overrides Mixpanel `special properties`_ + + .. _`special properties`: https://mixpanel.com/help/reference/http#people-analytics-updates + + If the profile does not exist, creates a new profile with these properties. """ return self.people_update({ '$distinct_id': distinct_id, @@ -179,14 +170,14 @@ def people_set(self, distinct_id, properties, meta=None): }, meta=meta or {}) def people_set_once(self, distinct_id, properties, meta=None): - """ - Set immutable properties of a people record. + """Set properties of a people record if they are not already set. - Sets properties of a people record given in JSON object. If the profile - does not exist, creates new profile with these properties. Does not - overwrite existing property values. - Example: - mp.people_set_once('12345', {'First Login': "2013-04-01T13:20:00"}) + :param str distinct_id: the profile to update + :param dict properties: properties to set + + Any properties that already exist on the profile will not be + overwritten. If the profile does not exist, creates a new profile with + these properties. """ return self.people_update({ '$distinct_id': distinct_id, @@ -194,14 +185,15 @@ def people_set_once(self, distinct_id, properties, meta=None): }, meta=meta or {}) def people_increment(self, distinct_id, properties, meta=None): - """ - Increments/decrements numerical properties of people record. + """Increment/decrement numerical properties of a people record. + + :param str distinct_id: the profile to update + :param dict properties: properties to increment/decrement; values + should be numeric - Takes in JSON object with keys and numerical values. Adds numerical - values to current property of profile. If property doesn't exist adds - value to zero. Takes in negative values for subtraction. - Example: - mp.people_increment('12345', {'Coins Gathered': 12}) + Adds numerical values to properties of a people record. Nonexistent + properties on the record default to zero. Negative values in + ``properties`` will decrement the given property. """ return self.people_update({ '$distinct_id': distinct_id, @@ -209,15 +201,16 @@ def people_increment(self, distinct_id, properties, meta=None): }, meta=meta or {}) def people_append(self, distinct_id, properties, meta=None): - """ - Appends to the list associated with a property. - - Takes a JSON object containing keys and values, and appends each to a - list associated with the corresponding property name. $appending to a - property that doesn't exist will result in assigning a list with one - element to that property. - Example: - mp.people_append('12345', { "Power Ups": "Bubble Lead" }) + """Append to the list associated with a property. + + :param str distinct_id: the profile to update + :param dict properties: properties to append + + Adds items to list-style properties of a people record. Appending to + nonexistent properties results in a list with a single element. For + example:: + + mp.people_append('123', {'Items': 'Super Arm'}) """ return self.people_update({ '$distinct_id': distinct_id, @@ -225,14 +218,16 @@ def people_append(self, distinct_id, properties, meta=None): }, meta=meta or {}) def people_union(self, distinct_id, properties, meta=None): - """ - Merges the values for a list associated with a property. + """Merge the values of a list associated with a property. + + :param str distinct_id: the profile to update + :param dict properties: properties to merge + + Merges list values in ``properties`` with existing list-style + properties of a people record. Duplicate values are ignored. For + example:: - Takes a JSON object containing keys and list values. The list values in - the request are merged with the existing list on the user profile, - ignoring duplicate list values. - Example: - mp.people_union('12345', {"Items purchased": ["socks", "shirts"]}) + mp.people_union('123', {'Items': ['Super Arm', 'Fire Storm']}) """ return self.people_update({ '$distinct_id': distinct_id, @@ -240,13 +235,10 @@ def people_union(self, distinct_id, properties, meta=None): }, meta=meta or {}) def people_unset(self, distinct_id, properties, meta=None): - """ - Removes properties from a profile. + """Permanently remove properties from a people record. - Takes a JSON list of string property names, and permanently removes the - properties and their values from a profile. - Example: - mp.people_unset('12345', ["Days Overdue"]) + :param str distinct_id: the profile to update + :param list properties: property names to remove """ return self.people_update({ '$distinct_id': distinct_id, @@ -254,13 +246,9 @@ def people_unset(self, distinct_id, properties, meta=None): }, meta=meta) def people_delete(self, distinct_id, meta=None): - """ - Permanently deletes a profile. + """Permanently delete a people record. - Permanently delete the profile from Mixpanel, along with all of its - properties. - Example: - mp.people_delete('12345') + :param str distinct_id: the profile to delete """ return self.people_update({ '$distinct_id': distinct_id, @@ -269,18 +257,15 @@ def people_delete(self, distinct_id, meta=None): def people_track_charge(self, distinct_id, amount, properties=None, meta=None): - """ - Tracks a charge to a user. + """Track a charge on a people record. + + :param str distinct_id: the profile with which to associate the charge + :param numeric amount: number of dollars charged + :param dict properties: extra properties related to the transaction Record that you have charged the current user a certain amount of - money. Charges recorded with track_charge will appear in the Mixpanel + money. Charges recorded with this way will appear in the Mixpanel revenue report. - Example: - #tracks a charge of $50 to user '1234' - mp.people_track_charge('1234', 50) - - #tracks a charge of $50 to user '1234' at a specific time - mp.people_track_charge('1234', 50, {'$time': "2013-04-01T09:02:00"}) """ properties.update({'$amount': amount}) return self.people_append( @@ -288,29 +273,25 @@ def people_track_charge(self, distinct_id, amount, ) def people_clear_charges(self, distinct_id, meta=None): - """ - Clears all charges from a user. + """Permanently clear all charges on a people record. - Clears all charges associated with a user profile on Mixpanel. - Example: - #clear all charges from user '1234' - mp.people_clear_charges('1234') + :param str distinct_id: the profile whose charges will be cleared """ return self.people_unset( distinct_id, ["$transactions"], meta=meta or {}, ) def people_update(self, message, meta=None): - """ - Send a generic update to Mixpanel people analytics. - - Caller is responsible for formatting the update message, as - documented in the Mixpanel HTTP specification, and passing - the message as a dict to update. This - method might be useful if you want to use very new - or experimental features of people analytics from python - The Mixpanel HTTP tracking API is documented at - https://mixpanel.com/help/reference/http + """Send a generic update to Mixpanel people analytics. + + :param dict message: the message to send + + Callers are responsible for formatting the update message as documented + in the `Mixpanel HTTP specification`_. This method may be useful if you + want to use very new or experimental features of people analytics, but + please use the other ``people_*`` methods where possible. + + .. _`Mixpanel HTTP specification`: https://mixpanel.com/help/reference/http """ record = { '$token': self._token, @@ -323,20 +304,25 @@ def people_update(self, message, meta=None): class MixpanelException(Exception): - """ - MixpanelExceptions will be thrown if the server can't receive - our events or updates for some reason- for example, if we can't - connect to the Internet. + """Raised by consumers when unable to send messages. + + This could be caused by a network outage or interruption, or by an invalid + endpoint passed to :meth:`.Consumer.send`. """ pass class Consumer(object): """ - The simple consumer sends an HTTP request directly to the Mixpanel service, - with one request for every call. This is the default consumer for Mixpanel - objects- if you don't provide your own, you get one of these. + A consumer that sends an HTTP request directly to the Mixpanel service, one + per call to :meth:`~.send`. + + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds """ + def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None): self._endpoints = { 'events': events_url or 'https://api.mixpanel.com/track', @@ -346,20 +332,13 @@ def __init__(self, events_url=None, people_url=None, import_url=None, request_ti self._request_timeout = request_timeout def send(self, endpoint, json_message, api_key=None): - """ - Record an event or a profile update. Send is the only method - associated with consumers. Will raise an exception if the endpoint - doesn't exist, if the server is unreachable or for some reason - can't process the message. - - All you need to do to write your own consumer is to implement - a send method of your own. - - :param endpoint: One of 'events' or 'people', the Mixpanel endpoint for sending the data - :type endpoint: str (one of 'events' or 'people') - :param json_message: A json message formatted for the endpoint. - :type json_message: str - :raises: MixpanelException + """Immediately record an event or a profile update. + + :param endpoint: the Mixpanel API endpoint appropriate for the message + :type endpoint: "events" | "people" | "imports" + :param str json_message: a JSON message formatted for the endpoint + :raises MixpanelException: if the endpoint doesn't exist, the server is + unreachable, or the message cannot be processed """ if endpoint in self._endpoints: self._write_request(self._endpoints[endpoint], json_message, api_key) @@ -400,13 +379,22 @@ def _write_request(self, request_url, json_message, api_key=None): class BufferedConsumer(object): """ - BufferedConsumer works just like Consumer, but holds messages in - memory and sends them in batches. This can save bandwidth and - reduce the total amount of time required to post your events. - - Because BufferedConsumers hold events, you need to call flush() - when you're sure you're done sending them. calls to flush() will - send all remaining unsent events being held by the BufferedConsumer. + A consumer that maintains per-endpoint buffers of messages and then sends + them in batches. This can save bandwidth and reduce the total amount of + time required to post your events to Mixpanel. + + .. note:: + Because :class:`~.BufferedConsumer` holds events, you need to call + :meth:`~.flush` when you're sure you're done sending them—for example, + just before your program exits. Calls to :meth:`~.flush` will send all + remaining unsent events being held by the instance. + + :param int max_size: number of :meth:`~.send` calls for a given endpoint to + buffer before flushing automatically + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None): self._consumer = Consumer(events_url, people_url, import_url, request_timeout) @@ -418,20 +406,18 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non self._max_size = min(50, max_size) def send(self, endpoint, json_message): - """ - Record an event or a profile update. Calls to send() will store - the given message in memory, and (when enough messages have been stored) - may trigger a request to Mixpanel's servers. - - Calls to send() may throw an exception, but the exception may be - associated with the message given in an earlier call. If this is the case, - the resulting MixpanelException e will have members e.message and e.endpoint - - :param endpoint: One of 'events' or 'people', the Mixpanel endpoint for sending the data - :type endpoint: str (one of 'events' or 'people') - :param json_message: A json message formatted for the endpoint. - :type json_message: str - :raises: MixpanelException + """Record an event or profile update. + + Internally, adds the message to a buffer, and then flushes the buffer + if it has reached the configured maximum size. Note that exceptions + raised may have been caused by a message buffered by an earlier call to + :meth:`~.send`. + + :param endpoint: the Mixpanel API endpoint appropriate for the message + :type endpoint: "events" | "people" | "imports" + :param str json_message: a JSON message formatted for the endpoint + :raises MixpanelException: if the endpoint doesn't exist, the server is + unreachable, or any buffered message cannot be processed """ if endpoint not in self._buffers: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) @@ -442,20 +428,10 @@ def send(self, endpoint, json_message): self._flush_endpoint(endpoint) def flush(self): - """ - Send all remaining messages to Mixpanel. - - BufferedConsumers will flush automatically when you call send(), but - you will need to call flush() when you are completely done using the - consumer (for example, when your application exits) to ensure there are - no messages remaining in memory. - - Calls to flush() may raise a MixpanelException if there is a problem - communicating with the Mixpanel servers. In this case, the exception - thrown will have a message property, containing the text of the message, - and an endpoint property containing the endpoint that failed. + """Immediately send all buffered messages to Mixpanel. - :raises: MixpanelException + :raises MixpanelException: if the server is unreachable or any buffered + message cannot be processed """ for endpoint in self._buffers.keys(): self._flush_endpoint(endpoint) From b2a468362be7df88726379fa945b7a38d6e7f79c Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 6 May 2015 16:23:02 -0700 Subject: [PATCH 035/208] Fix reraise in BufferedConsumer._flush_endpoint --- mixpanel/__init__.py | 8 +++++--- test_mixpanel.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index b0bc31e..d075925 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -467,8 +467,10 @@ def _flush_endpoint(self, endpoint): batch_json = '[{0}]'.format(','.join(batch)) try: self._consumer.send(endpoint, batch_json) - except MixpanelException as e: - e.message = 'batch_json' - e.endpoint = endpoint + except MixpanelException as orig_e: + mp_e = MixpanelException(orig_e) + mp_e.message = batch_json + mp_e.endpoint = endpoint + raise six.raise_from(mp_e, orig_e) buf = buf[self._max_size:] self._buffers[endpoint] = buf diff --git a/test_mixpanel.py b/test_mixpanel.py index 7839309..c4a1ad0 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -347,6 +347,17 @@ def test_unknown_endpoint(self): with pytest.raises(mixpanel.MixpanelException): self.consumer.send('unknown', '1') + def test_useful_reraise_in_flush_endpoint(self): + error_mock = Mock() + error_mock.read.return_value = six.b('{"status": 0, "error": "arbitrary error"}') + broken_json = '{broken JSON' + with patch('six.moves.urllib.request.urlopen', return_value=error_mock): + self.consumer.send('events', broken_json) + with pytest.raises(mixpanel.MixpanelException) as excinfo: + self.consumer.flush() + assert excinfo.value.message == '[%s]' % broken_json + assert excinfo.value.endpoint == 'events' + class TestFunctional: From d92dd5faf69f3a743c093396e8699f6747efbadf Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 6 May 2015 16:42:18 -0700 Subject: [PATCH 036/208] Fix people_track_charge calls without properties --- mixpanel/__init__.py | 2 ++ test_mixpanel.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index d075925..b641a44 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -282,6 +282,8 @@ def people_track_charge(self, distinct_id, amount, #tracks a charge of $50 to user '1234' at a specific time mp.people_track_charge('1234', 50, {'$time': "2013-04-01T09:02:00"}) """ + if properties is None: + properties = {} properties.update({'$amount': amount}) return self.people_append( distinct_id, {'$transactions': properties or {}}, meta=meta or {} diff --git a/test_mixpanel.py b/test_mixpanel.py index c4a1ad0..966822d 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -196,6 +196,21 @@ def test_people_track_charge(self): } )] + def test_people_track_charge_without_properties(self): + self.mp.people_track_charge('amq', 12.65) + assert self.consumer.log == [( + 'people', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$distinct_id': 'amq', + '$append': { + '$transactions': { + '$amount': 12.65, + }, + }, + } + )] + def test_people_clear_charges(self): self.mp.people_clear_charges('amq') assert self.consumer.log == [( From e5df499ac9b10a17ff88b73953686744276b1d07 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 28 May 2015 15:44:47 -0700 Subject: [PATCH 037/208] Add build instructions --- BUILD.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 BUILD.rst diff --git a/BUILD.rst b/BUILD.rst new file mode 100644 index 0000000..fc6fede --- /dev/null +++ b/BUILD.rst @@ -0,0 +1,16 @@ +Run tests:: + + tox + +Publish to PyPI:: + + python setup.py sdist bdist_wheel + twine upload dist/* + +Build docs:: + + python setup.py build_sphinx + +Publish docs to GitHub Pages:: + + ghp-import -n -p build/sphinx/html From 6d2c99f7707d0eceea79f826aa2a8778526c97c3 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 4 Jun 2015 10:55:27 -0700 Subject: [PATCH 038/208] Release 4.1.0 --- CHANGES.txt | 7 +++++++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 8c788b2..0697680 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,10 @@ +v4.1.0 +* Add support for Python 3. +* Rename mixpanel.VERSION to mixpanel.__version__. +* Move from `mixpanel-py` to `mixpanel` on PyPI. +* Fix exception handling in `BufferedConsumer`. +* Fix `people_track_charge` calls without properties. + v4.0.2 * Fix packaging. diff --git a/docs/conf.py b/docs/conf.py index 0e4e7dc..0ec2647 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2015, Mixpanel, Inc' author = u'Mixpanel ' -version = release = '4.0.2' +version = release = '4.1.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 15cacc1..fe7c9fb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.0.2' +__version__ = '4.1.0' VERSION = __version__ # TODO: remove when bumping major version. From 355f99605559cd48f43cc459b7f48db65fc40e69 Mon Sep 17 00:00:00 2001 From: Ashwini Chaudhary Date: Mon, 2 Nov 2015 11:26:13 -0800 Subject: [PATCH 039/208] Allow customization of JSONEncoder class --- mixpanel/__init__.py | 17 ++++++++++------- test_mixpanel.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index fe7c9fb..8f7fc1a 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -36,9 +36,9 @@ def default(self, obj): return json.JSONEncoder.default(self, obj) -def json_dumps(data): +def json_dumps(data, cls=None): # Separators are specified to eliminate whitespace. - return json.dumps(data, separators=(',', ':'), cls=DatetimeSerializer) + return json.dumps(data, separators=(',', ':'), cls=cls) class Mixpanel(object): @@ -47,13 +47,16 @@ class Mixpanel(object): :param str token: your project's Mixpanel token :param consumer: can be used to alter the behavior of tracking (default :class:`~.Consumer`) + :param serializer json.JSONEncoder: a JSONEncoder subclass used to handle + JSON serialization (default :class:`~.DatetimeSerializer`) See `Built-in consumers`_ for details about the consumer interface. """ - def __init__(self, token, consumer=None): + def __init__(self, token, consumer=None, serializer=DatetimeSerializer): self._token = token self._consumer = consumer or Consumer() + self._serializer = serializer def _now(self): return time.time() @@ -86,7 +89,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): } if meta: event.update(meta) - self._consumer.send('events', json_dumps(event)) + self._consumer.send('events', json_dumps(event, cls=self._serializer)) def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None): @@ -121,7 +124,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event), api_key) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def alias(self, alias_id, original, meta=None): """Apply a custom alias to a people record. @@ -151,7 +154,7 @@ def alias(self, alias_id, original, meta=None): } if meta: event.update(meta) - sync_consumer.send('events', json_dumps(event)) + sync_consumer.send('events', json_dumps(event, cls=self._serializer)) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -302,7 +305,7 @@ def people_update(self, message, meta=None): record.update(message) if meta: record.update(meta) - self._consumer.send('people', json_dumps(record)) + self._consumer.send('people', json_dumps(record, cls=self._serializer)) class MixpanelException(Exception): diff --git a/test_mixpanel.py b/test_mixpanel.py index 966822d..eb2c2fa 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -3,6 +3,7 @@ import cgi import contextlib import datetime +import decimal import json import time @@ -282,6 +283,33 @@ def test_people_meta(self): } )] + def test_custom_json_serializer(self): + decimal_string = '12.05' + with pytest.raises(TypeError) as excinfo: + self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) + assert "Decimal('%s') is not JSON serializable" % decimal_string in str(excinfo.value) + + class CustomSerializer(mixpanel.DatetimeSerializer): + def default(self, obj): + if isinstance(obj, decimal.Decimal): + return obj.to_eng_string() + + self.mp._serializer = CustomSerializer + self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) + assert self.consumer.log == [( + 'events', { + 'event': 'button press', + 'properties': { + 'token': self.TOKEN, + 'size': decimal_string, + 'distinct_id': 'ID', + 'time': int(self.mp._now()), + 'mp_lib': 'python', + '$lib_version': mixpanel.__version__, + } + } + )] + class TestConsumer: From 2c7f00e3d15a6d268480c02c2147e41bd5b23f5b Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 2 Nov 2015 11:33:24 -0800 Subject: [PATCH 040/208] Release 4.2.0 --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 0697680..270dc9e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.2.0 +* Add support for customizing JSON serialization. + v4.1.0 * Add support for Python 3. * Rename mixpanel.VERSION to mixpanel.__version__. diff --git a/docs/conf.py b/docs/conf.py index 0ec2647..dfa03a3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2015, Mixpanel, Inc' author = u'Mixpanel ' -version = release = '4.1.0' +version = release = '4.2.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 8f7fc1a..1f3ca53 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.1.0' +__version__ = '4.2.0' VERSION = __version__ # TODO: remove when bumping major version. @@ -47,10 +47,13 @@ class Mixpanel(object): :param str token: your project's Mixpanel token :param consumer: can be used to alter the behavior of tracking (default :class:`~.Consumer`) - :param serializer json.JSONEncoder: a JSONEncoder subclass used to handle + :param json.JSONEncoder serializer: a JSONEncoder subclass used to handle JSON serialization (default :class:`~.DatetimeSerializer`) See `Built-in consumers`_ for details about the consumer interface. + + .. versionadded:: 4.2.0 + The *serializer* parameter. """ def __init__(self, token, consumer=None, serializer=DatetimeSerializer): From cc8e884b6a996c38b96f2a18b37005502b564f06 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Mon, 2 Nov 2015 11:50:47 -0800 Subject: [PATCH 041/208] Specify minimum six version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a2baf3b..3984009 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ def find_version(*paths): author='Mixpanel, Inc.', author_email='dev@mixpanel.com', license='Apache', - install_requires=['six'], + install_requires=['six >= 1.9.0'], classifiers=[ 'License :: OSI Approved :: Apache Software License', From 6518b79f845acc938b5c06214c5f4cdc4bb8df8d Mon Sep 17 00:00:00 2001 From: Ilya Kamens Date: Tue, 3 Nov 2015 08:32:37 +0000 Subject: [PATCH 042/208] update readme --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 38f6246..d031792 100644 --- a/README.rst +++ b/README.rst @@ -23,10 +23,10 @@ Typical usage usually looks like this:: mp = Mixpanel(YOUR_TOKEN) # tracks an event with certain properties - mp.track(USER_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) + mp.track(DISTINCT_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) # sends an update to a user profile - mp.people_set(USER_ID, {'$first_name' : 'Amy', 'favorite color': 'red'}) + mp.people_set(DISTINCT_ID, {'$first_name' : 'Ilya', 'favorite pizza': 'margherita'}) You can use an instance of the Mixpanel class for sending all of your events and people updates. From a8c73deb53356f697be37de0bd53b8a1ea4dd523 Mon Sep 17 00:00:00 2001 From: Naoya Kanai Date: Mon, 5 Jan 2015 12:38:18 -0800 Subject: [PATCH 043/208] Add error handling for URLError in _write_request --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 1f3ca53..676b11d 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -371,7 +371,7 @@ def _write_request(self, request_url, json_message, api_key=None): response = urllib.request.urlopen(request, timeout=self._request_timeout).read() else: response = urllib.request.urlopen(request).read() - except urllib.error.HTTPError as e: + except urllib.error.URLError as e: raise six.raise_from(MixpanelException(e), e) try: From 3b42388034b9ea5fb7fcb861dcf898ee2d39ac8e Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Thu, 21 Jan 2016 12:00:16 -0800 Subject: [PATCH 044/208] Release 4.3.0 --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 270dc9e..fc19bf2 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.3.0 +* Catch URLError when tracking data. + v4.2.0 * Add support for customizing JSON serialization. diff --git a/docs/conf.py b/docs/conf.py index dfa03a3..1a6b531 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2015, Mixpanel, Inc' author = u'Mixpanel ' -version = release = '4.2.0' +version = release = '4.3.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 676b11d..c82bcdc 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.2.0' +__version__ = '4.3.0' VERSION = __version__ # TODO: remove when bumping major version. From 1238682e1ea2eaaee8de1790efe06e78dc685997 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 21 Jun 2016 11:33:48 -0700 Subject: [PATCH 045/208] Add api_key parameter to BufferedConsumer.send Fixes #62. --- mixpanel/__init__.py | 8 ++++---- test_mixpanel.py | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index c82bcdc..0dd2dbd 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -413,7 +413,7 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non } self._max_size = min(50, max_size) - def send(self, endpoint, json_message): + def send(self, endpoint, json_message, api_key=None): """Record an event or profile update. Internally, adds the message to a buffer, and then flushes the buffer @@ -433,7 +433,7 @@ def send(self, endpoint, json_message): buf = self._buffers[endpoint] buf.append(json_message) if len(buf) >= self._max_size: - self._flush_endpoint(endpoint) + self._flush_endpoint(endpoint, api_key) def flush(self): """Immediately send all buffered messages to Mixpanel. @@ -444,13 +444,13 @@ def flush(self): for endpoint in self._buffers.keys(): self._flush_endpoint(endpoint) - def _flush_endpoint(self, endpoint): + def _flush_endpoint(self, endpoint, api_key=None): buf = self._buffers[endpoint] while buf: batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json) + self._consumer.send(endpoint, batch_json, api_key) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index eb2c2fa..132b2f6 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -401,6 +401,11 @@ def test_useful_reraise_in_flush_endpoint(self): assert excinfo.value.message == '[%s]' % broken_json assert excinfo.value.endpoint == 'events' + def test_import_data_receives_api_key(self): + # Ensure BufferedConsumer.send accepts the API_KEY parameter needed for + # import_data; see #62. + self.consumer.send('imports', '"Event"', api_key='MY_API_KEY') + class TestFunctional: From 4d2aafbd19a651a7879e6f8be543468cab10f480 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 21 Jun 2016 11:46:10 -0700 Subject: [PATCH 046/208] Release 4.3.1 --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index fc19bf2..220d4ce 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.3.1 +* Fix bug preventing use of `import_data` with a `BufferedConsumer`. + v4.3.0 * Catch URLError when tracking data. diff --git a/docs/conf.py b/docs/conf.py index 1a6b531..52b6910 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2015, Mixpanel, Inc' author = u'Mixpanel ' -version = release = '4.3.0' +version = release = '4.3.1' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 0dd2dbd..3596743 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.3.0' +__version__ = '4.3.1' VERSION = __version__ # TODO: remove when bumping major version. From 9aaf506a1f684a6adef56112aaf0ab4ecd7b8bc6 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 21 Jun 2016 13:55:50 -0700 Subject: [PATCH 047/208] Remove link to mixpanel-py3 This library now supports Python 3, and the fork no longer appears to be maintained. --- README.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.rst b/README.rst index d031792..5b83042 100644 --- a/README.rst +++ b/README.rst @@ -39,8 +39,6 @@ Additional Information * `Full Documentation`_ * mixpanel-python-async_; a third party tool for sending data asynchronously from the tracking python process. -* mixpanel-py3_; a Python 3-only fork of this library that includes some - additional features, maintained by Fredrik Svensson. .. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master @@ -48,4 +46,3 @@ Additional Information .. _Help Docs: https://www.mixpanel.com/help/reference/python .. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ .. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async -.. _mixpanel-py3: https://github.com/MyGGaN/mixpanel-python From 0ccd6ca011adb5f9472bbf2b11cfba6a5b5df3cf Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Tue, 21 Jun 2016 13:58:50 -0700 Subject: [PATCH 048/208] Bump copyright year --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 52b6910..d3a84a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ # General information about the project. project = u'mixpanel' -copyright = u' 2015, Mixpanel, Inc' +copyright = u' 2016, Mixpanel, Inc.' author = u'Mixpanel ' version = release = '4.3.1' exclude_patterns = ['_build'] From 08baf93767c9efe317225e2d73b9dca74f76ea57 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 21 Dec 2016 13:13:47 -0800 Subject: [PATCH 049/208] Add py35 to tox environments --- requirements-testing.txt | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 057c09e..7831397 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,2 +1,2 @@ mock==1.0.1 -pytest==2.6.4 +pytest==3.0.5 diff --git a/tox.ini b/tox.ini index 10bbae7..a38fd50 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py34 +envlist = py26, py27, py34, py35 [testenv] deps = -rrequirements-testing.txt From 40c98e0b285898384cc4aa6cc803d8d0f46f6218 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 21 Dec 2016 13:20:56 -0800 Subject: [PATCH 050/208] Just call six.raise_from; don't raise it --- mixpanel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 3596743..fc17602 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -372,7 +372,7 @@ def _write_request(self, request_url, json_message, api_key=None): else: response = urllib.request.urlopen(request).read() except urllib.error.URLError as e: - raise six.raise_from(MixpanelException(e), e) + six.raise_from(MixpanelException(e), e) try: response = json.loads(response.decode('utf8')) @@ -455,6 +455,6 @@ def _flush_endpoint(self, endpoint, api_key=None): mp_e = MixpanelException(orig_e) mp_e.message = batch_json mp_e.endpoint = endpoint - raise six.raise_from(mp_e, orig_e) + six.raise_from(mp_e, orig_e) buf = buf[self._max_size:] self._buffers[endpoint] = buf From 3b67e649e6cfb1c72517ccb6d9a81a49c2e6990b Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 21 Dec 2016 16:01:16 -0800 Subject: [PATCH 051/208] Simplify BufferedConsumer tests --- test_mixpanel.py | 53 +++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index 132b2f6..1f8353e 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -353,40 +353,32 @@ class TestBufferedConsumer: def setup_class(cls): cls.MAX_LENGTH = 10 cls.consumer = mixpanel.BufferedConsumer(cls.MAX_LENGTH) - cls.mock = Mock() - cls.mock.read.return_value = six.b('{"status":1, "error": null}') + cls.consumer._consumer = LogConsumer() + cls.log = cls.consumer._consumer.log - def test_buffer_hold_and_flush(self): - with patch('six.moves.urllib.request.urlopen', return_value=self.mock) as urlopen: - self.consumer.send('events', '"Event"') - assert not self.mock.called - self.consumer.flush() + def setup_method(self): + del self.log[:] - assert urlopen.call_count == 1 - - (call_args, kwargs) = urlopen.call_args - (request,) = call_args - timeout = kwargs.get('timeout', None) - - assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.data) == qs('ip=0&data=WyJFdmVudCJd&verbose=1') - assert timeout is None + def test_buffer_hold_and_flush(self): + self.consumer.send('events', '"Event"') + assert len(self.log) == 0 + self.consumer.flush() + assert self.log == [('events', ['Event'])] def test_buffer_fills_up(self): - with patch('six.moves.urllib.request.urlopen', return_value=self.mock) as urlopen: - for i in range(self.MAX_LENGTH - 1): - self.consumer.send('events', '"Event"') - assert not self.mock.called - - self.consumer.send('events', '"Last Event"') + for i in range(self.MAX_LENGTH - 1): + self.consumer.send('events', '"Event"') + assert len(self.log) == 0 - assert urlopen.call_count == 1 - ((request,), _) = urlopen.call_args - assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.data) == \ - qs('ip=0&data=WyJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJFdmVudCIsIkV2ZW50IiwiRXZlbnQiLCJMYXN0IEV2ZW50Il0%3D&verbose=1') + self.consumer.send('events', '"Last Event"') + assert len(self.log) == 1 + assert self.log == [('events', [ + 'Event', 'Event', 'Event', 'Event', 'Event', + 'Event', 'Event', 'Event', 'Event', 'Last Event', + ])] - def test_unknown_endpoint(self): + def test_unknown_endpoint_raises_on_send(self): + # Ensure the exception isn't hidden until a flush. with pytest.raises(mixpanel.MixpanelException): self.consumer.send('unknown', '1') @@ -394,10 +386,11 @@ def test_useful_reraise_in_flush_endpoint(self): error_mock = Mock() error_mock.read.return_value = six.b('{"status": 0, "error": "arbitrary error"}') broken_json = '{broken JSON' + consumer = mixpanel.BufferedConsumer(2) with patch('six.moves.urllib.request.urlopen', return_value=error_mock): - self.consumer.send('events', broken_json) + consumer.send('events', broken_json) with pytest.raises(mixpanel.MixpanelException) as excinfo: - self.consumer.flush() + consumer.flush() assert excinfo.value.message == '[%s]' % broken_json assert excinfo.value.endpoint == 'events' From 5beb83cdda365f0f21a52a77e5bdc730d7ef1ca8 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 21 Dec 2016 16:02:44 -0800 Subject: [PATCH 052/208] Remember api_key in BufferedConsumer.send Previously, `import_data` would often fail when using a `BufferedConsumer`, because `flush` had no way to know about the `api_key` needed for that endpoint. Now we remember the last `api_key` we've seen, and pass it along to the backing consumer. Fixes #63. --- mixpanel/__init__.py | 14 +++++++++++--- test_mixpanel.py | 7 ++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index fc17602..e8ab564 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -345,6 +345,7 @@ def send(self, endpoint, json_message, api_key=None): :param endpoint: the Mixpanel API endpoint appropriate for the message :type endpoint: "events" | "people" | "imports" :param str json_message: a JSON message formatted for the endpoint + :param str api_key: your Mixpanel project's API key :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or the message cannot be processed """ @@ -412,6 +413,7 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non 'imports': [], } self._max_size = min(50, max_size) + self._api_key = None def send(self, endpoint, json_message, api_key=None): """Record an event or profile update. @@ -424,16 +426,22 @@ def send(self, endpoint, json_message, api_key=None): :param endpoint: the Mixpanel API endpoint appropriate for the message :type endpoint: "events" | "people" | "imports" :param str json_message: a JSON message formatted for the endpoint + :param str api_key: your Mixpanel project's API key :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or any buffered message cannot be processed + + .. versionadded:: 4.3.2 + The *api_key* parameter. """ if endpoint not in self._buffers: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) buf = self._buffers[endpoint] buf.append(json_message) + if api_key is not None: + self._api_key = api_key if len(buf) >= self._max_size: - self._flush_endpoint(endpoint, api_key) + self._flush_endpoint(endpoint) def flush(self): """Immediately send all buffered messages to Mixpanel. @@ -444,13 +452,13 @@ def flush(self): for endpoint in self._buffers.keys(): self._flush_endpoint(endpoint) - def _flush_endpoint(self, endpoint, api_key=None): + def _flush_endpoint(self, endpoint): buf = self._buffers[endpoint] while buf: batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json, api_key) + self._consumer.send(endpoint, batch_json, self._api_key) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index 1f8353e..190517c 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -394,10 +394,11 @@ def test_useful_reraise_in_flush_endpoint(self): assert excinfo.value.message == '[%s]' % broken_json assert excinfo.value.endpoint == 'events' - def test_import_data_receives_api_key(self): - # Ensure BufferedConsumer.send accepts the API_KEY parameter needed for - # import_data; see #62. + def test_send_remembers_api_key(self): self.consumer.send('imports', '"Event"', api_key='MY_API_KEY') + assert len(self.log) == 0 + self.consumer.flush() + assert self.log == [('imports', ['Event'], 'MY_API_KEY')] class TestFunctional: From 69a5b84e572047761f8ab9da6ebbd657784c8d61 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Wed, 21 Dec 2016 16:14:54 -0800 Subject: [PATCH 053/208] Release 4.3.2 --- CHANGES.txt | 2 +- docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 220d4ce..231df3d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -v4.3.1 +v4.3.2 * Fix bug preventing use of `import_data` with a `BufferedConsumer`. v4.3.0 diff --git a/docs/conf.py b/docs/conf.py index d3a84a5..7adf505 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2016, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.3.1' +version = release = '4.3.2' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index e8ab564..44d9b19 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.3.1' +__version__ = '4.3.2' VERSION = __version__ # TODO: remove when bumping major version. From a56d528d2ab21824dcdb3cce21c5f682eaa76cf7 Mon Sep 17 00:00:00 2001 From: Adam Nelson Date: Thu, 16 Mar 2017 15:07:07 -0400 Subject: [PATCH 054/208] Test Python3.6, no longer test Python2.6 (#67) --- .gitignore | 3 +++ .travis.yml | 4 ++-- test_mixpanel.py | 2 +- tox.ini | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d235539..9daca02 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ build dist docs/_build +.idea/ +.cache/ + diff --git a/.travis.yml b/.travis.yml index cd186d6..bfb652a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: python python: - - "2.6" - "2.7" - - "3.3" - "3.4" + - "3.5" + - "3.6" install: - "pip install ." - "pip install -r requirements-testing.txt" diff --git a/test_mixpanel.py b/test_mixpanel.py index 190517c..af80a5c 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -287,7 +287,7 @@ def test_custom_json_serializer(self): decimal_string = '12.05' with pytest.raises(TypeError) as excinfo: self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) - assert "Decimal('%s') is not JSON serializable" % decimal_string in str(excinfo.value) + assert "not JSON serializable" in str(excinfo.value) class CustomSerializer(mixpanel.DatetimeSerializer): def default(self, obj): diff --git a/tox.ini b/tox.ini index a38fd50..0e0b489 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py34, py35 +envlist = py27, py34, py35, py36 [testenv] deps = -rrequirements-testing.txt From 2b536c9745c6cf027f99a112b6ec0695a66abb81 Mon Sep 17 00:00:00 2001 From: Joseph Malysz Date: Mon, 19 Mar 2018 17:32:22 -0700 Subject: [PATCH 055/208] Update README.rst Include note explaining difference between our python lib and mixpanel-api module (customers are getting tripped up) --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index 5b83042..b525404 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,9 @@ mixpanel-python |travis-badge| This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. +To import, export, transform, or delete your Mixpanel data, please see our +`mixpanel_api package`_. + Installation ------------ @@ -43,6 +46,7 @@ Additional Information .. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master :target: https://travis-ci.org/mixpanel/mixpanel-python +.. _mixpanel_api package: https://github.com/mixpanel/mixpanel_api .. _Help Docs: https://www.mixpanel.com/help/reference/python .. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ .. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async From 9a4b33b6a7ea4191aab4b7d929106e9cc6010f8a Mon Sep 17 00:00:00 2001 From: smcoll Date: Thu, 21 Mar 2019 17:30:44 -0500 Subject: [PATCH 056/208] add support for remove action refs #71 --- mixpanel/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 44d9b19..6039cea 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -251,6 +251,22 @@ def people_unset(self, distinct_id, properties, meta=None): '$unset': properties, }, meta=meta) + def people_remove(self, distinct_id, properties, meta=None): + """Remove a value from the list associated with a property. + + :param str distinct_id: the profile to update + :param dict properties: properties to remove + + Removes items from list-style properties of a people record. + For example:: + + mp.people_remove('123', {'Items': 'Super Arm'}) + """ + return self.people_update({ + '$distinct_id': distinct_id, + '$remove': properties, + }, meta=meta or {}) + def people_delete(self, distinct_id, meta=None): """Permanently delete a people record. From 7390d2244e2cc77c7c090d5a3c1dabc92c83060e Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Fri, 31 May 2019 14:00:52 -0700 Subject: [PATCH 057/208] Clarify people_remove docstring --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 6039cea..6f2e414 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -252,7 +252,7 @@ def people_unset(self, distinct_id, properties, meta=None): }, meta=meta) def people_remove(self, distinct_id, properties, meta=None): - """Remove a value from the list associated with a property. + """Permanently remove a value from the list associated with a property. :param str distinct_id: the profile to update :param dict properties: properties to remove From e4585f30ed23c25e12896f926147cf5f43ef38d1 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Fri, 31 May 2019 14:00:56 -0700 Subject: [PATCH 058/208] Add test for people_remove --- test_mixpanel.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test_mixpanel.py b/test_mixpanel.py index af80a5c..f602c89 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -181,6 +181,17 @@ def test_people_unset(self): } )] + def test_people_remove(self): + self.mp.people_remove('amq', {'Albums': 'Diamond Dogs'}) + assert self.consumer.log == [( + 'people', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$distinct_id': 'amq', + '$remove': {'Albums': 'Diamond Dogs'}, + } + )] + def test_people_track_charge(self): self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) assert self.consumer.log == [( From 7d5b910cfe7f1b3a59b9764144d00790ee891885 Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Fri, 31 May 2019 14:04:49 -0700 Subject: [PATCH 059/208] Add py37 to tests --- .travis.yml | 6 ++++++ tox.ini | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bfb652a..8b8e3a1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,12 @@ python: - "3.4" - "3.5" - "3.6" +# See . +matrix: + include: + - python: 3.7 + dist: xenial + sudo: true install: - "pip install ." - "pip install -r requirements-testing.txt" diff --git a/tox.ini b/tox.ini index 0e0b489..33f0750 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36 +envlist = py27, py34, py35, py36, py37 [testenv] deps = -rrequirements-testing.txt From 752f3f30650a244aa4e256eec8752a267074b8db Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Fri, 31 May 2019 14:07:03 -0700 Subject: [PATCH 060/208] Release 4.4.0 --- CHANGES.txt | 3 +++ docs/conf.py | 4 ++-- mixpanel/__init__.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 231df3d..708e4bb 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.4.0 +* Add `people_remove`. + v4.3.2 * Fix bug preventing use of `import_data` with a `BufferedConsumer`. diff --git a/docs/conf.py b/docs/conf.py index 7adf505..fce971c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,9 +18,9 @@ # General information about the project. project = u'mixpanel' -copyright = u' 2016, Mixpanel, Inc.' +copyright = u' 2019, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.3.2' +version = release = '4.4.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 6f2e414..cfb1bf7 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.3.2' +__version__ = '4.4.0' VERSION = __version__ # TODO: remove when bumping major version. From e0d74733903c688df83ed3512ecd179c7d224aba Mon Sep 17 00:00:00 2001 From: Zak Johnson Date: Fri, 31 May 2019 14:22:39 -0700 Subject: [PATCH 061/208] Update build docs --- BUILD.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUILD.rst b/BUILD.rst index fc6fede..63a7296 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -4,13 +4,16 @@ Run tests:: Publish to PyPI:: + pip install twine wheel python setup.py sdist bdist_wheel twine upload dist/* Build docs:: + pip install sphinx python setup.py build_sphinx Publish docs to GitHub Pages:: + pip install ghp-import ghp-import -n -p build/sphinx/html From 4a23098dfe108a6934a6d4235aaa958fed01cc21 Mon Sep 17 00:00:00 2001 From: J Connolly Date: Wed, 31 Jul 2019 17:43:36 -0700 Subject: [PATCH 062/208] Add group profile methods See https://developer.mixpanel.com/docs/http#section-group-analytics --- mixpanel/__init__.py | 129 ++++++++++++++++++++++++++++++++++++++++++- test_mixpanel.py | 68 +++++++++++++++++++++++ 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index cfb1bf7..2a965df 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -327,6 +327,125 @@ def people_update(self, message, meta=None): self._consumer.send('people', json_dumps(record, cls=self._serializer)) + def group_set(self, group_key, group_id, properties, meta=None): + """Set properties of a group profile. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to update + :param dict properties: properties to set + :param dict meta: overrides Mixpanel `special properties`_ + + .. _`special properties`: https://mixpanel.com/help/reference/http#people-analytics-updates + + If the profile does not exist, creates a new profile with these properties. + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$set': properties, + }, meta=meta or {}) + + def group_set_once(self, group_key, group_id, properties, meta=None): + """Set properties of a group profile if they are not already set. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to update + :param dict properties: properties to set + + Any properties that already exist on the profile will not be + overwritten. If the profile does not exist, creates a new profile with + these properties. + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$set_once': properties, + }, meta=meta or {}) + + def group_union(self, group_key, group_id, properties, meta=None): + """Merge the values of a list associated with a property. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to update + :param dict properties: properties to merge + + Merges list values in ``properties`` with existing list-style + properties of a group profile. Duplicate values are ignored. For + example:: + + mp.group_union('company', 'Acme Inc.', {'Items': ['Super Arm', 'Fire Storm']}) + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$union': properties, + }, meta=meta or {}) + + def group_unset(self, group_key, group_id, properties, meta=None): + """Permanently remove properties from a group profile. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to update + :param list properties: property names to remove + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$unset': properties, + }, meta=meta) + + def group_remove(self, group_key, group_id, properties, meta=None): + """Permanently remove a value from the list associated with a property. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to update + :param dict properties: properties to remove + + Removes items from list-style properties of a group profile. + For example:: + + mp.group_remove('company', 'Acme Inc.', {'Items': 'Super Arm'}) + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$remove': properties, + }, meta=meta or {}) + + def group_delete(self, group_key, group_id, meta=None): + """Permanently delete a group profile. + + :param str group_key: the group key, e.g. 'company' + :param str group_id: the group to delete + """ + return self.group_update({ + '$group_key': group_key, + '$group_id': group_id, + '$delete': "", + }, meta=meta or None) + + def group_update(self, message, meta=None): + """Send a generic group profile update + + :param dict message: the message to send + + Callers are responsible for formatting the update message as documented + in the `Mixpanel HTTP specification`_. This method may be useful if you + want to use very new or experimental features, but + please use the other ``group_*`` methods where possible. + + .. _`Mixpanel HTTP specification`: https://mixpanel.com/help/reference/http + """ + record = { + '$token': self._token, + '$time': int(self._now() * 1000), + } + record.update(message) + if meta: + record.update(meta) + self._consumer.send('groups', json_dumps(record, cls=self._serializer)) + + class MixpanelException(Exception): """Raised by consumers when unable to send messages. @@ -343,14 +462,16 @@ class Consumer(object): :param str events_url: override the default events API endpoint :param str people_url: override the default people API endpoint + :param str groups_url: override the default groups API endpoint :param str import_url: override the default import API endpoint :param int request_timeout: connection timeout in seconds """ - def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None): + def __init__(self, events_url=None, people_url=None, groups_url=None, import_url=None, request_timeout=None): self._endpoints = { 'events': events_url or 'https://api.mixpanel.com/track', 'people': people_url or 'https://api.mixpanel.com/engage', + 'groups': groups_url or 'https://api.mixpanel.com/groups', 'imports': import_url or 'https://api.mixpanel.com/import', } self._request_timeout = request_timeout @@ -418,14 +539,16 @@ class BufferedConsumer(object): buffer before flushing automatically :param str events_url: override the default events API endpoint :param str people_url: override the default people API endpoint + :param str groups_url: override the default groups API endpoint :param str import_url: override the default import API endpoint :param int request_timeout: connection timeout in seconds """ - def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None): - self._consumer = Consumer(events_url, people_url, import_url, request_timeout) + def __init__(self, max_size=50, events_url=None, people_url=None, groups_url=None, import_url=None, request_timeout=None): + self._consumer = Consumer(events_url, people_url, groups_url, import_url, request_timeout) self._buffers = { 'events': [], 'people': [], + 'groups': [], 'imports': [], } self._max_size = min(50, max_size) diff --git a/test_mixpanel.py b/test_mixpanel.py index f602c89..7a0e02d 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -294,6 +294,74 @@ def test_people_meta(self): } )] + def test_group_set(self): + self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) + assert self.consumer.log == [( + 'groups', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$group_key': 'company', + '$group_id': 'amq', + '$set': { + 'birth month': 'october', + 'favorite color': 'purple', + }, + } + )] + + def test_group_set_once(self): + self.mp.group_set_once('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) + assert self.consumer.log == [( + 'groups', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$group_key': 'company', + '$group_id': 'amq', + '$set_once': { + 'birth month': 'october', + 'favorite color': 'purple', + }, + } + )] + + def test_group_union(self): + self.mp.group_union('company', 'amq', {'Albums': ['Diamond Dogs']}) + assert self.consumer.log == [( + 'groups', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$group_key': 'company', + '$group_id': 'amq', + '$union': { + 'Albums': ['Diamond Dogs'], + }, + } + )] + + def test_group_unset(self): + self.mp.group_unset('company', 'amq', ['Albums', 'Singles']) + assert self.consumer.log == [( + 'groups', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$group_key': 'company', + '$group_id': 'amq', + '$unset': ['Albums', 'Singles'], + } + )] + + def test_group_remove(self): + self.mp.group_remove('company', 'amq', {'Albums': 'Diamond Dogs'}) + assert self.consumer.log == [( + 'groups', { + '$time': int(self.mp._now() * 1000), + '$token': self.TOKEN, + '$group_key': 'company', + '$group_id': 'amq', + '$remove': {'Albums': 'Diamond Dogs'}, + } + )] + def test_custom_json_serializer(self): decimal_string = '12.05' with pytest.raises(TypeError) as excinfo: From 8afeeb9bc3b36dc8b9e5904f4a1b85fd083d43b2 Mon Sep 17 00:00:00 2001 From: J Connolly Date: Mon, 5 Aug 2019 17:30:02 -0700 Subject: [PATCH 063/208] Make groups_url last argument (thanks Dave) --- mixpanel/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 2a965df..10588b7 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -266,7 +266,7 @@ def people_remove(self, distinct_id, properties, meta=None): '$distinct_id': distinct_id, '$remove': properties, }, meta=meta or {}) - + def people_delete(self, distinct_id, meta=None): """Permanently delete a people record. @@ -462,12 +462,12 @@ class Consumer(object): :param str events_url: override the default events API endpoint :param str people_url: override the default people API endpoint - :param str groups_url: override the default groups API endpoint :param str import_url: override the default import API endpoint :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint """ - def __init__(self, events_url=None, people_url=None, groups_url=None, import_url=None, request_timeout=None): + def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None): self._endpoints = { 'events': events_url or 'https://api.mixpanel.com/track', 'people': people_url or 'https://api.mixpanel.com/engage', @@ -539,12 +539,12 @@ class BufferedConsumer(object): buffer before flushing automatically :param str events_url: override the default events API endpoint :param str people_url: override the default people API endpoint - :param str groups_url: override the default groups API endpoint :param str import_url: override the default import API endpoint :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint """ - def __init__(self, max_size=50, events_url=None, people_url=None, groups_url=None, import_url=None, request_timeout=None): - self._consumer = Consumer(events_url, people_url, groups_url, import_url, request_timeout) + def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None): + self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url) self._buffers = { 'events': [], 'people': [], From 58b2a76af008808e800d0c3a1fae7e651ee6d2e7 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 12 Aug 2019 21:33:16 -0700 Subject: [PATCH 064/208] Update Mixpanel people props doc link. --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index cfb1bf7..5cc9934 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -166,7 +166,7 @@ def people_set(self, distinct_id, properties, meta=None): :param dict properties: properties to set :param dict meta: overrides Mixpanel `special properties`_ - .. _`special properties`: https://mixpanel.com/help/reference/http#people-analytics-updates + .. _`special properties`: https://developer.mixpanel.com/docs/http#section-storing-user-profiles If the profile does not exist, creates a new profile with these properties. """ From de31e1cb7b6eb5e9d5865e1654a8c23d6d03c676 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 6 Sep 2019 16:18:01 +0000 Subject: [PATCH 065/208] Update URLs that now redirect elsewhere. --- mixpanel/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 1737372..285cfcb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -7,8 +7,8 @@ documentation`_. If your users are interacting with your application via the web, you may also be interested in our `JavaScript library`_. -.. _`Javascript library`: https://mixpanel.com/help/reference/javascript -.. _`usage documentation`: https://mixpanel.com/help/reference/python +.. _`Javascript library`: https://developer.mixpanel.com/docs/javascript +.. _`usage documentation`: https://developer.mixpanel.com/docs/python :class:`~.Mixpanel` is the primary class for tracking events and sending People Analytics updates. :class:`~.Consumer` and :class:`~.BufferedConsumer` allow @@ -110,7 +110,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, ``track`` endpoint disallows events that occurred too long ago. This method can be used to import such events. See our online documentation for `more details - `__. + `__. """ all_properties = { 'token': self._token, @@ -315,7 +315,7 @@ def people_update(self, message, meta=None): want to use very new or experimental features of people analytics, but please use the other ``people_*`` methods where possible. - .. _`Mixpanel HTTP specification`: https://mixpanel.com/help/reference/http + .. _`Mixpanel HTTP specification`: https://developer.mixpanel.com/docs/http """ record = { '$token': self._token, @@ -434,7 +434,7 @@ def group_update(self, message, meta=None): want to use very new or experimental features, but please use the other ``group_*`` methods where possible. - .. _`Mixpanel HTTP specification`: https://mixpanel.com/help/reference/http + .. _`Mixpanel HTTP specification`: https://developer.mixpanel.com/docs/http """ record = { '$token': self._token, From 6ec1b47275caaa2b2b983a644c18244fee7718bb Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 6 Sep 2019 16:49:38 +0000 Subject: [PATCH 066/208] Update doc for group_set. --- mixpanel/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 285cfcb..e94962d 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -333,9 +333,7 @@ def group_set(self, group_key, group_id, properties, meta=None): :param str group_key: the group key, e.g. 'company' :param str group_id: the group to update :param dict properties: properties to set - :param dict meta: overrides Mixpanel `special properties`_ - - .. _`special properties`: https://mixpanel.com/help/reference/http#people-analytics-updates + :param dict meta: overrides Mixpanel `special properties`_. (See also `Mixpanel.people_set`.) If the profile does not exist, creates a new profile with these properties. """ From e14a144cf506ec5a82d9d24fc709c28c2f9b3cd8 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 6 Sep 2019 18:23:11 +0000 Subject: [PATCH 067/208] 4.5.0 release changes. --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 708e4bb..9c27719 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.5.0 +* Add Mixpanel Groups API functionality. + v4.4.0 * Add `people_remove`. diff --git a/docs/conf.py b/docs/conf.py index fce971c..8b3f591 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2019, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.4.0' +version = release = '4.5.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index e94962d..3fd6e35 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.4.0' +__version__ = '4.5.0' VERSION = __version__ # TODO: remove when bumping major version. From 1a21969ad90c748aab73990a189aefa4def17972 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 30 Oct 2019 21:17:49 -0700 Subject: [PATCH 068/208] Add Python 3.8 to tests. --- .travis.yml | 8 ++------ tox.ini | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8b8e3a1..9037e26 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,12 +4,8 @@ python: - "3.4" - "3.5" - "3.6" -# See . -matrix: - include: - - python: 3.7 - dist: xenial - sudo: true + - "3.7" + - "3.8" install: - "pip install ." - "pip install -r requirements-testing.txt" diff --git a/tox.ini b/tox.ini index 33f0750..df07398 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37 +envlist = py27, py34, py35, py36, py37, py38 [testenv] deps = -rrequirements-testing.txt From 6bcbe2924773b8d276072acd6a43d0249b2230d1 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 30 Oct 2019 21:34:01 -0700 Subject: [PATCH 069/208] Fix moved cgi.parse_qs in Py38. --- test_mixpanel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index 7a0e02d..7fec155 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals import base64 -import cgi import contextlib import datetime import decimal @@ -31,7 +30,7 @@ def send(self, endpoint, event, api_key=None): def qs(s): if isinstance(s, six.binary_type): s = s.decode('utf8') - blob = cgi.parse_qs(s) + blob = urllib.parse.parse_qs(s) if len(blob['data']) != 1: pytest.fail('found multi-item data: %s' % blob['data']) json_bytes = base64.b64decode(blob['data'][0]) From f8de0b2910549993d28a245f98fff2133ffb1666 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 4 May 2020 13:08:01 +0000 Subject: [PATCH 070/208] Updates to alias docs --- mixpanel/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 3fd6e35..dcd11b5 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -130,17 +130,18 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def alias(self, alias_id, original, meta=None): - """Apply a custom alias to a people record. + """Creates an alias which Mixpanel will use to remap one id to another. - :param str alias_id: the new distinct_id - :param str original: the previous distinct_id + :param str alias_id: A distinct_id to be merged with the original + distinct_id. Each alias can only map to one distinct_id. + :param str original: A distinct_id to be merged with alias_id. :param dict meta: overrides Mixpanel special properties Immediately creates a one-way mapping between two ``distinct_ids``. Events triggered by the new id will be associated with the existing user's profile and behavior. See our online documentation for `more details - `__. + `__. .. note:: Calling this method *always* results in a synchronous HTTP request From 397f6b5e289045ba34905fe404363aa743565415 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 14:56:20 -0700 Subject: [PATCH 071/208] Upgrade testing libs a little. --- requirements-testing.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 7831397..847df2a 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,2 +1,2 @@ -mock==1.0.1 -pytest==3.0.5 +mock==1.3.0 +pytest==4.6.11 From e7044b82985f9efe3d0ac7aa1bb1cfdc3f6e3e7d Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 15:01:55 -0700 Subject: [PATCH 072/208] try py39 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index df07398..9068f32 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37, py38 +envlist = py27, py34, py35, py36, py37, py38, py39 [testenv] deps = -rrequirements-testing.txt From 6a894b2f65970d73b8533300673c1c4225ac2a73 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 15:03:14 -0700 Subject: [PATCH 073/208] try py39 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 9037e26..58bde30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9" install: - "pip install ." - "pip install -r requirements-testing.txt" From 1833edbedbb8de4aa1d208251ed31a511e08b202 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 15:08:20 -0700 Subject: [PATCH 074/208] 3.9-dev and pypy --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 58bde30..eadac1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,9 @@ python: - "3.6" - "3.7" - "3.8" - - "3.9" + - "3.9-dev" + - "pypy" + - "pypy3" install: - "pip install ." - "pip install -r requirements-testing.txt" From 718661ff2564a9ef9906630a55187df5d19496b6 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 15:12:07 -0700 Subject: [PATCH 075/208] -py39. --- .travis.yml | 1 - tox.ini | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index eadac1c..20f1ec7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ python: - "3.6" - "3.7" - "3.8" - - "3.9-dev" - "pypy" - "pypy3" install: diff --git a/tox.ini b/tox.ini index 9068f32..df07398 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37, py38, py39 +envlist = py27, py34, py35, py36, py37, py38 [testenv] deps = -rrequirements-testing.txt From dd22d29cb1ec7629c145e77984089cf747540116 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 15:42:22 -0700 Subject: [PATCH 076/208] merge() method. --- mixpanel/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index dcd11b5..4536b59 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -160,6 +160,22 @@ def alias(self, alias_id, original, meta=None): event.update(meta) sync_consumer.send('events', json_dumps(event, cls=self._serializer)) + def merge(self, distinct_id1, distinct_id2, meta=None): + """ + Merges the two given distinct_ids. + """ + sync_consumer = Consumer() + event = { + 'event': '$merge', + 'properties': { + 'distinct_ids': [distinct_id1, distinct_id2], + 'token': self._token, + }, + } + if meta: + event.update(meta) + self._consumer.send('imports', json_dumps(event, cls=self._serializer)) + def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. From f2f0fa06835a04f036420d673ce28de276022971 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 16:08:30 -0700 Subject: [PATCH 077/208] Merge fixes & docs --- mixpanel/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 4536b59..d081c20 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -163,12 +163,19 @@ def alias(self, alias_id, original, meta=None): def merge(self, distinct_id1, distinct_id2, meta=None): """ Merges the two given distinct_ids. + + :param str distinct_id1: The first distinct_id to merge. + :param str distinct_id2: The second (other) distinct_id to merge. + :param dict meta: overrides Mixpanel special properties + + See our online documentation for `more + details + `__. """ - sync_consumer = Consumer() event = { 'event': '$merge', 'properties': { - 'distinct_ids': [distinct_id1, distinct_id2], + '$distinct_ids': [distinct_id1, distinct_id2], 'token': self._token, }, } From d8af93a5376229a1e15ff64def0f92cc3b7ebdea Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 5 Jun 2020 16:22:05 -0700 Subject: [PATCH 078/208] add a test. --- test_mixpanel.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test_mixpanel.py b/test_mixpanel.py index 7fec155..a5e12af 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -264,6 +264,7 @@ def test_people_set_created_date_datetime(self): )] def test_alias(self): + # More complicated since alias() forces a synchronous call. mock_response = Mock() mock_response.read.return_value = six.b('{"status":1, "error": null}') with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: @@ -276,6 +277,19 @@ def test_alias(self): assert qs(request.data) == \ qs('ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') + def test_merge(self): + self.mp.merge('d1', 'd2') + + assert self.consumer.log == [( + 'imports', { + 'event': '$merge', + 'properties': { + '$distinct_ids': ['d1', 'd2'], + 'token': self.TOKEN, + } + } + )] + def test_people_meta(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, meta={'$ip': 0, '$ignore_time': True}) From 0c6d8ede34320e3ca281dc0ef5139472008ffaff Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 09:57:49 -0700 Subject: [PATCH 079/208] Integration testing; adding required api_key param. --- mixpanel/__init__.py | 5 +++-- test_mixpanel.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index d081c20..0ba3ee2 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -160,10 +160,11 @@ def alias(self, alias_id, original, meta=None): event.update(meta) sync_consumer.send('events', json_dumps(event, cls=self._serializer)) - def merge(self, distinct_id1, distinct_id2, meta=None): + def merge(self, api_key, distinct_id1, distinct_id2, meta=None): """ Merges the two given distinct_ids. + :param str api_key: Your Mixpanel project's API key. :param str distinct_id1: The first distinct_id to merge. :param str distinct_id2: The second (other) distinct_id to merge. :param dict meta: overrides Mixpanel special properties @@ -181,7 +182,7 @@ def merge(self, distinct_id1, distinct_id2, meta=None): } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer)) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. diff --git a/test_mixpanel.py b/test_mixpanel.py index a5e12af..d78309e 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -278,16 +278,18 @@ def test_alias(self): qs('ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') def test_merge(self): - self.mp.merge('d1', 'd2') + self.mp.merge('my_good_api_key', 'd1', 'd2') assert self.consumer.log == [( - 'imports', { + 'imports', + { 'event': '$merge', 'properties': { '$distinct_ids': ['d1', 'd2'], 'token': self.TOKEN, } - } + }, + 'my_good_api_key', )] def test_people_meta(self): From 33dd93ceb12e46fb84ec1c8cf462d40167f3390d Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 10:24:16 -0700 Subject: [PATCH 080/208] Add release process docs. --- BUILD.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BUILD.rst b/BUILD.rst index 63a7296..cafa469 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -1,3 +1,11 @@ +Release process:: + +1. Document all changes in CHANGES.rst. +2. Tag in git. +3. Create a release in github. +4. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) +5. Publish to PyPI. (see below) + Run tests:: tox From a13f4db3539254c47827c20d36f77fde39bfb64b Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 10:44:27 -0700 Subject: [PATCH 081/208] 4.6.0 changes WIP --- CHANGES.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 9c27719..a139ddc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +v4.6.0 +* Add `$merge` support. +* Updates to `$alias` documentation. + v4.5.0 * Add Mixpanel Groups API functionality. From 81c84c5876ce6dc9bb6ccca19471482b459ad071 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 10:57:42 -0700 Subject: [PATCH 082/208] Bump __version__, update build docs. --- BUILD.rst | 9 +++++---- mixpanel/__init__.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/BUILD.rst b/BUILD.rst index cafa469..e65a97c 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -1,10 +1,11 @@ Release process:: 1. Document all changes in CHANGES.rst. -2. Tag in git. -3. Create a release in github. -4. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) -5. Publish to PyPI. (see below) +2. Update __version__ in __init__.py. +3. Tag the version in git. +4. Create a release in GitHub. https://github.com/mixpanel/mixpanel-python/releases +5. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) +6. Publish to PyPI. (see below) Run tests:: diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 0ba3ee2..6ba3f9c 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -23,7 +23,7 @@ import six from six.moves import urllib -__version__ = '4.5.0' +__version__ = '4.6.0' VERSION = __version__ # TODO: remove when bumping major version. From 4c8c17e3abead7994b3542d5fe60fb4df6665a73 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 11:19:21 -0700 Subject: [PATCH 083/208] Add ability to override api_host by itself, to support EU data residency API calls. --- mixpanel/__init__.py | 52 ++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 6ba3f9c..042af3d 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -482,20 +482,26 @@ class Consumer(object): """ A consumer that sends an HTTP request directly to the Mixpanel service, one per call to :meth:`~.send`. - - :param str events_url: override the default events API endpoint - :param str people_url: override the default people API endpoint - :param str import_url: override the default import API endpoint - :param int request_timeout: connection timeout in seconds - :param str groups_url: override the default groups API endpoint """ - def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None): + def __init__(self, events_url=None, people_url=None, import_url=None, + request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): + """ + Create a Consumer. + + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint + :param str api_host: the Mixpanel API domain where all requests should be + issued (unless overridden by above URLs). + """ self._endpoints = { - 'events': events_url or 'https://api.mixpanel.com/track', - 'people': people_url or 'https://api.mixpanel.com/engage', - 'groups': groups_url or 'https://api.mixpanel.com/groups', - 'imports': import_url or 'https://api.mixpanel.com/import', + 'events': events_url or 'https://{}/track'.format(api_host), + 'people': people_url or 'https://{}/engage'.format(api_host), + 'groups': groups_url or 'https://{}/groups'.format(api_host), + 'imports': import_url or 'https://{}/import'.format(api_host), } self._request_timeout = request_timeout @@ -557,17 +563,21 @@ class BufferedConsumer(object): :meth:`~.flush` when you're sure you're done sending them—for example, just before your program exits. Calls to :meth:`~.flush` will send all remaining unsent events being held by the instance. - - :param int max_size: number of :meth:`~.send` calls for a given endpoint to - buffer before flushing automatically - :param str events_url: override the default events API endpoint - :param str people_url: override the default people API endpoint - :param str import_url: override the default import API endpoint - :param int request_timeout: connection timeout in seconds - :param str groups_url: override the default groups API endpoint """ - def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None): - self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url) + def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, + request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): + """ + :param int max_size: number of :meth:`~.send` calls for a given endpoint to + buffer before flushing automatically + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint + :param str api_host: the Mixpanel API domain where all requests should be + issued (unless overridden by above URLs). + """ + self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url, api_host) self._buffers = { 'events': [], 'people': [], From fbd56b56f743b069c356733a75fca271c2df55cd Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 11:31:47 -0700 Subject: [PATCH 084/208] Add test for overriding api_host. --- test_mixpanel.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index d78309e..73d5af0 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -412,7 +412,10 @@ def setup_class(cls): cls.consumer = mixpanel.Consumer(request_timeout=30) @contextlib.contextmanager - def _assertSends(self, expect_url, expect_data): + def _assertSends(self, expect_url, expect_data, consumer=None): + if consumer is None: + consumer = self.consumer + mock_response = Mock() mock_response.read.return_value = six.b('{"status":1, "error": null}') with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: @@ -426,7 +429,7 @@ def _assertSends(self, expect_url, expect_data): assert request.get_full_url() == expect_url assert qs(request.data) == qs(expect_data) - assert timeout == self.consumer._request_timeout + assert timeout == consumer._request_timeout def test_send_events(self): with self._assertSends('https://api.mixpanel.com/track', 'ip=0&data=IkV2ZW50Ig%3D%3D&verbose=1'): @@ -436,6 +439,13 @@ def test_send_people(self): with self._assertSends('https://api.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1'): self.consumer.send('people', '"People"') + def test_consumer_override_api_host(self): + consumer = mixpanel.Consumer(api_host="api-eu.mixpanel.com") + with self._assertSends('https://api-eu.mixpanel.com/track', 'ip=0&data=IkV2ZW50Ig%3D%3D&verbose=1', consumer=consumer): + consumer.send('events', '"Event"') + with self._assertSends('https://api-eu.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1', consumer=consumer): + consumer.send('people', '"People"') + def test_unknown_endpoint(self): with pytest.raises(mixpanel.MixpanelException): self.consumer.send('unknown', '1') From def8bea326e5f8cf501789384ca01105968b5407 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 11:36:38 -0700 Subject: [PATCH 085/208] Add API host override to changes. --- CHANGES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.txt b/CHANGES.txt index a139ddc..c5616fa 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ v4.6.0 * Add `$merge` support. +* Support for overriding API host for, say, making calls to EU APIs. * Updates to `$alias` documentation. v4.5.0 From 4d3408dd182fe907a5372ccb41de8bfba31be0cb Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 11:59:53 -0700 Subject: [PATCH 086/208] Doc formatting updates. --- mixpanel/__init__.py | 46 +++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 042af3d..3f0d1b0 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -482,21 +482,21 @@ class Consumer(object): """ A consumer that sends an HTTP request directly to the Mixpanel service, one per call to :meth:`~.send`. + + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint + :param str api_host: the Mixpanel API domain where all requests should be + issued (unless overridden by above URLs). + + .. versionadded:: 4.6.0 + The *api_host* parameter. """ def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): - """ - Create a Consumer. - - :param str events_url: override the default events API endpoint - :param str people_url: override the default people API endpoint - :param str import_url: override the default import API endpoint - :param int request_timeout: connection timeout in seconds - :param str groups_url: override the default groups API endpoint - :param str api_host: the Mixpanel API domain where all requests should be - issued (unless overridden by above URLs). - """ self._endpoints = { 'events': events_url or 'https://{}/track'.format(api_host), 'people': people_url or 'https://{}/engage'.format(api_host), @@ -558,6 +558,19 @@ class BufferedConsumer(object): them in batches. This can save bandwidth and reduce the total amount of time required to post your events to Mixpanel. + :param int max_size: number of :meth:`~.send` calls for a given endpoint to + buffer before flushing automatically + :param str events_url: override the default events API endpoint + :param str people_url: override the default people API endpoint + :param str import_url: override the default import API endpoint + :param int request_timeout: connection timeout in seconds + :param str groups_url: override the default groups API endpoint + :param str api_host: the Mixpanel API domain where all requests should be + issued (unless overridden by above URLs). + + .. versionadded:: 4.6.0 + The *api_host* parameter. + .. note:: Because :class:`~.BufferedConsumer` holds events, you need to call :meth:`~.flush` when you're sure you're done sending them—for example, @@ -566,17 +579,6 @@ class BufferedConsumer(object): """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): - """ - :param int max_size: number of :meth:`~.send` calls for a given endpoint to - buffer before flushing automatically - :param str events_url: override the default events API endpoint - :param str people_url: override the default people API endpoint - :param str import_url: override the default import API endpoint - :param int request_timeout: connection timeout in seconds - :param str groups_url: override the default groups API endpoint - :param str api_host: the Mixpanel API domain where all requests should be - issued (unless overridden by above URLs). - """ self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url, api_host) self._buffers = { 'events': [], From 902f0faa1c525d9ea5245b73cc58fc18894ad98f Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 14:25:26 -0700 Subject: [PATCH 087/208] Update properties in docs conf. --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8b3f591..8165464 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,9 +18,9 @@ # General information about the project. project = u'mixpanel' -copyright = u' 2019, Mixpanel, Inc.' +copyright = u' 2020, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.5.0' +version = release = '4.6.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' From e69ecf666af7f01b4e09c53bcd953e7c34382810 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 15 Jul 2020 14:26:21 -0700 Subject: [PATCH 088/208] Include docs/conf in release instructions. --- BUILD.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/BUILD.rst b/BUILD.rst index e65a97c..96b9140 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -2,10 +2,11 @@ Release process:: 1. Document all changes in CHANGES.rst. 2. Update __version__ in __init__.py. -3. Tag the version in git. -4. Create a release in GitHub. https://github.com/mixpanel/mixpanel-python/releases -5. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) -6. Publish to PyPI. (see below) +3. Update version in docs/conf.py +4. Tag the version in git. +5. Create a release in GitHub. https://github.com/mixpanel/mixpanel-python/releases +6. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) +7. Publish to PyPI. (see below) Run tests:: From 05e129b26f03b9e3171f494b843ec22516e67564 Mon Sep 17 00:00:00 2001 From: David Grant Date: Sat, 12 Sep 2020 14:01:38 -0700 Subject: [PATCH 089/208] People time prop is in seconds, not milliseconds. --- mixpanel/__init__.py | 4 ++-- test_mixpanel.py | 38 +++++++++++++++++++------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 3f0d1b0..240933a 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -344,7 +344,7 @@ def people_update(self, message, meta=None): """ record = { '$token': self._token, - '$time': int(self._now() * 1000), + '$time': int(self._now()), } record.update(message) if meta: @@ -461,7 +461,7 @@ def group_update(self, message, meta=None): """ record = { '$token': self._token, - '$time': int(self._now() * 1000), + '$time': int(self._now()), } record.update(message) if meta: diff --git a/test_mixpanel.py b/test_mixpanel.py index 73d5af0..125aaa6 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -105,7 +105,7 @@ def test_people_set(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -119,7 +119,7 @@ def test_people_set_once(self): self.mp.people_set_once('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$set_once': { @@ -133,7 +133,7 @@ def test_people_increment(self): self.mp.people_increment('amq', {'Albums Released': 1}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$add': { @@ -146,7 +146,7 @@ def test_people_append(self): self.mp.people_append('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -160,7 +160,7 @@ def test_people_union(self): self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$union': { @@ -173,7 +173,7 @@ def test_people_unset(self): self.mp.people_unset('amq', ['Albums', 'Singles']) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['Albums', 'Singles'], @@ -184,7 +184,7 @@ def test_people_remove(self): self.mp.people_remove('amq', {'Albums': 'Diamond Dogs'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$remove': {'Albums': 'Diamond Dogs'}, @@ -195,7 +195,7 @@ def test_people_track_charge(self): self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -211,7 +211,7 @@ def test_people_track_charge_without_properties(self): self.mp.people_track_charge('amq', 12.65) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -226,7 +226,7 @@ def test_people_clear_charges(self): self.mp.people_clear_charges('amq') assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['$transactions'], @@ -238,7 +238,7 @@ def test_people_set_created_date_string(self): self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -253,7 +253,7 @@ def test_people_set_created_date_datetime(self): self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -297,7 +297,7 @@ def test_people_meta(self): meta={'$ip': 0, '$ignore_time': True}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -313,7 +313,7 @@ def test_group_set(self): self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -328,7 +328,7 @@ def test_group_set_once(self): self.mp.group_set_once('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -343,7 +343,7 @@ def test_group_union(self): self.mp.group_union('company', 'amq', {'Albums': ['Diamond Dogs']}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -357,7 +357,7 @@ def test_group_unset(self): self.mp.group_unset('company', 'amq', ['Albums', 'Singles']) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -369,7 +369,7 @@ def test_group_remove(self): self.mp.group_remove('company', 'amq', {'Albums': 'Diamond Dogs'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now() * 1000), + '$time': int(self.mp._now()), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -536,6 +536,6 @@ def test_track_functional(self): self.mp.track('button press', {'size': 'big', 'color': 'blue'}) def test_people_set_functional(self): - expect_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000000, '$token': '12345'} + expect_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} with self._assertRequested('https://api.mixpanel.com/engage', expect_data): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) From f097d1d7c68cd69d69d178f0b56cb3e8c424e598 Mon Sep 17 00:00:00 2001 From: David Grant Date: Sat, 12 Sep 2020 14:02:54 -0700 Subject: [PATCH 090/208] Test with py3.9-dev. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 20f1ec7..eadac1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9-dev" - "pypy" - "pypy3" install: From a6ec21e6d1df0839a5fb00ca92900bca888b423c Mon Sep 17 00:00:00 2001 From: David Grant Date: Sat, 12 Sep 2020 14:26:03 -0700 Subject: [PATCH 091/208] docstring typo --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 240933a..9d3c17a 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -96,7 +96,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None): - """Record an event that occured more than 5 days in the past. + """Record an event that occurred more than 5 days in the past. :param str api_key: your Mixpanel project's API key :param str distinct_id: identifies the user triggering the event From 728769c846e39e263b4d48888fc10b6e2a9f3266 Mon Sep 17 00:00:00 2001 From: David Grant Date: Sat, 12 Sep 2020 14:31:39 -0700 Subject: [PATCH 092/208] Retry support (#83) * send request w/ Requests. * Move to urllib3, drop base64 encoding, fix tests. * Remove rest of urllib stuff. * json.load str, not bytes. * Stop encoding payload. * POST urlencoded data payload as www-form-urlencoded * Generate , add basic urllib3.Retry config. * remove import * Retry per request, not per PoolMgr. * Timeouts and status code retries. * Expose retry options in consumer initializer. * use uuid4 * Fix str test in py2 * test empty track dict * Retry tweaks. * Groups doc fix --- mixpanel/__init__.py | 76 +++++++++++++++--------- setup.py | 5 +- test_mixpanel.py | 134 +++++++++++++++++++++++-------------------- 3 files changed, 126 insertions(+), 89 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 9d3c17a..af35f85 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -15,15 +15,16 @@ callers to customize the IO characteristics of their tracking. """ from __future__ import absolute_import, unicode_literals -import base64 import datetime import json import time +import uuid import six -from six.moves import urllib +from six.moves import range +import urllib3 -__version__ = '4.6.0' +__version__ = '4.7.0' VERSION = __version__ # TODO: remove when bumping major version. @@ -64,6 +65,9 @@ def __init__(self, token, consumer=None, serializer=DatetimeSerializer): def _now(self): return time.time() + def _make_insert_id(self): + return uuid.uuid4().hex + def track(self, distinct_id, event_name, properties=None, meta=None): """Record an event. @@ -81,6 +85,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): 'token': self._token, 'distinct_id': distinct_id, 'time': int(self._now()), + '$insert_id': self._make_insert_id(), 'mp_lib': 'python', '$lib_version': __version__, } @@ -116,6 +121,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, 'token': self._token, 'distinct_id': distinct_id, 'time': int(timestamp), + '$insert_id': self._make_insert_id(), 'mp_lib': 'python', '$lib_version': __version__, } @@ -351,7 +357,6 @@ def people_update(self, message, meta=None): record.update(meta) self._consumer.send('people', json_dumps(record, cls=self._serializer)) - def group_set(self, group_key, group_id, properties, meta=None): """Set properties of a group profile. @@ -490,26 +495,41 @@ class Consumer(object): :param str groups_url: override the default groups API endpoint :param str api_host: the Mixpanel API domain where all requests should be issued (unless overridden by above URLs). + :param int retry_limit: number of times to retry each retry in case of + connection or HTTP 5xx error; 0 to fail after first attempt. + :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., + sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). .. versionadded:: 4.6.0 The *api_host* parameter. """ def __init__(self, events_url=None, people_url=None, import_url=None, - request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): + request_timeout=None, groups_url=None, api_host="api.mixpanel.com", + retry_limit=4, retry_backoff_factor=0.25): + # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { 'events': events_url or 'https://{}/track'.format(api_host), 'people': people_url or 'https://{}/engage'.format(api_host), 'groups': groups_url or 'https://{}/groups'.format(api_host), 'imports': import_url or 'https://{}/import'.format(api_host), } - self._request_timeout = request_timeout + retry_config = urllib3.Retry( + total=retry_limit, + backoff_factor=retry_backoff_factor, + method_whitelist={'POST'}, + status_forcelist=set(range(500, 600)), + ) + self._http = urllib3.PoolManager( + retries=retry_config, + timeout=urllib3.Timeout(request_timeout), + ) def send(self, endpoint, json_message, api_key=None): """Immediately record an event or a profile update. :param endpoint: the Mixpanel API endpoint appropriate for the message - :type endpoint: "events" | "people" | "imports" + :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key :raises MixpanelException: if the endpoint doesn't exist, the server is @@ -522,34 +542,32 @@ def send(self, endpoint, json_message, api_key=None): def _write_request(self, request_url, json_message, api_key=None): data = { - 'data': base64.b64encode(json_message.encode('utf8')), + 'data': json_message, 'verbose': 1, 'ip': 0, } if api_key: data.update({'api_key': api_key}) - encoded_data = urllib.parse.urlencode(data).encode('utf8') + try: - request = urllib.request.Request(request_url, encoded_data) - - # Note: We don't send timeout=None here, because the timeout in urllib2 defaults to - # an internal socket timeout, not None. - if self._request_timeout is not None: - response = urllib.request.urlopen(request, timeout=self._request_timeout).read() - else: - response = urllib.request.urlopen(request).read() - except urllib.error.URLError as e: + response = self._http.request( + 'POST', + request_url, + fields=data, + encode_multipart=False, # URL-encode payload in POST body. + ) + except Exception as e: six.raise_from(MixpanelException(e), e) try: - response = json.loads(response.decode('utf8')) + response_dict = json.loads(response.data.decode('utf-8')) except ValueError: - raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response)) + raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response.data)) - if response['status'] != 1: - raise MixpanelException('Mixpanel error: {0}'.format(response['error'])) + if response_dict['status'] != 1: + raise MixpanelException('Mixpanel error: {0}'.format(response_dict['error'])) - return True + return True # <- TODO: remove return val with major release. class BufferedConsumer(object): @@ -567,6 +585,10 @@ class BufferedConsumer(object): :param str groups_url: override the default groups API endpoint :param str api_host: the Mixpanel API domain where all requests should be issued (unless overridden by above URLs). + :param int retry_limit: number of times to retry each retry in case of + connection or HTTP 5xx error; 0 to fail after first attempt. + :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., + sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). .. versionadded:: 4.6.0 The *api_host* parameter. @@ -578,8 +600,10 @@ class BufferedConsumer(object): remaining unsent events being held by the instance. """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, - request_timeout=None, groups_url=None, api_host="api.mixpanel.com"): - self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url, api_host) + request_timeout=None, groups_url=None, api_host="api.mixpanel.com", + retry_limit=4, retry_backoff_factor=0.25): + self._consumer = Consumer(events_url, people_url, import_url, request_timeout, + groups_url, api_host, retry_limit, retry_backoff_factor) self._buffers = { 'events': [], 'people': [], @@ -598,7 +622,7 @@ def send(self, endpoint, json_message, api_key=None): :meth:`~.send`. :param endpoint: the Mixpanel API endpoint appropriate for the message - :type endpoint: "events" | "people" | "imports" + :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key :raises MixpanelException: if the endpoint doesn't exist, the server is diff --git a/setup.py b/setup.py index 3984009..c89af8a 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,10 @@ def find_version(*paths): author='Mixpanel, Inc.', author_email='dev@mixpanel.com', license='Apache', - install_requires=['six >= 1.9.0'], + install_requires=[ + 'six >= 1.9.0', + 'urllib3 >= 1.21.1', + ], classifiers=[ 'License :: OSI Approved :: Apache Software License', diff --git a/test_mixpanel.py b/test_mixpanel.py index 125aaa6..142fec4 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -9,7 +9,8 @@ from mock import Mock, patch import pytest import six -from six.moves import range, urllib +from six.moves import range +import urllib3 import mixpanel @@ -26,28 +27,17 @@ def send(self, endpoint, event, api_key=None): self.log.append((endpoint, json.loads(event))) -# Convert a query string with base64 data into a dict for safe comparison. -def qs(s): - if isinstance(s, six.binary_type): - s = s.decode('utf8') - blob = urllib.parse.parse_qs(s) - if len(blob['data']) != 1: - pytest.fail('found multi-item data: %s' % blob['data']) - json_bytes = base64.b64decode(blob['data'][0]) - blob['data'] = json.loads(json_bytes.decode('utf8')) - return blob - - class TestMixpanel: TOKEN = '12345' def setup_method(self, method): self.consumer = LogConsumer() - self.mp = mixpanel.Mixpanel('12345', consumer=self.consumer) + self.mp = mixpanel.Mixpanel(self.TOKEN, consumer=self.consumer) self.mp._now = lambda: 1000.1 + self.mp._make_insert_id = lambda: "abcdefg" def test_track(self): - self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}) + self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) assert self.consumer.log == [( 'events', { 'event': 'button press', @@ -57,15 +47,39 @@ def test_track(self): 'color': 'blue', 'distinct_id': 'ID', 'time': int(self.mp._now()), + '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, } } )] + def test_track_makes_insert_id(self): + self.mp.track('ID', 'button press', {'size': 'big'}) + props = self.consumer.log[0][1]["properties"] + assert "$insert_id" in props + assert isinstance(props["$insert_id"], six.text_type) + assert len(props["$insert_id"]) > 0 + + def test_track_empty(self): + self.mp.track('person_xyz', 'login', {}) + assert self.consumer.log == [( + 'events', { + 'event': 'login', + 'properties': { + 'token': self.TOKEN, + 'distinct_id': 'person_xyz', + 'time': int(self.mp._now()), + '$insert_id': self.mp._make_insert_id(), + 'mp_lib': 'python', + '$lib_version': mixpanel.__version__, + }, + }, + )] + def test_import_data(self): timestamp = time.time() - self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue'}) + self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) assert self.consumer.log == [( 'imports', { 'event': 'button press', @@ -75,6 +89,7 @@ def test_import_data(self): 'color': 'blue', 'distinct_id': 'ID', 'time': int(timestamp), + '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, }, @@ -83,7 +98,7 @@ def test_import_data(self): )] def test_track_meta(self): - self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue'}, + self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, meta={'ip': 0}) assert self.consumer.log == [( 'events', { @@ -94,6 +109,7 @@ def test_track_meta(self): 'color': 'blue', 'distinct_id': 'ID', 'time': int(self.mp._now()), + '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, }, @@ -266,16 +282,17 @@ def test_people_set_created_date_datetime(self): def test_alias(self): # More complicated since alias() forces a synchronous call. mock_response = Mock() - mock_response.read.return_value = six.b('{"status":1, "error": null}') - with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: + mock_response.data = six.b('{"status": 1, "error": null}') + with patch('mixpanel.urllib3.PoolManager.request', return_value=mock_response) as req: self.mp.alias('ALIAS', 'ORIGINAL ID') assert self.consumer.log == [] - assert urlopen.call_count == 1 - ((request,), _) = urlopen.call_args + assert req.call_count == 1 + ((method, url), kwargs) = req.call_args - assert request.get_full_url() == 'https://api.mixpanel.com/track' - assert qs(request.data) == \ - qs('ip=0&data=eyJldmVudCI6IiRjcmVhdGVfYWxpYXMiLCJwcm9wZXJ0aWVzIjp7ImFsaWFzIjoiQUxJQVMiLCJ0b2tlbiI6IjEyMzQ1IiwiZGlzdGluY3RfaWQiOiJPUklHSU5BTCBJRCJ9fQ%3D%3D&verbose=1') + assert method == 'POST' + assert url == 'https://api.mixpanel.com/track' + expected_data = {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} + assert json.loads(kwargs["fields"]["data"]) == expected_data def test_merge(self): self.mp.merge('my_good_api_key', 'd1', 'd2') @@ -389,7 +406,7 @@ def default(self, obj): return obj.to_eng_string() self.mp._serializer = CustomSerializer - self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) + self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string), '$insert_id': 'abc123'}) assert self.consumer.log == [( 'events', { 'event': 'button press', @@ -398,6 +415,7 @@ def default(self, obj): 'size': decimal_string, 'distinct_id': 'ID', 'time': int(self.mp._now()), + '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, } @@ -406,7 +424,6 @@ def default(self, obj): class TestConsumer: - @classmethod def setup_class(cls): cls.consumer = mixpanel.Consumer(request_timeout=30) @@ -417,34 +434,31 @@ def _assertSends(self, expect_url, expect_data, consumer=None): consumer = self.consumer mock_response = Mock() - mock_response.read.return_value = six.b('{"status":1, "error": null}') - with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: + mock_response.data = six.b('{"status": 1, "error": null}') + with patch('mixpanel.urllib3.PoolManager.request', return_value=mock_response) as req: yield - assert urlopen.call_count == 1 - - (call_args, kwargs) = urlopen.call_args - (request,) = call_args - timeout = kwargs.get('timeout', None) - - assert request.get_full_url() == expect_url - assert qs(request.data) == qs(expect_data) - assert timeout == consumer._request_timeout + assert req.call_count == 1 + (call_args, kwargs) = req.call_args + (method, url) = call_args + assert method == 'POST' + assert url == expect_url + assert kwargs["fields"] == expect_data def test_send_events(self): - with self._assertSends('https://api.mixpanel.com/track', 'ip=0&data=IkV2ZW50Ig%3D%3D&verbose=1'): - self.consumer.send('events', '"Event"') + with self._assertSends('https://api.mixpanel.com/track', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}): + self.consumer.send('events', '{"foo":"bar"}') def test_send_people(self): - with self._assertSends('https://api.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1'): - self.consumer.send('people', '"People"') + with self._assertSends('https://api.mixpanel.com/engage', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}): + self.consumer.send('people', '{"foo":"bar"}') def test_consumer_override_api_host(self): consumer = mixpanel.Consumer(api_host="api-eu.mixpanel.com") - with self._assertSends('https://api-eu.mixpanel.com/track', 'ip=0&data=IkV2ZW50Ig%3D%3D&verbose=1', consumer=consumer): - consumer.send('events', '"Event"') - with self._assertSends('https://api-eu.mixpanel.com/engage', 'ip=0&data=IlBlb3BsZSI%3D&verbose=1', consumer=consumer): - consumer.send('people', '"People"') + with self._assertSends('https://api-eu.mixpanel.com/track', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}, consumer=consumer): + consumer.send('events', '{"foo":"bar"}') + with self._assertSends('https://api-eu.mixpanel.com/engage', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}, consumer=consumer): + consumer.send('people', '{"foo":"bar"}') def test_unknown_endpoint(self): with pytest.raises(mixpanel.MixpanelException): @@ -452,7 +466,6 @@ def test_unknown_endpoint(self): class TestBufferedConsumer: - @classmethod def setup_class(cls): cls.MAX_LENGTH = 10 @@ -488,10 +501,10 @@ def test_unknown_endpoint_raises_on_send(self): def test_useful_reraise_in_flush_endpoint(self): error_mock = Mock() - error_mock.read.return_value = six.b('{"status": 0, "error": "arbitrary error"}') + error_mock.data = six.b('{"status": 0, "error": "arbitrary error"}') broken_json = '{broken JSON' consumer = mixpanel.BufferedConsumer(2) - with patch('six.moves.urllib.request.urlopen', return_value=error_mock): + with patch('mixpanel.urllib3.PoolManager.request', return_value=error_mock): consumer.send('events', broken_json) with pytest.raises(mixpanel.MixpanelException) as excinfo: consumer.flush() @@ -506,7 +519,6 @@ def test_send_remembers_api_key(self): class TestFunctional: - @classmethod def setup_class(cls): cls.TOKEN = '12345' @@ -515,25 +527,23 @@ def setup_class(cls): @contextlib.contextmanager def _assertRequested(self, expect_url, expect_data): - mock_response = Mock() - mock_response.read.return_value = six.b('{"status":1, "error": null}') - with patch('six.moves.urllib.request.urlopen', return_value=mock_response) as urlopen: + res = Mock() + res.data = six.b('{"status": 1, "error": null}') + with patch('mixpanel.urllib3.PoolManager.request', return_value=res) as req: yield - assert urlopen.call_count == 1 - ((request,), _) = urlopen.call_args - assert request.get_full_url() == expect_url - data = urllib.parse.parse_qs(request.data.decode('utf8')) - assert len(data['data']) == 1 - payload_encoded = data['data'][0] - payload_json = base64.b64decode(payload_encoded).decode('utf8') - payload = json.loads(payload_json) + assert req.call_count == 1 + ((method, url,), data) = req.call_args + data = data["fields"]["data"] + assert method == 'POST' + assert url == expect_url + payload = json.loads(data) assert payload == expect_data def test_track_functional(self): - expect_data = {'event': {'color': 'blue', 'size': 'big'}, 'properties': {'mp_lib': 'python', 'token': '12345', 'distinct_id': 'button press', '$lib_version': mixpanel.__version__, 'time': 1000}} + expect_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'}} with self._assertRequested('https://api.mixpanel.com/track', expect_data): - self.mp.track('button press', {'size': 'big', 'color': 'blue'}) + self.mp.track('player1', 'button_press', {'size': 'big', 'color': 'blue', '$insert_id': 'xyz1200'}) def test_people_set_functional(self): expect_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} From e658608414592daad75cc1836b9b5dbf5acd30d1 Mon Sep 17 00:00:00 2001 From: David Grant Date: Sat, 12 Sep 2020 14:52:27 -0700 Subject: [PATCH 093/208] Changes & doc version., --- CHANGES.txt | 7 +++++++ docs/conf.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.txt b/CHANGES.txt index c5616fa..f2a5468 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,10 @@ +v4.7.0 +* Form $insert_id for track and import calls (if not present) to enable server-side event deduplication. +* Retry API calls upon connection or HTTP 5xx errors. Added new retry options to Consumer classes. +* Replaced urllib2-based HTTP calls with urllib3. This allows connection pooling as well at the aforementioned retries. +* Stop base64 encoding payloads, as Mixpanel APIs now support naked JSON. +* Bug: $time in people operations should be sent in seconds, not milliseconds. + v4.6.0 * Add `$merge` support. * Support for overriding API host for, say, making calls to EU APIs. diff --git a/docs/conf.py b/docs/conf.py index 8165464..48a7af5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2020, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.6.0' +version = release = '4.7.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' From 085012a5348cf0be383a909c347ddaf4a4e93e31 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 3 Dec 2020 20:11:16 -0800 Subject: [PATCH 094/208] Adding api_secret. --- mixpanel/__init__.py | 47 +++++++++++++++++++++++++++++++++----------- test_mixpanel.py | 10 ++++++---- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index af35f85..e2e5d5f 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -100,7 +100,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): self._consumer.send('events', json_dumps(event, cls=self._serializer)) def import_data(self, api_key, distinct_id, event_name, timestamp, - properties=None, meta=None): + properties=None, meta=None, api_secret=None): """Record an event that occurred more than 5 days in the past. :param str api_key: your Mixpanel project's API key @@ -110,6 +110,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, :param dict properties: additional data to record; keys should be strings, and values should be strings, numbers, or booleans :param dict meta: overrides Mixpanel special properties + :param str api_secret: Your Mixpanel project's API secret. To avoid accidentally recording invalid events, the Mixpanel API's ``track`` endpoint disallows events that occurred too long ago. This @@ -117,6 +118,10 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, for `more details `__. """ + + if api_secret is None: + raise ValueError("api_secret is required in import calls") + all_properties = { 'token': self._token, 'distinct_id': distinct_id, @@ -133,6 +138,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, } if meta: event.update(meta) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def alias(self, alias_id, original, meta=None): @@ -166,7 +172,7 @@ def alias(self, alias_id, original, meta=None): event.update(meta) sync_consumer.send('events', json_dumps(event, cls=self._serializer)) - def merge(self, api_key, distinct_id1, distinct_id2, meta=None): + def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): """ Merges the two given distinct_ids. @@ -174,11 +180,15 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None): :param str distinct_id1: The first distinct_id to merge. :param str distinct_id2: The second (other) distinct_id to merge. :param dict meta: overrides Mixpanel special properties + :param str api_secret: Your Mixpanel project's API secret. See our online documentation for `more details `__. """ + if api_secret is None: + raise ValueError("api_secret is required in merge calls") + event = { 'event': '$merge', 'properties': { @@ -188,7 +198,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None): } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -525,22 +535,27 @@ def __init__(self, events_url=None, people_url=None, import_url=None, timeout=urllib3.Timeout(request_timeout), ) - def send(self, endpoint, json_message, api_key=None): + def send(self, endpoint, json_message, api_key=None, api_secret=None): """Immediately record an event or a profile update. :param endpoint: the Mixpanel API endpoint appropriate for the message :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key + :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or the message cannot be processed + + + .. versionadded:: 4.8.0 + The *api_secret* parameter. """ - if endpoint in self._endpoints: - self._write_request(self._endpoints[endpoint], json_message, api_key) - else: + if endpoint not in self._endpoints: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) - def _write_request(self, request_url, json_message, api_key=None): + self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) + + def _write_request(self, request_url, json_message, api_key=None, api_secret=None): data = { 'data': json_message, 'verbose': 1, @@ -549,11 +564,17 @@ def _write_request(self, request_url, json_message, api_key=None): if api_key: data.update({'api_key': api_key}) + headers = None + + if api_secret is not None: + headers = urllib3.util.make_headers(basic_auth="{}:".format(api_secret)) + try: response = self._http.request( 'POST', request_url, fields=data, + headers=headers, encode_multipart=False, # URL-encode payload in POST body. ) except Exception as e: @@ -613,7 +634,7 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non self._max_size = min(50, max_size) self._api_key = None - def send(self, endpoint, json_message, api_key=None): + def send(self, endpoint, json_message, api_key=None, api_secret=None): """Record an event or profile update. Internally, adds the message to a buffer, and then flushes the buffer @@ -625,6 +646,7 @@ def send(self, endpoint, json_message, api_key=None): :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key + :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or any buffered message cannot be processed @@ -636,8 +658,9 @@ def send(self, endpoint, json_message, api_key=None): buf = self._buffers[endpoint] buf.append(json_message) - if api_key is not None: - self._api_key = api_key + # Fixme: Don't stick these in the instance. + self._api_key = api_key + self._api_secret = api_secret if len(buf) >= self._max_size: self._flush_endpoint(endpoint) @@ -656,7 +679,7 @@ def _flush_endpoint(self, endpoint): batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json, self._api_key) + self._consumer.send(endpoint, batch_json, self._api_key, self._api_secret) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index 142fec4..e2e5074 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -20,11 +20,13 @@ class LogConsumer(object): def __init__(self): self.log = [] - def send(self, endpoint, event, api_key=None): + def send(self, endpoint, event, api_key=None, api_secret=None): + entry = [endpoint, json.loads(event)] if api_key: - self.log.append((endpoint, json.loads(event), api_key)) - else: - self.log.append((endpoint, json.loads(event))) + entry.append(api_key) + if api_secret: + entry.append(api_secret) + self.log.append(tuple(entry)) class TestMixpanel: From 269701e2d2971b54f127ecf36314682acf63e1dd Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 3 Dec 2020 20:13:04 -0800 Subject: [PATCH 095/208] warnings. --- mixpanel/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index e2e5d5f..7eeb2d5 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -17,6 +17,7 @@ from __future__ import absolute_import, unicode_literals import datetime import json +import logging import time import uuid @@ -120,7 +121,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, """ if api_secret is None: - raise ValueError("api_secret is required in import calls") + logging.warning("api_secret is required in import calls") all_properties = { 'token': self._token, @@ -139,7 +140,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) def alias(self, alias_id, original, meta=None): """Creates an alias which Mixpanel will use to remap one id to another. @@ -187,7 +188,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) `__. """ if api_secret is None: - raise ValueError("api_secret is required in merge calls") + logging.warning("api_secret is required in merge calls") event = { 'event': '$merge', From 12e99b1711244412530dca52a6ac23f5ab960fcd Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 16 Dec 2020 22:00:54 -0800 Subject: [PATCH 096/208] Include api_secret in test. --- test_mixpanel.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index e2e5074..f7cca41 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -81,7 +81,9 @@ def test_track_empty(self): def test_import_data(self): timestamp = time.time() - self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) + self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, + {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, + api_secret='MY_SECRET') assert self.consumer.log == [( 'imports', { 'event': 'button press', @@ -96,7 +98,8 @@ def test_import_data(self): '$lib_version': mixpanel.__version__, }, }, - 'MY_API_KEY' + 'MY_API_KEY', + 'MY_SECRET', )] def test_track_meta(self): From b8c0a1a521e9026044b2256528706733ace51f67 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 16 Dec 2020 22:03:14 -0800 Subject: [PATCH 097/208] api_secret in tests. --- test_mixpanel.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test_mixpanel.py b/test_mixpanel.py index f7cca41..afa756d 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -522,6 +522,14 @@ def test_send_remembers_api_key(self): self.consumer.flush() assert self.log == [('imports', ['Event'], 'MY_API_KEY')] + def test_send_remembers_api_secret(self): + self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') + assert len(self.log) == 0 + self.consumer.flush() + assert self.log == [('imports', ['Event'], 'ZZZZZZ')] + + + class TestFunctional: @classmethod From d0f44e9ca1fd318b260b316b0da180002cc065d1 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 16 Dec 2020 22:10:30 -0800 Subject: [PATCH 098/208] Critical log, and mark as deprecated. --- mixpanel/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 7eeb2d5..83ff7da 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -104,7 +104,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None, api_secret=None): """Record an event that occurred more than 5 days in the past. - :param str api_key: your Mixpanel project's API key + :param str api_key: (DEPRECATED) your Mixpanel project's API key :param str distinct_id: identifies the user triggering the event :param str event_name: a name describing the event :param int timestamp: UTC seconds since epoch @@ -113,6 +113,9 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, :param dict meta: overrides Mixpanel special properties :param str api_secret: Your Mixpanel project's API secret. + .. versionadded:: 4.8.0 + The *api_secret* parameter.` + To avoid accidentally recording invalid events, the Mixpanel API's ``track`` endpoint disallows events that occurred too long ago. This method can be used to import such events. See our online documentation @@ -121,7 +124,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, """ if api_secret is None: - logging.warning("api_secret is required in import calls") + logging.critical("api_secret is now required in import_data calls") all_properties = { 'token': self._token, From 6d25d37b53c77eeb013bc081a90676f394994a8b Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 06:22:19 -0800 Subject: [PATCH 099/208] Revert "Critical log, and mark as deprecated." This reverts commit d0f44e9ca1fd318b260b316b0da180002cc065d1. --- mixpanel/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 83ff7da..7eeb2d5 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -104,7 +104,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, properties=None, meta=None, api_secret=None): """Record an event that occurred more than 5 days in the past. - :param str api_key: (DEPRECATED) your Mixpanel project's API key + :param str api_key: your Mixpanel project's API key :param str distinct_id: identifies the user triggering the event :param str event_name: a name describing the event :param int timestamp: UTC seconds since epoch @@ -113,9 +113,6 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, :param dict meta: overrides Mixpanel special properties :param str api_secret: Your Mixpanel project's API secret. - .. versionadded:: 4.8.0 - The *api_secret* parameter.` - To avoid accidentally recording invalid events, the Mixpanel API's ``track`` endpoint disallows events that occurred too long ago. This method can be used to import such events. See our online documentation @@ -124,7 +121,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, """ if api_secret is None: - logging.critical("api_secret is now required in import_data calls") + logging.warning("api_secret is required in import calls") all_properties = { 'token': self._token, From ac16cb28d0a095913ef4f8db6444f07de323e54d Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 06:22:24 -0800 Subject: [PATCH 100/208] Revert "api_secret in tests." This reverts commit b8c0a1a521e9026044b2256528706733ace51f67. --- test_mixpanel.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index afa756d..f7cca41 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -522,14 +522,6 @@ def test_send_remembers_api_key(self): self.consumer.flush() assert self.log == [('imports', ['Event'], 'MY_API_KEY')] - def test_send_remembers_api_secret(self): - self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') - assert len(self.log) == 0 - self.consumer.flush() - assert self.log == [('imports', ['Event'], 'ZZZZZZ')] - - - class TestFunctional: @classmethod From 55ffa5baadb45dc9d4e94f252f37b9f27b9bf7c4 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 06:22:26 -0800 Subject: [PATCH 101/208] Revert "Include api_secret in test." This reverts commit 12e99b1711244412530dca52a6ac23f5ab960fcd. --- test_mixpanel.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index f7cca41..e2e5074 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -81,9 +81,7 @@ def test_track_empty(self): def test_import_data(self): timestamp = time.time() - self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, - {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, - api_secret='MY_SECRET') + self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) assert self.consumer.log == [( 'imports', { 'event': 'button press', @@ -98,8 +96,7 @@ def test_import_data(self): '$lib_version': mixpanel.__version__, }, }, - 'MY_API_KEY', - 'MY_SECRET', + 'MY_API_KEY' )] def test_track_meta(self): From 6d19fc39934d178f4ea49cef9f11213d1dc29b68 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 06:22:26 -0800 Subject: [PATCH 102/208] Revert "warnings." This reverts commit 269701e2d2971b54f127ecf36314682acf63e1dd. --- mixpanel/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 7eeb2d5..e2e5d5f 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -17,7 +17,6 @@ from __future__ import absolute_import, unicode_literals import datetime import json -import logging import time import uuid @@ -121,7 +120,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, """ if api_secret is None: - logging.warning("api_secret is required in import calls") + raise ValueError("api_secret is required in import calls") all_properties = { 'token': self._token, @@ -140,7 +139,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def alias(self, alias_id, original, meta=None): """Creates an alias which Mixpanel will use to remap one id to another. @@ -188,7 +187,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) `__. """ if api_secret is None: - logging.warning("api_secret is required in merge calls") + raise ValueError("api_secret is required in merge calls") event = { 'event': '$merge', From c03895de9fcecd511201773363874f3b29271e1f Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 06:22:28 -0800 Subject: [PATCH 103/208] Revert "Adding api_secret." This reverts commit 085012a5348cf0be383a909c347ddaf4a4e93e31. --- mixpanel/__init__.py | 47 +++++++++++--------------------------------- test_mixpanel.py | 10 ++++------ 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index e2e5d5f..af35f85 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -100,7 +100,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): self._consumer.send('events', json_dumps(event, cls=self._serializer)) def import_data(self, api_key, distinct_id, event_name, timestamp, - properties=None, meta=None, api_secret=None): + properties=None, meta=None): """Record an event that occurred more than 5 days in the past. :param str api_key: your Mixpanel project's API key @@ -110,7 +110,6 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, :param dict properties: additional data to record; keys should be strings, and values should be strings, numbers, or booleans :param dict meta: overrides Mixpanel special properties - :param str api_secret: Your Mixpanel project's API secret. To avoid accidentally recording invalid events, the Mixpanel API's ``track`` endpoint disallows events that occurred too long ago. This @@ -118,10 +117,6 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, for `more details `__. """ - - if api_secret is None: - raise ValueError("api_secret is required in import calls") - all_properties = { 'token': self._token, 'distinct_id': distinct_id, @@ -138,7 +133,6 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def alias(self, alias_id, original, meta=None): @@ -172,7 +166,7 @@ def alias(self, alias_id, original, meta=None): event.update(meta) sync_consumer.send('events', json_dumps(event, cls=self._serializer)) - def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): + def merge(self, api_key, distinct_id1, distinct_id2, meta=None): """ Merges the two given distinct_ids. @@ -180,15 +174,11 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) :param str distinct_id1: The first distinct_id to merge. :param str distinct_id2: The second (other) distinct_id to merge. :param dict meta: overrides Mixpanel special properties - :param str api_secret: Your Mixpanel project's API secret. See our online documentation for `more details `__. """ - if api_secret is None: - raise ValueError("api_secret is required in merge calls") - event = { 'event': '$merge', 'properties': { @@ -198,7 +188,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -535,27 +525,22 @@ def __init__(self, events_url=None, people_url=None, import_url=None, timeout=urllib3.Timeout(request_timeout), ) - def send(self, endpoint, json_message, api_key=None, api_secret=None): + def send(self, endpoint, json_message, api_key=None): """Immediately record an event or a profile update. :param endpoint: the Mixpanel API endpoint appropriate for the message :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key - :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or the message cannot be processed - - - .. versionadded:: 4.8.0 - The *api_secret* parameter. """ - if endpoint not in self._endpoints: + if endpoint in self._endpoints: + self._write_request(self._endpoints[endpoint], json_message, api_key) + else: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) - self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) - - def _write_request(self, request_url, json_message, api_key=None, api_secret=None): + def _write_request(self, request_url, json_message, api_key=None): data = { 'data': json_message, 'verbose': 1, @@ -564,17 +549,11 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non if api_key: data.update({'api_key': api_key}) - headers = None - - if api_secret is not None: - headers = urllib3.util.make_headers(basic_auth="{}:".format(api_secret)) - try: response = self._http.request( 'POST', request_url, fields=data, - headers=headers, encode_multipart=False, # URL-encode payload in POST body. ) except Exception as e: @@ -634,7 +613,7 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non self._max_size = min(50, max_size) self._api_key = None - def send(self, endpoint, json_message, api_key=None, api_secret=None): + def send(self, endpoint, json_message, api_key=None): """Record an event or profile update. Internally, adds the message to a buffer, and then flushes the buffer @@ -646,7 +625,6 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key - :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or any buffered message cannot be processed @@ -658,9 +636,8 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): buf = self._buffers[endpoint] buf.append(json_message) - # Fixme: Don't stick these in the instance. - self._api_key = api_key - self._api_secret = api_secret + if api_key is not None: + self._api_key = api_key if len(buf) >= self._max_size: self._flush_endpoint(endpoint) @@ -679,7 +656,7 @@ def _flush_endpoint(self, endpoint): batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json, self._api_key, self._api_secret) + self._consumer.send(endpoint, batch_json, self._api_key) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index e2e5074..142fec4 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -20,13 +20,11 @@ class LogConsumer(object): def __init__(self): self.log = [] - def send(self, endpoint, event, api_key=None, api_secret=None): - entry = [endpoint, json.loads(event)] + def send(self, endpoint, event, api_key=None): if api_key: - entry.append(api_key) - if api_secret: - entry.append(api_secret) - self.log.append(tuple(entry)) + self.log.append((endpoint, json.loads(event), api_key)) + else: + self.log.append((endpoint, json.loads(event))) class TestMixpanel: From cdc310a9237a8db6382e528b439b25617bfb5dba Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 09:39:35 -0800 Subject: [PATCH 104/208] Add verify_cert consumer option. (#90) --- mixpanel/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index af35f85..02c0b52 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -499,14 +499,17 @@ class Consumer(object): connection or HTTP 5xx error; 0 to fail after first attempt. :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). + :param bool verify_cert: whether to verify the server certificate. Recommended. .. versionadded:: 4.6.0 The *api_host* parameter. + .. versionadded:: 4.8.0 + The *verify_cert* parameter. """ def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { 'events': events_url or 'https://{}/track'.format(api_host), @@ -520,9 +523,11 @@ def __init__(self, events_url=None, people_url=None, import_url=None, method_whitelist={'POST'}, status_forcelist=set(range(500, 600)), ) + cert_reqs = 'CERT_REQUIRED' if verify_cert else 'CERT_NONE' self._http = urllib3.PoolManager( retries=retry_config, timeout=urllib3.Timeout(request_timeout), + cert_reqs=cert_reqs, ) def send(self, endpoint, json_message, api_key=None): @@ -589,9 +594,12 @@ class BufferedConsumer(object): connection or HTTP 5xx error; 0 to fail after first attempt. :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). + :param bool verify_cert: whether to verify the server certificate. Recommended. .. versionadded:: 4.6.0 The *api_host* parameter. + .. versionadded:: 4.8.0 + The *verify_cert* parameter. .. note:: Because :class:`~.BufferedConsumer` holds events, you need to call @@ -601,9 +609,9 @@ class BufferedConsumer(object): """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): self._consumer = Consumer(events_url, people_url, import_url, request_timeout, - groups_url, api_host, retry_limit, retry_backoff_factor) + groups_url, api_host, retry_limit, retry_backoff_factor, verify_cert) self._buffers = { 'events': [], 'people': [], From 57a12d6dee5a2305cceb86d19d8f1fba06a8d826 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 09:44:14 -0800 Subject: [PATCH 105/208] Py version, copyright year, release changes. --- .travis.yml | 2 +- CHANGES.txt | 3 +++ LICENSE.txt | 2 +- tox.ini | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index eadac1c..db0b75f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - "3.6" - "3.7" - "3.8" - - "3.9-dev" + - "3.9" - "pypy" - "pypy3" install: diff --git a/CHANGES.txt b/CHANGES.txt index f2a5468..269849f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.8.0 +* Add optional verify_cert param to Consumer.__init__ for those having trouble with server cert validation. + v4.7.0 * Form $insert_id for track and import calls (if not present) to enable server-side event deduplication. * Retry API calls upon connection or HTTP 5xx errors. Added new retry options to Consumer classes. diff --git a/LICENSE.txt b/LICENSE.txt index 0e4c7f6..e0d9fde 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ - Copyright 2013 Mixpanel, Inc. + Copyright 2013-2021 Mixpanel, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/tox.ini b/tox.ini index df07398..9068f32 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37, py38 +envlist = py27, py34, py35, py36, py37, py38, py39 [testenv] deps = -rrequirements-testing.txt From 2c17d99d07381ac6e231cc6f5182858d54b7dc8c Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 09:45:02 -0800 Subject: [PATCH 106/208] api_secret support (#89) * api_secret support. * Clarify key/secret a little more. * Don't use root logger. --- mixpanel/__init__.py | 76 ++++++++++++++++++++++++++++++++++---------- test_mixpanel.py | 25 +++++++++++---- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 02c0b52..f5fdfc6 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -17,6 +17,7 @@ from __future__ import absolute_import, unicode_literals import datetime import json +import logging import time import uuid @@ -24,9 +25,11 @@ from six.moves import range import urllib3 -__version__ = '4.7.0' +__version__ = '4.8.0' VERSION = __version__ # TODO: remove when bumping major version. +logger = logging.getLogger(__name__) + class DatetimeSerializer(json.JSONEncoder): def default(self, obj): @@ -100,16 +103,26 @@ def track(self, distinct_id, event_name, properties=None, meta=None): self._consumer.send('events', json_dumps(event, cls=self._serializer)) def import_data(self, api_key, distinct_id, event_name, timestamp, - properties=None, meta=None): + properties=None, meta=None, api_secret=None): """Record an event that occurred more than 5 days in the past. - :param str api_key: your Mixpanel project's API key + :param str api_key: (DEPRECATED) your Mixpanel project's API key :param str distinct_id: identifies the user triggering the event :param str event_name: a name describing the event :param int timestamp: UTC seconds since epoch :param dict properties: additional data to record; keys should be strings, and values should be strings, numbers, or booleans :param dict meta: overrides Mixpanel special properties + :param str api_secret: Your Mixpanel project's API secret. + + Important: Mixpanel's ``import`` HTTP endpoint requires the project API + secret found in your Mixpanel project's settings. The older API key is + no longer accessible in the Mixpanel UI, but will continue to work. + The api_key parameter will be removed in an upcoming release of + mixpanel-python. + + .. versionadded:: 4.8.0 + The *api_secret* parameter. To avoid accidentally recording invalid events, the Mixpanel API's ``track`` endpoint disallows events that occurred too long ago. This @@ -117,6 +130,10 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, for `more details `__. """ + + if api_secret is None: + logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") + all_properties = { 'token': self._token, 'distinct_id': distinct_id, @@ -133,7 +150,8 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) + + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) def alias(self, alias_id, original, meta=None): """Creates an alias which Mixpanel will use to remap one id to another. @@ -166,19 +184,32 @@ def alias(self, alias_id, original, meta=None): event.update(meta) sync_consumer.send('events', json_dumps(event, cls=self._serializer)) - def merge(self, api_key, distinct_id1, distinct_id2, meta=None): + def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): """ Merges the two given distinct_ids. - :param str api_key: Your Mixpanel project's API key. + :param str api_key: (DEPRECATED) Your Mixpanel project's API key. :param str distinct_id1: The first distinct_id to merge. :param str distinct_id2: The second (other) distinct_id to merge. :param dict meta: overrides Mixpanel special properties + :param str api_secret: Your Mixpanel project's API secret. + + Important: Mixpanel's ``merge`` HTTP endpoint requires the project API + secret found in your Mixpanel project's settings. The older API key is + no longer accessible in the Mixpanel UI, but will continue to work. + The api_key parameter will be removed in an upcoming release of + mixpanel-python. + + .. versionadded:: 4.8.0 + The *api_secret* parameter. See our online documentation for `more details `__. """ + if api_secret is None: + logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") + event = { 'event': '$merge', 'properties': { @@ -188,7 +219,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None): } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -530,22 +561,27 @@ def __init__(self, events_url=None, people_url=None, import_url=None, cert_reqs=cert_reqs, ) - def send(self, endpoint, json_message, api_key=None): + def send(self, endpoint, json_message, api_key=None, api_secret=None): """Immediately record an event or a profile update. :param endpoint: the Mixpanel API endpoint appropriate for the message :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key + :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or the message cannot be processed + + + .. versionadded:: 4.8.0 + The *api_secret* parameter. """ - if endpoint in self._endpoints: - self._write_request(self._endpoints[endpoint], json_message, api_key) - else: + if endpoint not in self._endpoints: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) - def _write_request(self, request_url, json_message, api_key=None): + self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) + + def _write_request(self, request_url, json_message, api_key=None, api_secret=None): data = { 'data': json_message, 'verbose': 1, @@ -554,11 +590,17 @@ def _write_request(self, request_url, json_message, api_key=None): if api_key: data.update({'api_key': api_key}) + headers = None + + if api_secret is not None: + headers = urllib3.util.make_headers(basic_auth="{}:".format(api_secret)) + try: response = self._http.request( 'POST', request_url, fields=data, + headers=headers, encode_multipart=False, # URL-encode payload in POST body. ) except Exception as e: @@ -621,7 +663,7 @@ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=Non self._max_size = min(50, max_size) self._api_key = None - def send(self, endpoint, json_message, api_key=None): + def send(self, endpoint, json_message, api_key=None, api_secret=None): """Record an event or profile update. Internally, adds the message to a buffer, and then flushes the buffer @@ -633,6 +675,7 @@ def send(self, endpoint, json_message, api_key=None): :type endpoint: "events" | "people" | "groups" | "imports" :param str json_message: a JSON message formatted for the endpoint :param str api_key: your Mixpanel project's API key + :param str api_secret: your Mixpanel project's API secret :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or any buffered message cannot be processed @@ -644,8 +687,9 @@ def send(self, endpoint, json_message, api_key=None): buf = self._buffers[endpoint] buf.append(json_message) - if api_key is not None: - self._api_key = api_key + # Fixme: Don't stick these in the instance. + self._api_key = api_key + self._api_secret = api_secret if len(buf) >= self._max_size: self._flush_endpoint(endpoint) @@ -664,7 +708,7 @@ def _flush_endpoint(self, endpoint): batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json, self._api_key) + self._consumer.send(endpoint, batch_json, self._api_key, self._api_secret) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index 142fec4..afa756d 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -20,11 +20,13 @@ class LogConsumer(object): def __init__(self): self.log = [] - def send(self, endpoint, event, api_key=None): + def send(self, endpoint, event, api_key=None, api_secret=None): + entry = [endpoint, json.loads(event)] if api_key: - self.log.append((endpoint, json.loads(event), api_key)) - else: - self.log.append((endpoint, json.loads(event))) + entry.append(api_key) + if api_secret: + entry.append(api_secret) + self.log.append(tuple(entry)) class TestMixpanel: @@ -79,7 +81,9 @@ def test_track_empty(self): def test_import_data(self): timestamp = time.time() - self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) + self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, + {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, + api_secret='MY_SECRET') assert self.consumer.log == [( 'imports', { 'event': 'button press', @@ -94,7 +98,8 @@ def test_import_data(self): '$lib_version': mixpanel.__version__, }, }, - 'MY_API_KEY' + 'MY_API_KEY', + 'MY_SECRET', )] def test_track_meta(self): @@ -517,6 +522,14 @@ def test_send_remembers_api_key(self): self.consumer.flush() assert self.log == [('imports', ['Event'], 'MY_API_KEY')] + def test_send_remembers_api_secret(self): + self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') + assert len(self.log) == 0 + self.consumer.flush() + assert self.log == [('imports', ['Event'], 'ZZZZZZ')] + + + class TestFunctional: @classmethod From 7cac4a7cf5d984d5f8975465fee8774e80fde885 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 10:05:52 -0800 Subject: [PATCH 107/208] 4.8.0 version notes. --- CHANGES.txt | 6 +++++- docs/.DS_Store | Bin 0 -> 8196 bytes docs/conf.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 docs/.DS_Store diff --git a/CHANGES.txt b/CHANGES.txt index 269849f..5f522d6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,9 @@ v4.8.0 -* Add optional verify_cert param to Consumer.__init__ for those having trouble with server cert validation. +* Add api_secret parameter to import_data and merge methods. API secret is the + new preferred auth mechanism; the old API Key still works but is no longer + accessible in the Mixpanel settings UI. (ref: issues #85, #88) +* Add optional verify_cert param to Consumer.__init__ for those having trouble + with server cert validation. (ref: issue #86) v4.7.0 * Form $insert_id for track and import calls (if not present) to enable server-side event deduplication. diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..27fc31ed3f18788c53ba308bf82fd15bc1e9dec9 GIT binary patch literal 8196 zcmeHMTTC2P82*2t?aa{2P)Z97B`YLA1I48+(9)(Xw-y2h>@L^RuCqH+m^jSf&g?=( z5^Up}q1HFs2aTzh1{0rn(3g7Gq?)t|G1>=X;+s##2NPqw{AbQU<@R7=l%#W#bN+MA z|NrOmoo~;{KMMfZn%3$73IRZ%%%q%4#RiG#Szed2{GJ?=Nd5pOXo)FjGGW?@b=E;g zfIxsifIxsifIxu2RzQH>Y?g#g-hHkO$^d}?fkzSnc0YtDGa2#a9GBkJL4_9rkYpi% zm#9v0faeqPWyF_rTzalFCXXJ_cSS$MK<-ZZ5Hlwk@#P$s+#Qg+1Nt+gAEBT(JNbn? zbHE6fK^Y(rAg~?*_Vd{eaY#TKj5YE5FNsOhaNKbC8_dei*|vR$AWFGn?(SUiLUi1o zin^+sc3Ra`TrPgrE4)`S|4 z9Gk5P%``TKYeL74Hq6e7f>2a>=G?{fmH8VtZ@uy6TOWVIK|uc^fjr(`DLGip4mJ43EM?Xud4W2f9U&oC~_X-7Y&lbw;#+sWD)GH5tvCZo8pv*2J^ zMRmBo>2%xDuHAd&JSkrqAsj8&xMb+5Vbw|MmS$LE{Zs5{1~Z0ZBuqVKsS~=Ghc)8T zuKfoJi;5M68$5JaHXTYQjAzn@ds!|Q1aFQ~B_xe%w3H`ojm@G7@*<5xvzIF&ZGGezo{m4QPnZJ*wmOPU;q0(ifzot+j^~Wtiz&G>t8Yh*QOnnqyEN z3Hv86KoQi^Ytsp%pu;3wg%@ESuE9;X1@FRpa0fnzFX1b=2lwFtd=Edu&+r@k4u8O( zC}08Z$3iT^GOWeNF^nhhG&W-kwqiH-;gcA}7+%06YIq69a1y8SGS1+0_yW%3HGBzQ z!ME`?F5o-3hR*rJb`Td3cvjd&cJ@Dz6789a+!M86pJ;{Xofh(|x2=x1OG9dt26G<+Vf;MElxUdJ2w zD!#_qwL-d1pKZRt+f-Rax=b`>nYJ}X{mVpM?Gm)MAOhKZO@%D}@7VnN{}x<%u%rNi z0D*rC0nBNSwztw~o88?k)(%mAiZV;gZjMXOg$gemC&}UoreFEPkm@17FXGENE=fb_ SKmQOAy#K-b?=kPuF8%`Q8u76J literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py index 48a7af5..da5e5a0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,9 +18,9 @@ # General information about the project. project = u'mixpanel' -copyright = u' 2020, Mixpanel, Inc.' +copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.7.0' +version = release = '4.8.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' From 0ef0d3c45af7053ee26014db893099d521c18cd9 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 11:09:40 -0800 Subject: [PATCH 108/208] Doc link fixes. --- mixpanel/__init__.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index f5fdfc6..9ed62b0 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -7,7 +7,7 @@ documentation`_. If your users are interacting with your application via the web, you may also be interested in our `JavaScript library`_. -.. _`Javascript library`: https://developer.mixpanel.com/docs/javascript +.. _`JavaScript library`: https://developer.mixpanel.com/docs/javascript .. _`usage documentation`: https://developer.mixpanel.com/docs/python :class:`~.Mixpanel` is the primary class for tracking events and sending People @@ -115,7 +115,8 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, :param dict meta: overrides Mixpanel special properties :param str api_secret: Your Mixpanel project's API secret. - Important: Mixpanel's ``import`` HTTP endpoint requires the project API + .. Important:: + Mixpanel's ``import`` HTTP endpoint requires the project API secret found in your Mixpanel project's settings. The older API key is no longer accessible in the Mixpanel UI, but will continue to work. The api_key parameter will be removed in an upcoming release of @@ -128,7 +129,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, ``track`` endpoint disallows events that occurred too long ago. This method can be used to import such events. See our online documentation for `more details - `__. + `__. """ if api_secret is None: @@ -165,7 +166,7 @@ def alias(self, alias_id, original, meta=None): Events triggered by the new id will be associated with the existing user's profile and behavior. See our online documentation for `more details - `__. + `__. .. note:: Calling this method *always* results in a synchronous HTTP request @@ -194,7 +195,8 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) :param dict meta: overrides Mixpanel special properties :param str api_secret: Your Mixpanel project's API secret. - Important: Mixpanel's ``merge`` HTTP endpoint requires the project API + .. Important:: + Mixpanel's ``merge`` HTTP endpoint requires the project API secret found in your Mixpanel project's settings. The older API key is no longer accessible in the Mixpanel UI, but will continue to work. The api_key parameter will be removed in an upcoming release of @@ -205,7 +207,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) See our online documentation for `more details - `__. + `__. """ if api_secret is None: logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") @@ -372,12 +374,12 @@ def people_update(self, message, meta=None): :param dict message: the message to send - Callers are responsible for formatting the update message as documented - in the `Mixpanel HTTP specification`_. This method may be useful if you + Callers are responsible for formatting the update message as described + in the `user profiles documentation`_. This method may be useful if you want to use very new or experimental features of people analytics, but please use the other ``people_*`` methods where possible. - .. _`Mixpanel HTTP specification`: https://developer.mixpanel.com/docs/http + .. _`user profiles documentation`: https://developer.mixpanel.com/reference/user-profiles """ record = { '$token': self._token, @@ -489,11 +491,11 @@ def group_update(self, message, meta=None): :param dict message: the message to send Callers are responsible for formatting the update message as documented - in the `Mixpanel HTTP specification`_. This method may be useful if you + in the `group profiles documentation`_. This method may be useful if you want to use very new or experimental features, but please use the other ``group_*`` methods where possible. - .. _`Mixpanel HTTP specification`: https://developer.mixpanel.com/docs/http + .. _`group profiles documentation`: https://developer.mixpanel.com/reference/group-profiles """ record = { '$token': self._token, From bcde45c22c12aa7a6290232bc3fbd1841c0fec9e Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 11:18:18 -0800 Subject: [PATCH 109/208] Remove link to special props stuff, no longer exists. --- mixpanel/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 9ed62b0..13cbf53 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -228,9 +228,7 @@ def people_set(self, distinct_id, properties, meta=None): :param str distinct_id: the profile to update :param dict properties: properties to set - :param dict meta: overrides Mixpanel `special properties`_ - - .. _`special properties`: https://developer.mixpanel.com/docs/http#section-storing-user-profiles + :param dict meta: overrides Mixpanel special properties If the profile does not exist, creates a new profile with these properties. """ From 757af58710d62ced4ee05d7db72b74a3c433173d Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Dec 2020 12:15:14 -0800 Subject: [PATCH 110/208] Delete .DS_Store --- docs/.DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 27fc31ed3f18788c53ba308bf82fd15bc1e9dec9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMTTC2P82*2t?aa{2P)Z97B`YLA1I48+(9)(Xw-y2h>@L^RuCqH+m^jSf&g?=( z5^Up}q1HFs2aTzh1{0rn(3g7Gq?)t|G1>=X;+s##2NPqw{AbQU<@R7=l%#W#bN+MA z|NrOmoo~;{KMMfZn%3$73IRZ%%%q%4#RiG#Szed2{GJ?=Nd5pOXo)FjGGW?@b=E;g zfIxsifIxsifIxu2RzQH>Y?g#g-hHkO$^d}?fkzSnc0YtDGa2#a9GBkJL4_9rkYpi% zm#9v0faeqPWyF_rTzalFCXXJ_cSS$MK<-ZZ5Hlwk@#P$s+#Qg+1Nt+gAEBT(JNbn? zbHE6fK^Y(rAg~?*_Vd{eaY#TKj5YE5FNsOhaNKbC8_dei*|vR$AWFGn?(SUiLUi1o zin^+sc3Ra`TrPgrE4)`S|4 z9Gk5P%``TKYeL74Hq6e7f>2a>=G?{fmH8VtZ@uy6TOWVIK|uc^fjr(`DLGip4mJ43EM?Xud4W2f9U&oC~_X-7Y&lbw;#+sWD)GH5tvCZo8pv*2J^ zMRmBo>2%xDuHAd&JSkrqAsj8&xMb+5Vbw|MmS$LE{Zs5{1~Z0ZBuqVKsS~=Ghc)8T zuKfoJi;5M68$5JaHXTYQjAzn@ds!|Q1aFQ~B_xe%w3H`ojm@G7@*<5xvzIF&ZGGezo{m4QPnZJ*wmOPU;q0(ifzot+j^~Wtiz&G>t8Yh*QOnnqyEN z3Hv86KoQi^Ytsp%pu;3wg%@ESuE9;X1@FRpa0fnzFX1b=2lwFtd=Edu&+r@k4u8O( zC}08Z$3iT^GOWeNF^nhhG&W-kwqiH-;gcA}7+%06YIq69a1y8SGS1+0_yW%3HGBzQ z!ME`?F5o-3hR*rJb`Td3cvjd&cJ@Dz6789a+!M86pJ;{Xofh(|x2=x1OG9dt26G<+Vf;MElxUdJ2w zD!#_qwL-d1pKZRt+f-Rax=b`>nYJ}X{mVpM?Gm)MAOhKZO@%D}@7VnN{}x<%u%rNi z0D*rC0nBNSwztw~o88?k)(%mAiZV;gZjMXOg$gemC&}UoreFEPkm@17FXGENE=fb_ SKmQOAy#K-b?=kPuF8%`Q8u76J From 95148379bb99223cf87c32e2ec5dcb888856a048 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 18 Dec 2020 08:53:47 -0800 Subject: [PATCH 111/208] Fix group_set doc link ref. --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 13cbf53..994fb72 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -394,7 +394,7 @@ def group_set(self, group_key, group_id, properties, meta=None): :param str group_key: the group key, e.g. 'company' :param str group_id: the group to update :param dict properties: properties to set - :param dict meta: overrides Mixpanel `special properties`_. (See also `Mixpanel.people_set`.) + :param dict meta: overrides Mixpanel special properties. (See also `Mixpanel.people_set`.) If the profile does not exist, creates a new profile with these properties. """ From 721e41c95eaad80429567cb0d13a77423a191b60 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 18 Dec 2020 08:54:31 -0800 Subject: [PATCH 112/208] add .DS_Store --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9daca02..967442f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ dist docs/_build .idea/ .cache/ - +.DS_Store From 6bd8d15e4167b4e411d90e09da37e413aa2c177e Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 5 Jan 2021 11:37:32 -0800 Subject: [PATCH 113/208] For subclasser compatibility, cram (key, secret) into api_key param. (#92) * For subclasser compatibility, cram (key,secret) into api_key param. * Fix tests. --- mixpanel/__init__.py | 17 +++++++++++++---- test_mixpanel.py | 36 ++++++++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 994fb72..387467f 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -152,7 +152,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) def alias(self, alias_id, original, meta=None): """Creates an alias which Mixpanel will use to remap one id to another. @@ -221,7 +221,7 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), api_key, api_secret) + self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -572,7 +572,6 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): :raises MixpanelException: if the endpoint doesn't exist, the server is unreachable, or the message cannot be processed - .. versionadded:: 4.8.0 The *api_secret* parameter. """ @@ -587,6 +586,12 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non 'verbose': 1, 'ip': 0, } + + if isinstance(api_key, tuple): + # For compatibility with subclassers, allow the auth details to be + # packed into the existing api_key param. + api_key, api_secret = api_key + if api_key: data.update({'api_key': api_key}) @@ -685,6 +690,9 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): if endpoint not in self._buffers: raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) + if not isinstance(api_key, tuple): + api_key = (api_key, api_secret) + buf = self._buffers[endpoint] buf.append(json_message) # Fixme: Don't stick these in the instance. @@ -704,11 +712,12 @@ def flush(self): def _flush_endpoint(self, endpoint): buf = self._buffers[endpoint] + while buf: batch = buf[:self._max_size] batch_json = '[{0}]'.format(','.join(batch)) try: - self._consumer.send(endpoint, batch_json, self._api_key, self._api_secret) + self._consumer.send(endpoint, batch_json, api_key=self._api_key) except MixpanelException as orig_e: mp_e = MixpanelException(orig_e) mp_e.message = batch_json diff --git a/test_mixpanel.py b/test_mixpanel.py index afa756d..4e4c8bf 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -16,18 +16,21 @@ class LogConsumer(object): - def __init__(self): self.log = [] def send(self, endpoint, event, api_key=None, api_secret=None): entry = [endpoint, json.loads(event)] - if api_key: - entry.append(api_key) - if api_secret: - entry.append(api_secret) + if api_key != (None, None): + if api_key: + entry.append(api_key) + if api_secret: + entry.append(api_secret) self.log.append(tuple(entry)) + def clear(self): + self.log = [] + class TestMixpanel: TOKEN = '12345' @@ -98,8 +101,7 @@ def test_import_data(self): '$lib_version': mixpanel.__version__, }, }, - 'MY_API_KEY', - 'MY_SECRET', + ('MY_API_KEY', 'MY_SECRET'), )] def test_track_meta(self): @@ -301,7 +303,21 @@ def test_alias(self): def test_merge(self): self.mp.merge('my_good_api_key', 'd1', 'd2') + assert self.consumer.log == [( + 'imports', + { + 'event': '$merge', + 'properties': { + '$distinct_ids': ['d1', 'd2'], + 'token': self.TOKEN, + } + }, + ('my_good_api_key', None), + )] + + self.consumer.clear() + self.mp.merge('my_good_api_key', 'd1', 'd2', api_secret='my_secret') assert self.consumer.log == [( 'imports', { @@ -311,7 +327,7 @@ def test_merge(self): 'token': self.TOKEN, } }, - 'my_good_api_key', + ('my_good_api_key', 'my_secret'), )] def test_people_meta(self): @@ -520,13 +536,13 @@ def test_send_remembers_api_key(self): self.consumer.send('imports', '"Event"', api_key='MY_API_KEY') assert len(self.log) == 0 self.consumer.flush() - assert self.log == [('imports', ['Event'], 'MY_API_KEY')] + assert self.log == [('imports', ['Event'], ('MY_API_KEY', None))] def test_send_remembers_api_secret(self): self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') assert len(self.log) == 0 self.consumer.flush() - assert self.log == [('imports', ['Event'], 'ZZZZZZ')] + assert self.log == [('imports', ['Event'], (None, 'ZZZZZZ'))] From 125b391dd2437ad26e2789f06e8129009a2318a9 Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 5 Jan 2021 11:40:48 -0800 Subject: [PATCH 114/208] Release updates. --- CHANGES.txt | 4 ++++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 5f522d6..7431268 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +v4.8.1 +A compatibility bugfix -- 4.8.0 broke subclassing compatibility with some + other libraries. + v4.8.0 * Add api_secret parameter to import_data and merge methods. API secret is the new preferred auth mechanism; the old API Key still works but is no longer diff --git a/docs/conf.py b/docs/conf.py index da5e5a0..76e34aa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.8.0' +version = release = '4.8.1' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 387467f..1ccf3eb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -25,7 +25,7 @@ from six.moves import range import urllib3 -__version__ = '4.8.0' +__version__ = '4.8.1' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From 2822a86d021b0c59b8c5cbd315fa6665b265bb0f Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 5 Jan 2021 18:13:36 -0800 Subject: [PATCH 115/208] Actions testing. --- .github/workflows/test.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..06c4067 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [2.7, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-testing.txt + - name: Test with pytest + run: | + pytest test_mixpanel.py \ No newline at end of file From 48bc231e6de1ad6c673d0644a904d123e93ad6f6 Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 5 Jan 2021 19:06:07 -0800 Subject: [PATCH 116/208] actions tweaks --- .github/workflows/test.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 06c4067..9b474a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,9 @@ -name: Python package +name: Tests on: [push] jobs: - build: - + test: runs-on: ubuntu-latest strategy: matrix: From 0d88c32ecf8d8594cf39c87a24269cf0aefc6103 Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 5 Jan 2021 19:13:37 -0800 Subject: [PATCH 117/208] Remove Travis. --- .travis.yml | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index db0b75f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "3.9" - - "pypy" - - "pypy3" -install: - - "pip install ." - - "pip install -r requirements-testing.txt" -script: py.test From b8a299862253761945fb7efc96afe4d94011ee7c Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 6 Jan 2021 08:44:46 -0800 Subject: [PATCH 118/208] Swap in GH Actions badge. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b525404..72fcba1 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -mixpanel-python |travis-badge| +mixpanel-python ![Tests](https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg) ============================== This is the official Mixpanel Python library. This library allows for From 6644fa13359631ade10cb15e2e825ef4bd4863a9 Mon Sep 17 00:00:00 2001 From: David Grant Date: Wed, 6 Jan 2021 08:47:37 -0800 Subject: [PATCH 119/208] Fix badge. --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 72fcba1..c042b07 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ -mixpanel-python ![Tests](https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg) +mixpanel-python ============================== +.. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg + This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. From 90cd7a7aab2cc7c9fda370bfb1bfe8ad58ab5efc Mon Sep 17 00:00:00 2001 From: Hugo Arregui Date: Tue, 23 Feb 2021 19:22:11 -0300 Subject: [PATCH 120/208] Fix https://github.com/mixpanel/mixpanel-python/issues/94 (#95) --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 1ccf3eb..0eebe2b 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -558,7 +558,7 @@ def __init__(self, events_url=None, people_url=None, import_url=None, self._http = urllib3.PoolManager( retries=retry_config, timeout=urllib3.Timeout(request_timeout), - cert_reqs=cert_reqs, + cert_reqs=str(cert_reqs), ) def send(self, endpoint, json_message, api_key=None, api_secret=None): From f74238b395818824cd398c24d95e35e4af7af941 Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 23 Feb 2021 14:25:34 -0800 Subject: [PATCH 121/208] Work around renamed arg in urllib3. (#96) --- mixpanel/__init__.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 0eebe2b..a4738d9 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -548,12 +548,22 @@ def __init__(self, events_url=None, people_url=None, import_url=None, 'groups': groups_url or 'https://{}/groups'.format(api_host), 'imports': import_url or 'https://{}/import'.format(api_host), } - retry_config = urllib3.Retry( - total=retry_limit, - backoff_factor=retry_backoff_factor, - method_whitelist={'POST'}, - status_forcelist=set(range(500, 600)), - ) + + retry_args = { + "total": retry_limit, + "backoff_factor": retry_backoff_factor, + "status_forcelist": set(range(500, 600)), + } + + # Work around renamed argument in urllib3. + if hasattr(urllib3.util.Retry.DEFAULT, "allowed_methods"): + methods_arg = "allowed_methods" + else: + methods_arg = "method_whitelist" + + retry_args[methods_arg] = {"POST"} + retry_config = urllib3.Retry(**retry_args) + cert_reqs = 'CERT_REQUIRED' if verify_cert else 'CERT_NONE' self._http = urllib3.PoolManager( retries=retry_config, From 3ae1caf36d7b648f00ac698e33123d6658c654cd Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 23 Feb 2021 14:31:30 -0800 Subject: [PATCH 122/208] 4.8.2 release changes. --- CHANGES.txt | 5 +++++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 7431268..99b1584 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +v4.8.2 +Bugfix release: +* Fix DeprecationWarning in urllib3 when using older argument name. (issue #93) +* Fix creation of urllib3.PoolManager under Python 2 with unicode_literals. (issue #94 - thanks, Hugo Arregui!) + v4.8.1 A compatibility bugfix -- 4.8.0 broke subclassing compatibility with some other libraries. diff --git a/docs/conf.py b/docs/conf.py index 76e34aa..22889ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.8.1' +version = release = '4.8.2' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index a4738d9..249e904 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -25,7 +25,7 @@ from six.moves import range import urllib3 -__version__ = '4.8.1' +__version__ = '4.8.2' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From a9f753dcf3234c853f72afa0a439e3c40d8c9930 Mon Sep 17 00:00:00 2001 From: David Grant Date: Tue, 23 Feb 2021 14:51:56 -0800 Subject: [PATCH 123/208] Tag example. --- BUILD.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILD.rst b/BUILD.rst index 96b9140..4a0e8c6 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -3,7 +3,7 @@ Release process:: 1. Document all changes in CHANGES.rst. 2. Update __version__ in __init__.py. 3. Update version in docs/conf.py -4. Tag the version in git. +4. Tag the version in git. (ex: git tag 4.8.2 && git push --tags) 5. Create a release in GitHub. https://github.com/mixpanel/mixpanel-python/releases 6. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) 7. Publish to PyPI. (see below) From 77d62c42850456414e58a0234b7caecabe35dca3 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 9 Apr 2021 15:16:46 -0700 Subject: [PATCH 124/208] Don't verify server cert by default. (#99) * Don't verify server cert by default. * Ubuntu-latest breaks tests. * Silence urllib3 warning. --- .github/workflows/test.yml | 2 +- mixpanel/__init__.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b474a8..b9ec64c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 strategy: matrix: python-version: [2.7, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 249e904..bb72d12 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -65,6 +65,8 @@ def __init__(self, token, consumer=None, serializer=DatetimeSerializer): self._consumer = consumer or Consumer() self._serializer = serializer + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def _now(self): return time.time() @@ -530,7 +532,7 @@ class Consumer(object): connection or HTTP 5xx error; 0 to fail after first attempt. :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). - :param bool verify_cert: whether to verify the server certificate. Recommended. + :param bool verify_cert: whether to verify the server certificate. .. versionadded:: 4.6.0 The *api_host* parameter. @@ -540,7 +542,7 @@ class Consumer(object): def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=False): # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { 'events': events_url or 'https://{}/track'.format(api_host), @@ -651,7 +653,7 @@ class BufferedConsumer(object): connection or HTTP 5xx error; 0 to fail after first attempt. :param int retry_backoff_factor: In case of retries, controls sleep time. e.g., sleep_seconds = backoff_factor * (2 ^ (num_total_retries - 1)). - :param bool verify_cert: whether to verify the server certificate. Recommended. + :param bool verify_cert: whether to verify the server certificate. .. versionadded:: 4.6.0 The *api_host* parameter. @@ -666,7 +668,7 @@ class BufferedConsumer(object): """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=False): self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url, api_host, retry_limit, retry_backoff_factor, verify_cert) self._buffers = { From 0937b3b91a98ac6700bc40b0aff2d2d67bc390a8 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 9 Apr 2021 15:19:23 -0700 Subject: [PATCH 125/208] release changes --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 99b1584..6c3cb5b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.8.3 +* Do not verify server cert by default. (issue #97) + v4.8.2 Bugfix release: * Fix DeprecationWarning in urllib3 when using older argument name. (issue #93) diff --git a/docs/conf.py b/docs/conf.py index 22889ed..58d5286 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.8.2' +version = release = '4.8.3' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index bb72d12..391c8eb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -25,7 +25,7 @@ from six.moves import range import urllib3 -__version__ = '4.8.2' +__version__ = '4.8.3' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From d6ec4cf413e253f67e6b5e476fbad73977eefb38 Mon Sep 17 00:00:00 2001 From: Matt Layman Date: Sat, 12 Jun 2021 16:56:16 -0400 Subject: [PATCH 126/208] Only disable InsecureRequestWarning when verify_cert=False. (#102) --- mixpanel/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 391c8eb..b270b14 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -65,8 +65,6 @@ def __init__(self, token, consumer=None, serializer=DatetimeSerializer): self._consumer = consumer or Consumer() self._serializer = serializer - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - def _now(self): return time.time() @@ -566,6 +564,9 @@ def __init__(self, events_url=None, people_url=None, import_url=None, retry_args[methods_arg] = {"POST"} retry_config = urllib3.Retry(**retry_args) + if not verify_cert: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + cert_reqs = 'CERT_REQUIRED' if verify_cert else 'CERT_NONE' self._http = urllib3.PoolManager( retries=retry_config, From 96ebc4e3f54edc0c13c7c49f45d2213c09011275 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 14 Jun 2021 09:21:31 -0700 Subject: [PATCH 127/208] Fix CI. --- requirements-testing.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-testing.txt b/requirements-testing.txt index 847df2a..e477638 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,2 +1,3 @@ mock==1.3.0 pytest==4.6.11 +typing;python_version>="3.4",<"3.5" # To work around CI fail. From 76ce41e326594004a6f7b4c3769992295fc2e848 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 14 Jun 2021 09:26:46 -0700 Subject: [PATCH 128/208] Again. --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index e477638..8f61b35 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,3 +1,3 @@ mock==1.3.0 pytest==4.6.11 -typing;python_version>="3.4",<"3.5" # To work around CI fail. +typing; python_version >='3.4' and python_version <'3.5' # To work around CI fail. From 78a2476767c6b6b176f097f61052ac4f78fcd6cb Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 14 Jun 2021 09:46:54 -0700 Subject: [PATCH 129/208] version bump --- CHANGES.txt | 3 +++ docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 6c3cb5b..1e6b8a8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v4.8.4 +* Disable urllib3 security warning only if not verifying server certs. (#102) + v4.8.3 * Do not verify server cert by default. (issue #97) diff --git a/docs/conf.py b/docs/conf.py index 58d5286..1fdef45 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.8.3' +version = release = '4.8.4' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index b270b14..361f20c 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -25,7 +25,7 @@ from six.moves import range import urllib3 -__version__ = '4.8.3' +__version__ = '4.8.4' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From 7fe66fd99b5e32e038ed9ed2dd2f4acde183496c Mon Sep 17 00:00:00 2001 From: Safdar Iqbal Date: Mon, 14 Jun 2021 10:26:23 -0700 Subject: [PATCH 130/208] Update README with mixpanel-utils name change (#100) Co-authored-by: Safdar Iqbal --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c042b07..5317806 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. To import, export, transform, or delete your Mixpanel data, please see our -`mixpanel_api package`_. +`mixpanel-utils package`_. Installation @@ -48,7 +48,7 @@ Additional Information .. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master :target: https://travis-ci.org/mixpanel/mixpanel-python -.. _mixpanel_api package: https://github.com/mixpanel/mixpanel_api +.. _mixpanel-utils package: https://github.com/mixpanel/mixpanel-utils .. _Help Docs: https://www.mixpanel.com/help/reference/python .. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ .. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async From f0d33b8e344c5b9a81b48ee88719fbf6a7a92887 Mon Sep 17 00:00:00 2001 From: David Grant Date: Thu, 17 Jun 2021 08:51:19 -0700 Subject: [PATCH 131/208] Drop 3.4 from test matrix. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9ec64c..48f18af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [2.7, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] steps: - uses: actions/checkout@v2 From e8a9330448f8fd4ec2cdb1ab35e0de9a05d9717f Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:04:44 -0700 Subject: [PATCH 132/208] Use requests rather than urllib3 (#103) * Replace urllib3 w/ requests. * Pass through cert verify, timeout opts. Put verify_cert back to True by default. * fixing tests. * fix tests * Fix older requests str problem * Fix more encodings * Post as formencoded with pre-JSON'd data. Test fixes. More tests * Fix tests w/ form-encoded bodies. * order * optimize nearitude * Use context mgr form of Responses. It's nicer. --- mixpanel/__init__.py | 70 ++++++------ requirements-testing.txt | 6 +- setup.py | 5 +- test_mixpanel.py | 222 ++++++++++++++++++++++++++++----------- 4 files changed, 201 insertions(+), 102 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 361f20c..437f68b 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -21,6 +21,8 @@ import time import uuid +import requests +from requests.auth import HTTPBasicAuth import six from six.moves import range import urllib3 @@ -172,7 +174,6 @@ def alias(self, alias_id, original, meta=None): Calling this method *always* results in a synchronous HTTP request to Mixpanel servers, regardless of any custom consumer. """ - sync_consumer = Consumer() event = { 'event': '$create_alias', 'properties': { @@ -183,6 +184,8 @@ def alias(self, alias_id, original, meta=None): } if meta: event.update(meta) + + sync_consumer = Consumer() sync_consumer.send('events', json_dumps(event, cls=self._serializer)) def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): @@ -540,7 +543,7 @@ class Consumer(object): def __init__(self, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=False): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { 'events': events_url or 'https://{}/track'.format(api_host), @@ -549,11 +552,8 @@ def __init__(self, events_url=None, people_url=None, import_url=None, 'imports': import_url or 'https://{}/import'.format(api_host), } - retry_args = { - "total": retry_limit, - "backoff_factor": retry_backoff_factor, - "status_forcelist": set(range(500, 600)), - } + self._verify_cert = verify_cert + self._request_timeout = request_timeout # Work around renamed argument in urllib3. if hasattr(urllib3.util.Retry.DEFAULT, "allowed_methods"): @@ -561,19 +561,19 @@ def __init__(self, events_url=None, people_url=None, import_url=None, else: methods_arg = "method_whitelist" - retry_args[methods_arg] = {"POST"} - retry_config = urllib3.Retry(**retry_args) - - if not verify_cert: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - cert_reqs = 'CERT_REQUIRED' if verify_cert else 'CERT_NONE' - self._http = urllib3.PoolManager( - retries=retry_config, - timeout=urllib3.Timeout(request_timeout), - cert_reqs=str(cert_reqs), + retry_args = { + "total": retry_limit, + "backoff_factor": retry_backoff_factor, + "status_forcelist": set(range(500, 600)), + methods_arg: {"POST"}, + } + adapter = requests.adapters.HTTPAdapter( + max_retries=urllib3.Retry(**retry_args), ) + self._session = requests.Session() + self._session.mount('http', adapter) + def send(self, endpoint, json_message, api_key=None, api_secret=None): """Immediately record an event or a profile update. @@ -594,40 +594,38 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) def _write_request(self, request_url, json_message, api_key=None, api_secret=None): - data = { - 'data': json_message, - 'verbose': 1, - 'ip': 0, - } - if isinstance(api_key, tuple): # For compatibility with subclassers, allow the auth details to be # packed into the existing api_key param. api_key, api_secret = api_key + params = { + 'data': json_message, + 'verbose': 1, + 'ip': 0, + } if api_key: - data.update({'api_key': api_key}) - - headers = None + params['api_key'] = api_key + basic_auth = None if api_secret is not None: - headers = urllib3.util.make_headers(basic_auth="{}:".format(api_secret)) + basic_auth = HTTPBasicAuth(api_secret, '') try: - response = self._http.request( - 'POST', + response = self._session.post( request_url, - fields=data, - headers=headers, - encode_multipart=False, # URL-encode payload in POST body. + data=params, + auth=basic_auth, + timeout=self._request_timeout, + verify=self._verify_cert, ) except Exception as e: six.raise_from(MixpanelException(e), e) try: - response_dict = json.loads(response.data.decode('utf-8')) + response_dict = response.json() except ValueError: - raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response.data)) + raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response.text)) if response_dict['status'] != 1: raise MixpanelException('Mixpanel error: {0}'.format(response_dict['error'])) @@ -669,7 +667,7 @@ class BufferedConsumer(object): """ def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=False): + retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): self._consumer = Consumer(events_url, people_url, import_url, request_timeout, groups_url, api_host, retry_limit, retry_backoff_factor, verify_cert) self._buffers = { diff --git a/requirements-testing.txt b/requirements-testing.txt index 8f61b35..38ed87a 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,3 +1,3 @@ -mock==1.3.0 -pytest==4.6.11 -typing; python_version >='3.4' and python_version <'3.5' # To work around CI fail. +pytest~=4.6 +responses~=0.13.3 +typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. diff --git a/setup.py b/setup.py index c89af8a..dd49912 100644 --- a/setup.py +++ b/setup.py @@ -25,8 +25,9 @@ def find_version(*paths): author_email='dev@mixpanel.com', license='Apache', install_requires=[ - 'six >= 1.9.0', - 'urllib3 >= 1.21.1', + 'six>=1.9.0', + 'requests>=2.4.2', + 'urllib3', ], classifiers=[ diff --git a/test_mixpanel.py b/test_mixpanel.py index 4e4c8bf..42ed47a 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,16 +1,14 @@ from __future__ import absolute_import, unicode_literals -import base64 -import contextlib import datetime import decimal import json import time -from mock import Mock, patch import pytest +import responses import six -from six.moves import range -import urllib3 +from six.moves import range, urllib + import mixpanel @@ -288,18 +286,23 @@ def test_people_set_created_date_datetime(self): def test_alias(self): # More complicated since alias() forces a synchronous call. - mock_response = Mock() - mock_response.data = six.b('{"status": 1, "error": null}') - with patch('mixpanel.urllib3.PoolManager.request', return_value=mock_response) as req: + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 1, "error": None}, + status=200, + ) + self.mp.alias('ALIAS', 'ORIGINAL ID') - assert self.consumer.log == [] - assert req.call_count == 1 - ((method, url), kwargs) = req.call_args - assert method == 'POST' - assert url == 'https://api.mixpanel.com/track' - expected_data = {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} - assert json.loads(kwargs["fields"]["data"]) == expected_data + assert self.consumer.log == [] + call = rsps.calls[0] + assert call.request.method == "POST" + assert call.request.url == "https://api.mixpanel.com/track" + posted_data = dict(urllib.parse.parse_qsl(six.ensure_str(call.request.body))) + assert json.loads(posted_data["data"]) == {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} def test_merge(self): self.mp.merge('my_good_api_key', 'd1', 'd2') @@ -449,36 +452,113 @@ class TestConsumer: def setup_class(cls): cls.consumer = mixpanel.Consumer(request_timeout=30) - @contextlib.contextmanager - def _assertSends(self, expect_url, expect_data, consumer=None): - if consumer is None: - consumer = self.consumer - - mock_response = Mock() - mock_response.data = six.b('{"status": 1, "error": null}') - with patch('mixpanel.urllib3.PoolManager.request', return_value=mock_response) as req: - yield - - assert req.call_count == 1 - (call_args, kwargs) = req.call_args - (method, url) = call_args - assert method == 'POST' - assert url == expect_url - assert kwargs["fields"] == expect_data - def test_send_events(self): - with self._assertSends('https://api.mixpanel.com/track', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 1, "error": None}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) self.consumer.send('events', '{"foo":"bar"}') def test_send_people(self): - with self._assertSends('https://api.mixpanel.com/engage', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/engage', + json={"status": 1, "error": None}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) self.consumer.send('people', '{"foo":"bar"}') + def test_server_success(self): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 1, "error": None}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) + self.consumer.send('events', '{"foo":"bar"}') + + def test_server_invalid_data(self): + with responses.RequestsMock() as rsps: + error_msg = "bad data" + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 0, "error": error_msg}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'})], + ) + + with pytest.raises(mixpanel.MixpanelException) as exc: + self.consumer.send('events', '{INVALID "foo":"bar"}') + assert error_msg in str(exc) + + def test_server_unauthorized(self): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 0, "error": "unauthed"}, + status=401, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) + with pytest.raises(mixpanel.MixpanelException) as exc: + self.consumer.send('events', '{"foo":"bar"}') + assert "unauthed" in str(exc) + + def test_server_forbidden(self): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 0, "error": "forbade"}, + status=403, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) + with pytest.raises(mixpanel.MixpanelException) as exc: + self.consumer.send('events', '{"foo":"bar"}') + assert "forbade" in str(exc) + + def test_server_5xx(self): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + body="Internal server error", + status=500, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) + with pytest.raises(mixpanel.MixpanelException) as exc: + self.consumer.send('events', '{"foo":"bar"}') + def test_consumer_override_api_host(self): - consumer = mixpanel.Consumer(api_host="api-eu.mixpanel.com") - with self._assertSends('https://api-eu.mixpanel.com/track', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}, consumer=consumer): + consumer = mixpanel.Consumer(api_host="api-zoltan.mixpanel.com") + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api-zoltan.mixpanel.com/track', + json={"status": 1, "error": None}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) consumer.send('events', '{"foo":"bar"}') - with self._assertSends('https://api-eu.mixpanel.com/engage', {"ip": 0, "verbose": 1, "data": '{"foo":"bar"}'}, consumer=consumer): + + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api-zoltan.mixpanel.com/engage', + json={"status": 1, "error": None}, + status=200, + match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + ) consumer.send('people', '{"foo":"bar"}') def test_unknown_endpoint(self): @@ -521,12 +601,18 @@ def test_unknown_endpoint_raises_on_send(self): self.consumer.send('unknown', '1') def test_useful_reraise_in_flush_endpoint(self): - error_mock = Mock() - error_mock.data = six.b('{"status": 0, "error": "arbitrary error"}') - broken_json = '{broken JSON' - consumer = mixpanel.BufferedConsumer(2) - with patch('mixpanel.urllib3.PoolManager.request', return_value=error_mock): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 0, "error": "arbitrary error"}, + status=200, + ) + + broken_json = '{broken JSON' + consumer = mixpanel.BufferedConsumer(2) consumer.send('events', broken_json) + with pytest.raises(mixpanel.MixpanelException) as excinfo: consumer.flush() assert excinfo.value.message == '[%s]' % broken_json @@ -554,27 +640,41 @@ def setup_class(cls): cls.mp = mixpanel.Mixpanel(cls.TOKEN) cls.mp._now = lambda: 1000 - @contextlib.contextmanager - def _assertRequested(self, expect_url, expect_data): - res = Mock() - res.data = six.b('{"status": 1, "error": null}') - with patch('mixpanel.urllib3.PoolManager.request', return_value=res) as req: - yield - - assert req.call_count == 1 - ((method, url,), data) = req.call_args - data = data["fields"]["data"] - assert method == 'POST' - assert url == expect_url - payload = json.loads(data) - assert payload == expect_data - def test_track_functional(self): - expect_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'}} - with self._assertRequested('https://api.mixpanel.com/track', expect_data): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/track', + json={"status": 1, "error": None}, + status=200, + ) + self.mp.track('player1', 'button_press', {'size': 'big', 'color': 'blue', '$insert_id': 'xyz1200'}) + body = six.ensure_str(rsps.calls[0].request.body) + wrapper = dict(urllib.parse.parse_qsl(body)) + data = json.loads(wrapper["data"]) + del wrapper["data"] + + assert {"ip": "0", "verbose": "1"} == wrapper + 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'}} + assert expected_data == data + def test_people_set_functional(self): - expect_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} - with self._assertRequested('https://api.mixpanel.com/engage', expect_data): + with responses.RequestsMock() as rsps: + rsps.add( + responses.POST, + 'https://api.mixpanel.com/engage', + json={"status": 1, "error": None}, + status=200, + ) + self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) + body = six.ensure_str(rsps.calls[0].request.body) + wrapper = dict(urllib.parse.parse_qsl(body)) + data = json.loads(wrapper["data"]) + del wrapper["data"] + + assert {"ip": "0", "verbose": "1"} == wrapper + expected_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} + assert expected_data == data From 1364bfb6bba3421523d923754280c61603dc68db Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:14:16 -0700 Subject: [PATCH 133/208] Add python_requires arg. --- setup.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index dd49912..b059423 100644 --- a/setup.py +++ b/setup.py @@ -24,19 +24,24 @@ def find_version(*paths): author='Mixpanel, Inc.', author_email='dev@mixpanel.com', license='Apache', + python_requires='>=2.7, !=3.4.*', install_requires=[ 'six>=1.9.0', 'requests>=2.4.2', 'urllib3', ], - classifiers=[ 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', ], - keywords='mixpanel analytics', packages=find_packages(), ) From 943a80716b787da01c3994fec08c9c735094888b Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:34:02 -0700 Subject: [PATCH 134/208] 4.9.0 release changes --- CHANGES.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 1e6b8a8..1ffc558 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,9 @@ +v4.9.0 +* To reduce TLS cert friction, use requests rather than directly using urllib3. + Reinstate TLS cert validation by default. (#103) +* Drop support for Python 3.4 in setup.py and testing matrix. +* Update readme references to mixpanel-utils project. (#100) + v4.8.4 * Disable urllib3 security warning only if not verifying server certs. (#102) From 11104c4aad8744cbec272d5fae760198b8753e06 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:35:13 -0700 Subject: [PATCH 135/208] Changes in RST. --- CHANGES.txt => CHANGES.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CHANGES.txt => CHANGES.rst (100%) diff --git a/CHANGES.txt b/CHANGES.rst similarity index 100% rename from CHANGES.txt rename to CHANGES.rst From 10f9c3afa0ac9aeb97e4badf2523bb576614f73a Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:35:44 -0700 Subject: [PATCH 136/208] version bump --- docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1fdef45..c716be9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.8.4' +version = release = '4.9.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 437f68b..8498064 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -27,7 +27,7 @@ from six.moves import range import urllib3 -__version__ = '4.8.4' +__version__ = '4.9.0' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From a38a1db109ea653d8c5118629ef2e35b43ce0362 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:38:40 -0700 Subject: [PATCH 137/208] test --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1ffc558..0ca365b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ v4.9.0 * Update readme references to mixpanel-utils project. (#100) v4.8.4 -* Disable urllib3 security warning only if not verifying server certs. (#102) +* Disable urllib3 security warning only if not verifying server certs. #102 v4.8.3 * Do not verify server cert by default. (issue #97) From 3607c1f079018f8bae4bf4b80876b905c336a0ec Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:39:06 -0700 Subject: [PATCH 138/208] Revert "test" This reverts commit a38a1db109ea653d8c5118629ef2e35b43ce0362. --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0ca365b..1ffc558 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ v4.9.0 * Update readme references to mixpanel-utils project. (#100) v4.8.4 -* Disable urllib3 security warning only if not verifying server certs. #102 +* Disable urllib3 security warning only if not verifying server certs. (#102) v4.8.3 * Do not verify server cert by default. (issue #97) From a8e709f9451cd97887d489f6cc7ae8f01ea28c57 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:39:12 -0700 Subject: [PATCH 139/208] Revert "Changes in RST." This reverts commit 11104c4aad8744cbec272d5fae760198b8753e06. --- CHANGES.rst => CHANGES.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CHANGES.rst => CHANGES.txt (100%) diff --git a/CHANGES.rst b/CHANGES.txt similarity index 100% rename from CHANGES.rst rename to CHANGES.txt From fe077764e2a7e12f55ebea3b1edb5ca6a14d54b4 Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:49:03 -0700 Subject: [PATCH 140/208] More shields. Gotta have 'em. --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index 5317806..a0d20aa 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,18 @@ mixpanel-python .. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg +.. |PyPI| image:: https://img.shields.io/pypi/v/mixpanel + :target: https://pypi.org/project/mixpanel + :alt: PyPI + +.. |Python| image:: https://img.shields.io/pypi/pyversions/mixpanel + :target: https://pypi.org/project/mixpanel + :alt: PyPI - Python Version + +.. |Downloads| image:: https://img.shields.io/pypi/dm/mixpanel + :target: https://pypi.org/project/mixpanel + :alt: PyPI - Downloads + This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. From ac868325590f543b8f24ee9db1f4f3d72610ef5f Mon Sep 17 00:00:00 2001 From: David Grant Date: Mon, 21 Jun 2021 16:50:46 -0700 Subject: [PATCH 141/208] Fix shields. --- README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index a0d20aa..565ffab 100644 --- a/README.rst +++ b/README.rst @@ -1,20 +1,20 @@ mixpanel-python ============================== -.. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg - -.. |PyPI| image:: https://img.shields.io/pypi/v/mixpanel +.. image:: https://img.shields.io/pypi/v/mixpanel :target: https://pypi.org/project/mixpanel :alt: PyPI -.. |Python| image:: https://img.shields.io/pypi/pyversions/mixpanel +.. image:: https://img.shields.io/pypi/pyversions/mixpanel :target: https://pypi.org/project/mixpanel :alt: PyPI - Python Version -.. |Downloads| image:: https://img.shields.io/pypi/dm/mixpanel +.. image:: https://img.shields.io/pypi/dm/mixpanel :target: https://pypi.org/project/mixpanel :alt: PyPI - Downloads +.. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg + This is the official Mixpanel Python library. This library allows for server-side integration of Mixpanel. From 8c0841cae676bb17529bfe7ff1f0ac256c72cecf Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 19 Nov 2021 14:48:05 -0800 Subject: [PATCH 142/208] Add 3.10 support in PyPi metadata, testing matrix --- .github/workflows/test.yml | 2 +- setup.py | 1 + tox.ini | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48f18af..fd39a4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, pypy2, pypy3] steps: - uses: actions/checkout@v2 diff --git a/setup.py b/setup.py index b059423..cbb634e 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ def find_version(*paths): 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], keywords='mixpanel analytics', packages=find_packages(), diff --git a/tox.ini b/tox.ini index 9068f32..258984a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37, py38, py39 +envlist = py27, py34, py35, py36, py37, py38, py39, py310 [testenv] deps = -rrequirements-testing.txt From 0b90619bd2179502331c9e93beb33e5d94efe355 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 19 Nov 2021 14:51:50 -0800 Subject: [PATCH 143/208] fix GH actions parsing 3.10 as a number. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd39a4e..edcc649 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, pypy2, pypy3] + python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy2', 'pypy3'] steps: - uses: actions/checkout@v2 From d82da981bcef6239893018a5a2b160fdcc48fb48 Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 19 Nov 2021 15:00:33 -0800 Subject: [PATCH 144/208] Hack around more-itertools incompat --- requirements-testing.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-testing.txt b/requirements-testing.txt index 38ed87a..52d9dd4 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,3 +1,4 @@ pytest~=4.6 responses~=0.13.3 +more-itertools==8.10.0 ; python_version<='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 22717dd60a48bf69c46589f1c9500b29ebd28f1a Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 19 Nov 2021 15:02:24 -0800 Subject: [PATCH 145/208] again --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 52d9dd4..e831175 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ pytest~=4.6 responses~=0.13.3 -more-itertools==8.10.0 ; python_version<='3.5' # more-itertools added some f-strings after this. +more-itertools==8.10.0 ; python_version='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 241b838dc728a584cbe4c7413aba4aacf308a7dd Mon Sep 17 00:00:00 2001 From: David Grant Date: Fri, 19 Nov 2021 15:03:28 -0800 Subject: [PATCH 146/208] again --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index e831175..f4f1e2b 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ pytest~=4.6 responses~=0.13.3 -more-itertools==8.10.0 ; python_version='3.5' # more-itertools added some f-strings after this. +more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 0ad3aaefd76ca9a0299aec0347af8a189a2591f7 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 14:28:06 -0700 Subject: [PATCH 147/208] send millisecond time --- mixpanel/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 8498064..0b03cf0 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -89,7 +89,7 @@ def track(self, distinct_id, event_name, properties=None, meta=None): all_properties = { 'token': self._token, 'distinct_id': distinct_id, - 'time': int(self._now()), + 'time': self._now(), '$insert_id': self._make_insert_id(), 'mp_lib': 'python', '$lib_version': __version__, @@ -384,7 +384,7 @@ def people_update(self, message, meta=None): """ record = { '$token': self._token, - '$time': int(self._now()), + '$time': self._now(), } record.update(message) if meta: @@ -500,7 +500,7 @@ def group_update(self, message, meta=None): """ record = { '$token': self._token, - '$time': int(self._now()), + '$time': self._now(), } record.update(message) if meta: From 8c1d4200001d2818dbfc96d6971071b9aaab692b Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 14:32:26 -0700 Subject: [PATCH 148/208] fix tests --- test_mixpanel.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/test_mixpanel.py b/test_mixpanel.py index 42ed47a..a6793eb 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -49,7 +49,7 @@ def test_track(self): 'size': 'big', 'color': 'blue', 'distinct_id': 'ID', - 'time': int(self.mp._now()), + 'time': self.mp._now(), '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, @@ -72,7 +72,7 @@ def test_track_empty(self): 'properties': { 'token': self.TOKEN, 'distinct_id': 'person_xyz', - 'time': int(self.mp._now()), + 'time': self.mp._now(), '$insert_id': self.mp._make_insert_id(), 'mp_lib': 'python', '$lib_version': mixpanel.__version__, @@ -93,7 +93,7 @@ def test_import_data(self): 'size': 'big', 'color': 'blue', 'distinct_id': 'ID', - 'time': int(timestamp), + 'time': timestamp, '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, @@ -113,7 +113,7 @@ def test_track_meta(self): 'size': 'big', 'color': 'blue', 'distinct_id': 'ID', - 'time': int(self.mp._now()), + 'time': self.mp._now(), '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, @@ -126,7 +126,7 @@ def test_people_set(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -140,7 +140,7 @@ def test_people_set_once(self): self.mp.people_set_once('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$set_once': { @@ -154,7 +154,7 @@ def test_people_increment(self): self.mp.people_increment('amq', {'Albums Released': 1}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$add': { @@ -167,7 +167,7 @@ def test_people_append(self): self.mp.people_append('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -181,7 +181,7 @@ def test_people_union(self): self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$union': { @@ -194,7 +194,7 @@ def test_people_unset(self): self.mp.people_unset('amq', ['Albums', 'Singles']) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['Albums', 'Singles'], @@ -205,7 +205,7 @@ def test_people_remove(self): self.mp.people_remove('amq', {'Albums': 'Diamond Dogs'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$remove': {'Albums': 'Diamond Dogs'}, @@ -216,7 +216,7 @@ def test_people_track_charge(self): self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -232,7 +232,7 @@ def test_people_track_charge_without_properties(self): self.mp.people_track_charge('amq', 12.65) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$append': { @@ -247,7 +247,7 @@ def test_people_clear_charges(self): self.mp.people_clear_charges('amq') assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$unset': ['$transactions'], @@ -259,7 +259,7 @@ def test_people_set_created_date_string(self): self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -274,7 +274,7 @@ def test_people_set_created_date_datetime(self): self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -338,7 +338,7 @@ def test_people_meta(self): meta={'$ip': 0, '$ignore_time': True}) assert self.consumer.log == [( 'people', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$distinct_id': 'amq', '$set': { @@ -354,7 +354,7 @@ def test_group_set(self): self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -369,7 +369,7 @@ def test_group_set_once(self): self.mp.group_set_once('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -384,7 +384,7 @@ def test_group_union(self): self.mp.group_union('company', 'amq', {'Albums': ['Diamond Dogs']}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -398,7 +398,7 @@ def test_group_unset(self): self.mp.group_unset('company', 'amq', ['Albums', 'Singles']) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -410,7 +410,7 @@ def test_group_remove(self): self.mp.group_remove('company', 'amq', {'Albums': 'Diamond Dogs'}) assert self.consumer.log == [( 'groups', { - '$time': int(self.mp._now()), + '$time': self.mp._now(), '$token': self.TOKEN, '$group_key': 'company', '$group_id': 'amq', @@ -438,7 +438,7 @@ def default(self, obj): 'token': self.TOKEN, 'size': decimal_string, 'distinct_id': 'ID', - 'time': int(self.mp._now()), + 'time': self.mp._now(), '$insert_id': 'abc123', 'mp_lib': 'python', '$lib_version': mixpanel.__version__, From 34a685aa349fe4a4b58f16c9072b4c76dc2de731 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 14:38:52 -0700 Subject: [PATCH 149/208] fix import_data --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 0b03cf0..f8b3e84 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -140,7 +140,7 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, all_properties = { 'token': self._token, 'distinct_id': distinct_id, - 'time': int(timestamp), + 'time': timestamp, '$insert_id': self._make_insert_id(), 'mp_lib': 'python', '$lib_version': __version__, From e4b2c883ad29b1c080d57fa9ec4dae1ebb27de16 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 14:48:00 -0700 Subject: [PATCH 150/208] bump pytest version --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index f4f1e2b..865051a 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=4.6 +pytest~=4.6.11 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From a45ffdc4bb809584c32be20ae29dfe90281bbc63 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 14:52:13 -0700 Subject: [PATCH 151/208] pin pytest to <4.6 --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 865051a..a8d3000 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=4.6.11 +pytest<4.6 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 62db50988dc38f0e2b9b413eeb30a88148573a81 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:01:02 -0700 Subject: [PATCH 152/208] bump to latest pytest --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index a8d3000..cb31de1 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest<4.6 +pytest~=7.1.2 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From a0566c019595f597e8112b411a1a8cf0b6c5d7a9 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:03:26 -0700 Subject: [PATCH 153/208] try pytest~=6.2.5 --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index cb31de1..300e8c5 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=7.1.2 +pytest~=6.2.5 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From dc6427d9cff11878c3979060131977bc48135d24 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:06:37 -0700 Subject: [PATCH 154/208] try pytest~=5.4.3 --- requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 300e8c5..ed3cdac 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=6.2.5 +pytest~=5.4.3 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 432312d94415e398864969151e10c562641fe61f Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:10:08 -0700 Subject: [PATCH 155/208] =?UTF-8?q?=C2=AF\=5F(=E3=83=84)=5F/=C2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements-testing.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index ed3cdac..760fb35 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=5.4.3 +pytest~=7.1.2 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. -typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. +typing; python_version>='3.7' and python_version<'3.10' # To work around CI fail. From b7432cb822f56f88ac36b1e61e8cb7a6e672f6cd Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:24:24 -0700 Subject: [PATCH 156/208] break up tests --- requirements-testing.txt | 4 ++-- test_mixpanel.py | 46 +++++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 760fb35..865051a 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,4 @@ -pytest~=7.1.2 +pytest~=4.6.11 responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. -typing; python_version>='3.7' and python_version<'3.10' # To work around CI fail. +typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. diff --git a/test_mixpanel.py b/test_mixpanel.py index a6793eb..0275eba 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -30,7 +30,7 @@ def clear(self): self.log = [] -class TestMixpanel: +class TestMixpanelBase: TOKEN = '12345' def setup_method(self, method): @@ -39,6 +39,9 @@ def setup_method(self, method): self.mp._now = lambda: 1000.1 self.mp._make_insert_id = lambda: "abcdefg" + +class TestMixpanelTracking(TestMixpanelBase): + def test_track(self): self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) assert self.consumer.log == [( @@ -122,6 +125,9 @@ def test_track_meta(self): } )] + +class TestMixpanelPeople(TestMixpanelBase): + def test_people_set(self): self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) assert self.consumer.log == [( @@ -284,6 +290,26 @@ def test_people_set_created_date_datetime(self): } )] + def test_people_meta(self): + self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, + meta={'$ip': 0, '$ignore_time': True}) + assert self.consumer.log == [( + 'people', { + '$time': self.mp._now(), + '$token': self.TOKEN, + '$distinct_id': 'amq', + '$set': { + 'birth month': 'october', + 'favorite color': 'purple', + }, + '$ip': 0, + '$ignore_time': True, + } + )] + + +class TestMixpanelIdentity(TestMixpanelBase): + def test_alias(self): # More complicated since alias() forces a synchronous call. @@ -333,22 +359,8 @@ def test_merge(self): ('my_good_api_key', 'my_secret'), )] - def test_people_meta(self): - self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, - meta={'$ip': 0, '$ignore_time': True}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set': { - 'birth month': 'october', - 'favorite color': 'purple', - }, - '$ip': 0, - '$ignore_time': True, - } - )] + +class TestMixpanelGroups(TestMixpanelBase): def test_group_set(self): self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) From 9a0767cc09f590203bb55afea992853a291bfe80 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 25 Jul 2022 15:38:52 -0700 Subject: [PATCH 157/208] use different versions of pytest for different versions of python --- requirements-testing.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 865051a..56bf7e2 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,4 +1,6 @@ -pytest~=4.6.11 +pytest~=4.6.11 ; python_version<='3.4' +pytest~=5.4.3 ; python_version>='3.5' and python_version<'3.7' +pytest~=7.1.2 ; python_version>='3.7' responses~=0.13.3 more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. From 3dcd2d28aa29318f12a932563cc3e5c9711c6efa Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 2 Aug 2022 13:29:24 -0700 Subject: [PATCH 158/208] Bump version --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index f8b3e84..d93a1cb 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -27,7 +27,7 @@ from six.moves import range import urllib3 -__version__ = '4.9.0' +__version__ = '4.10.0' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From 7297eb40afb25f9b285c8c09f66d1b8e1d99fd55 Mon Sep 17 00:00:00 2001 From: dror-fs <122200684+dror-fs@users.noreply.github.com> Date: Fri, 10 Nov 2023 18:07:15 +0200 Subject: [PATCH 159/208] Fix ineffective mount operation HTTPAdatper mounted onto requests.Session using the 'http' prefix which is always superseded by already existing 'https://'. Effectively, the current code will not retry the mixpanel POST request and will cause a connection reset error. --- mixpanel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index d93a1cb..5d3d5ee 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -572,7 +572,7 @@ def __init__(self, events_url=None, people_url=None, import_url=None, ) self._session = requests.Session() - self._session.mount('http', adapter) + self._session.mount('https://', adapter) def send(self, endpoint, json_message, api_key=None, api_secret=None): """Immediately record an event or a profile update. From f7eb9ffba06ebd790281cf6cffbb9802793b7029 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 13:51:29 -0800 Subject: [PATCH 160/208] Add 3.11, 3.12 support in PyPi metadata, testing matrix --- .github/workflows/test.yml | 4 ++-- setup.py | 2 ++ tox.ini | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edcc649..f5f90c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy2', 'pypy3'] + python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] steps: - uses: actions/checkout@v2 @@ -22,4 +22,4 @@ jobs: pip install -r requirements-testing.txt - name: Test with pytest run: | - pytest test_mixpanel.py \ No newline at end of file + pytest test_mixpanel.py diff --git a/setup.py b/setup.py index cbb634e..9c163dc 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,8 @@ def find_version(*paths): 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12' ], keywords='mixpanel analytics', packages=find_packages(), diff --git a/tox.ini b/tox.ini index 258984a..d2c8379 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, py35, py36, py37, py38, py39, py310 +envlist = py27, py34, py35, py36, py37, py38, py39, py310, py311, py312 [testenv] deps = -rrequirements-testing.txt From 3395f64e7450e9b2b6d1bc12736c4ffe06615910 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 14:01:06 -0800 Subject: [PATCH 161/208] use ubuntu-latest --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5f90c2..ce5c0c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: matrix: python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] From da220b695042daef0e3492292883e98d09334d58 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 14:05:15 -0800 Subject: [PATCH 162/208] use ubuntu-20.04 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce5c0c2..926ae76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] From ea6d29ca8fdfa196bacb7a2c4834c84a04c112a4 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 14:11:25 -0800 Subject: [PATCH 163/208] remove python 2.7 from test matrix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 926ae76..ed123bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] + python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] steps: - uses: actions/checkout@v2 From a4f18b59fbac9bad644c0c8331f97a0f125d76eb Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 14:15:44 -0800 Subject: [PATCH 164/208] add trailing comma --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9c163dc..c81c050 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ def find_version(*paths): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12' + 'Programming Language :: Python :: 3.12', ], keywords='mixpanel analytics', packages=find_packages(), From cd2616988e0f6646d70d875f1d70090eb09ed48b Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 8 Mar 2024 14:36:24 -0800 Subject: [PATCH 165/208] bump version --- docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c716be9..a53f1e1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.9.0' +version = release = '4.10.1' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 5d3d5ee..be47ffc 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -27,7 +27,7 @@ from six.moves import range import urllib3 -__version__ = '4.10.0' +__version__ = '4.10.1' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) From 19a1bbee5e44382ad076ae8de4282af773882dbc Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:10:07 -0700 Subject: [PATCH 166/208] Deprecate EOL python versions and migrate to pyproject.toml (#135) * Deprecate EOL python versions and migrate to pyproject.toml * temp file removal * bump dependent actions versions * fix pypy version * Add python 3.13 and pypy311 - no breaking changes' * undo demo app change * test file update * Adding read-only permission to github action * remove test pkg exclusion * mixpanel should be the only top level pkg * Fix changelist version * cleanup-removing commented code line --- .github/workflows/test.yml | 14 +++++---- BUILD.rst | 16 +++++----- CHANGES.txt | 4 +++ LICENSE.txt | 2 +- demo/subprocess_consumer.py | 6 ++-- mixpanel/__init__.py | 9 ++---- pyproject.toml | 58 +++++++++++++++++++++++++++++++++++++ requirements-testing.txt | 6 ---- setup.cfg | 2 -- setup.py | 50 -------------------------------- test_mixpanel.py | 39 ++++++++++++------------- tox.ini | 6 ---- 12 files changed, 104 insertions(+), 108 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements-testing.txt delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed123bb..7d58d82 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,24 +2,26 @@ name: Tests on: [push] +permissions: + contents: read + jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy2', 'pypy3'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.11'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e . - pip install -r requirements-testing.txt + pip install -e .[test] - name: Test with pytest run: | pytest test_mixpanel.py diff --git a/BUILD.rst b/BUILD.rst index 4a0e8c6..b865d1d 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -8,22 +8,22 @@ Release process:: 6. Rebuild docs and publish to GitHub Pages (if appropriate -- see below) 7. Publish to PyPI. (see below) +Install test and developer environment modules:: + pip install -e .[test,dev] + Run tests:: - tox + python -m tox - runs all tests against all configured environments in the pyproject.toml Publish to PyPI:: - pip install twine wheel - python setup.py sdist bdist_wheel - twine upload dist/* + python -m build + python -m twine upload dist/* Build docs:: - pip install sphinx - python setup.py build_sphinx + python -m sphinx -b html docs docs/_build/html Publish docs to GitHub Pages:: - pip install ghp-import - ghp-import -n -p build/sphinx/html + python -m ghp_import -n -p docs/_build/html diff --git a/CHANGES.txt b/CHANGES.txt index 1ffc558..bdf842b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +v4.11.0 +* Set minimum supported python version to 3.9, deprecating support for end-of-life versions of python +* Convert setup.py to pyproject.toml + v4.9.0 * To reduce TLS cert friction, use requests rather than directly using urllib3. Reinstate TLS cert validation by default. (#103) diff --git a/LICENSE.txt b/LICENSE.txt index e0d9fde..7d6912f 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ - Copyright 2013-2021 Mixpanel, Inc. + Copyright 2013-2025 Mixpanel, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/demo/subprocess_consumer.py b/demo/subprocess_consumer.py index 4e2fb65..5b2a015 100644 --- a/demo/subprocess_consumer.py +++ b/demo/subprocess_consumer.py @@ -38,10 +38,10 @@ def do_tracking(project_token, distinct_id, queue): ''' consumer = QueueWriteConsumer(queue) mp = Mixpanel(project_token, consumer) - for i in xrange(100): + for i in range(100): event = 'Tick' mp.track(distinct_id, event, {'Tick Number': i}) - print 'tick {0}'.format(i) + print(f'tick {i}') queue.put(None) # tell worker we're out of jobs @@ -64,7 +64,7 @@ def do_sending(queue): if __name__ == '__main__': # replace token with your real project token token = '0ba349286c780fe53d8b4617d90e2d01' - distinct_id = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for x in xrange(32)) + distinct_id = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for x in range(32)) queue = multiprocessing.Queue() sender = multiprocessing.Process(target=do_sending, args=(queue,)) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index be47ffc..046d068 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -14,7 +14,6 @@ Analytics updates. :class:`~.Consumer` and :class:`~.BufferedConsumer` allow callers to customize the IO characteristics of their tracking. """ -from __future__ import absolute_import, unicode_literals import datetime import json import logging @@ -23,11 +22,9 @@ import requests from requests.auth import HTTPBasicAuth -import six -from six.moves import range import urllib3 -__version__ = '4.10.1' +__version__ = '4.11.0' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) @@ -620,7 +617,7 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non verify=self._verify_cert, ) except Exception as e: - six.raise_from(MixpanelException(e), e) + raise MixpanelException(e) from e try: response_dict = response.json() @@ -733,6 +730,6 @@ def _flush_endpoint(self, endpoint): mp_e = MixpanelException(orig_e) mp_e.message = batch_json mp_e.endpoint = endpoint - six.raise_from(mp_e, orig_e) + raise mp_e from orig_e buf = buf[self._max_size:] self._buffers[endpoint] = buf diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..966a674 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mixpanel" +dynamic = ["version"] +description = "Official Mixpanel library for Python" +readme = "README.rst" +license = "Apache-2.0" +authors = [ + {name = "Mixpanel, Inc.", email = "dev@mixpanel.com"}, +] +requires-python = ">=3.9" +dependencies = [ + "requests>=2.32.5", +] +keywords = ["mixpanel", "analytics"] +classifiers = [ + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "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", +] + +[project.urls] +Homepage = "https://github.com/mixpanel/mixpanel-python" + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "responses>=0.25.8", +] +dev = [ + "tox>=4.28.4", + "build", + "twine", + "sphinx", + "ghp-import", +] + +[tool.setuptools.dynamic] +version = {attr = "mixpanel.__version__"} + +[tool.setuptools.packages.find] +exclude = ["demo"] + +[tool.tox] +envlist = ["py39", "py310", "py311", "py312"] + +[tool.tox.env_run_base] +extras = ["test"] +commands = [ + ["pytest", "{posargs}"], +] \ No newline at end of file diff --git a/requirements-testing.txt b/requirements-testing.txt deleted file mode 100644 index 56bf7e2..0000000 --- a/requirements-testing.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest~=4.6.11 ; python_version<='3.4' -pytest~=5.4.3 ; python_version>='3.5' and python_version<'3.7' -pytest~=7.1.2 ; python_version>='3.7' -responses~=0.13.3 -more-itertools==8.10.0 ; python_version=='3.5' # more-itertools added some f-strings after this. -typing; python_version>='3.4' and python_version<'3.5' # To work around CI fail. diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5e40900..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index c81c050..0000000 --- a/setup.py +++ /dev/null @@ -1,50 +0,0 @@ -from codecs import open -from os import path -import re -from setuptools import setup, find_packages - -def read(*paths): - filename = path.join(path.abspath(path.dirname(__file__)), *paths) - with open(filename, encoding='utf-8') as f: - return f.read() - -def find_version(*paths): - contents = read(*paths) - match = re.search(r'^__version__ = [\'"]([^\'"]+)[\'"]', contents, re.M) - if not match: - raise RuntimeError('Unable to find version string.') - return match.group(1) - -setup( - name='mixpanel', - version=find_version('mixpanel', '__init__.py'), - description='Official Mixpanel library for Python', - long_description=read('README.rst'), - url='https://github.com/mixpanel/mixpanel-python', - author='Mixpanel, Inc.', - author_email='dev@mixpanel.com', - license='Apache', - python_requires='>=2.7, !=3.4.*', - install_requires=[ - 'six>=1.9.0', - 'requests>=2.4.2', - 'urllib3', - ], - classifiers=[ - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ], - keywords='mixpanel analytics', - packages=find_packages(), -) diff --git a/test_mixpanel.py b/test_mixpanel.py index 0275eba..7018efb 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,19 +1,17 @@ -from __future__ import absolute_import, unicode_literals import datetime import decimal import json import time +from urllib import parse as urllib_parse import pytest import responses -import six -from six.moves import range, urllib - +from responses.matchers import urlencoded_params_matcher import mixpanel -class LogConsumer(object): +class LogConsumer: def __init__(self): self.log = [] @@ -64,7 +62,7 @@ def test_track_makes_insert_id(self): self.mp.track('ID', 'button press', {'size': 'big'}) props = self.consumer.log[0][1]["properties"] assert "$insert_id" in props - assert isinstance(props["$insert_id"], six.text_type) + assert isinstance(props["$insert_id"], str) assert len(props["$insert_id"]) > 0 def test_track_empty(self): @@ -327,7 +325,8 @@ def test_alias(self): call = rsps.calls[0] assert call.request.method == "POST" assert call.request.url == "https://api.mixpanel.com/track" - posted_data = dict(urllib.parse.parse_qsl(six.ensure_str(call.request.body))) + body = call.request.body if isinstance(call.request.body, str) else call.request.body.decode('utf-8') + posted_data = dict(urllib_parse.parse_qsl(body)) assert json.loads(posted_data["data"]) == {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} def test_merge(self): @@ -471,7 +470,7 @@ def test_send_events(self): 'https://api.mixpanel.com/track', json={"status": 1, "error": None}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) self.consumer.send('events', '{"foo":"bar"}') @@ -482,7 +481,7 @@ def test_send_people(self): 'https://api.mixpanel.com/engage', json={"status": 1, "error": None}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) self.consumer.send('people', '{"foo":"bar"}') @@ -493,7 +492,7 @@ def test_server_success(self): 'https://api.mixpanel.com/track', json={"status": 1, "error": None}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) self.consumer.send('events', '{"foo":"bar"}') @@ -505,7 +504,7 @@ def test_server_invalid_data(self): 'https://api.mixpanel.com/track', json={"status": 0, "error": error_msg}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'})], ) with pytest.raises(mixpanel.MixpanelException) as exc: @@ -519,7 +518,7 @@ def test_server_unauthorized(self): 'https://api.mixpanel.com/track', json={"status": 0, "error": "unauthed"}, status=401, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) with pytest.raises(mixpanel.MixpanelException) as exc: self.consumer.send('events', '{"foo":"bar"}') @@ -532,7 +531,7 @@ def test_server_forbidden(self): 'https://api.mixpanel.com/track', json={"status": 0, "error": "forbade"}, status=403, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) with pytest.raises(mixpanel.MixpanelException) as exc: self.consumer.send('events', '{"foo":"bar"}') @@ -545,7 +544,7 @@ def test_server_5xx(self): 'https://api.mixpanel.com/track', body="Internal server error", status=500, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) with pytest.raises(mixpanel.MixpanelException) as exc: self.consumer.send('events', '{"foo":"bar"}') @@ -559,7 +558,7 @@ def test_consumer_override_api_host(self): 'https://api-zoltan.mixpanel.com/track', json={"status": 1, "error": None}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) consumer.send('events', '{"foo":"bar"}') @@ -569,7 +568,7 @@ def test_consumer_override_api_host(self): 'https://api-zoltan.mixpanel.com/engage', json={"status": 1, "error": None}, status=200, - match=[responses.urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], ) consumer.send('people', '{"foo":"bar"}') @@ -663,8 +662,8 @@ def test_track_functional(self): self.mp.track('player1', 'button_press', {'size': 'big', 'color': 'blue', '$insert_id': 'xyz1200'}) - body = six.ensure_str(rsps.calls[0].request.body) - wrapper = dict(urllib.parse.parse_qsl(body)) + body = rsps.calls[0].request.body + wrapper = dict(urllib_parse.parse_qsl(body)) data = json.loads(wrapper["data"]) del wrapper["data"] @@ -682,8 +681,8 @@ def test_people_set_functional(self): ) self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) - body = six.ensure_str(rsps.calls[0].request.body) - wrapper = dict(urllib.parse.parse_qsl(body)) + body = rsps.calls[0].request.body + wrapper = dict(urllib_parse.parse_qsl(body)) data = json.loads(wrapper["data"]) del wrapper["data"] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index d2c8379..0000000 --- a/tox.ini +++ /dev/null @@ -1,6 +0,0 @@ -[tox] -envlist = py27, py34, py35, py36, py37, py38, py39, py310, py311, py312 - -[testenv] -deps = -rrequirements-testing.txt -commands = py.test {posargs} From 0ce0815b6f8244abf68510edb61c1d91d8f73309 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Mon, 25 Aug 2025 17:19:19 -0700 Subject: [PATCH 167/208] Loosen requests requirement (#138) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 966a674..2b2035e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ authors = [ ] requires-python = ">=3.9" dependencies = [ - "requests>=2.32.5", + "requests>=2.25.0, <3", ] keywords = ["mixpanel", "analytics"] classifiers = [ @@ -55,4 +55,4 @@ envlist = ["py39", "py310", "py311", "py312"] extras = ["test"] commands = [ ["pytest", "{posargs}"], -] \ No newline at end of file +] From 127a0663f8360a78eab839fe247eb16e5d2d5725 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Mon, 25 Aug 2025 17:55:40 -0700 Subject: [PATCH 168/208] More tweaks on deps (#139) --- CHANGES.txt | 5 ++++- mixpanel/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index bdf842b..96a3d61 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,8 @@ +v4.11.1 +* Loosen requirements for `requests` lib to >=2.4.2 to keep compatible with 2.10 + v4.11.0 -* Set minimum supported python version to 3.9, deprecating support for end-of-life versions of python +* Set minimum supported python version to 3.9, deprecating support for end-of-life versions of python * Convert setup.py to pyproject.toml v4.9.0 diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 046d068..1a9035a 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -24,7 +24,7 @@ from requests.auth import HTTPBasicAuth import urllib3 -__version__ = '4.11.0' +__version__ = '4.11.1' VERSION = __version__ # TODO: remove when bumping major version. logger = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index 2b2035e..0230898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ authors = [ ] requires-python = ">=3.9" dependencies = [ - "requests>=2.25.0, <3", + "requests>=2.4.2, <3", ] keywords = ["mixpanel", "analytics"] classifiers = [ From 4d161f64c70984d14e57989c94585290df366061 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:02 -0700 Subject: [PATCH 169/208] Bump docs version (#140) --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index a53f1e1..22ca672 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.10.1' +version = release = '4.11.1' exclude_patterns = ['_build'] pygments_style = 'sphinx' From 87ec4ad2d5f9ef756e78f54d1441a66387f69e41 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 9 Sep 2025 01:40:47 +0200 Subject: [PATCH 170/208] Remove stray `docs/conf.py` from wheel (#143) Including `docs/conf.py` in a wheel pollutes the global namespace with a bogus `docs.conf` module and causes conflicts with other packages that do the same. Exclude `docs` to prevent this. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0230898..c10e9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ version = {attr = "mixpanel.__version__"} [tool.setuptools.packages.find] -exclude = ["demo"] +exclude = ["demo", "docs"] [tool.tox] envlist = ["py39", "py310", "py311", "py312"] From 0f8c84efc5630e1069afbbbb17870a865d0e8796 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:38:31 -0700 Subject: [PATCH 171/208] Implement initial feature flag support (#141) * Implement feature flag support * cleanup * update parsing and logging * fix casing and async method prefix conventions * Additional convention and changelist update * Add additional common query params * Addressing comments & adding support for user defined threadpool * Renaming query params for consistency * Add polling stop functionality, refactor api slightly, and increase code coverage * fix exception type * Fix tests when run under pypy * Fix race in tests by completing executor tasks before assertion * onboard to CodeCov * remove name override for coverage file * use updated codecov secret and snippet --------- Co-authored-by: Kwame Efah --- .github/workflows/test.yml | 9 +- BUILD.rst | 5 + CHANGES.txt | 3 + demo/local_flags.py | 31 ++ demo/remote_flags.py | 35 ++ mixpanel/__init__.py | 56 +++- mixpanel/flags/__init__.py | 0 mixpanel/flags/local_feature_flags.py | 270 ++++++++++++++++ mixpanel/flags/remote_feature_flags.py | 145 +++++++++ mixpanel/flags/test_local_feature_flags.py | 340 ++++++++++++++++++++ mixpanel/flags/test_remote_feature_flags.py | 163 ++++++++++ mixpanel/flags/types.py | 62 ++++ mixpanel/flags/utils.py | 50 +++ pyproject.toml | 11 +- 14 files changed, 1172 insertions(+), 8 deletions(-) create mode 100644 demo/local_flags.py create mode 100644 demo/remote_flags.py create mode 100644 mixpanel/flags/__init__.py create mode 100644 mixpanel/flags/local_feature_flags.py create mode 100644 mixpanel/flags/remote_feature_flags.py create mode 100644 mixpanel/flags/test_local_feature_flags.py create mode 100644 mixpanel/flags/test_remote_feature_flags.py create mode 100644 mixpanel/flags/types.py create mode 100644 mixpanel/flags/utils.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d58d82..4c1bd19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,11 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] - - name: Test with pytest + - name: Run tests run: | - pytest test_mixpanel.py + pytest --cov --cov-branch --cov-report=xml + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: mixpanel/mixpanel-python \ No newline at end of file diff --git a/BUILD.rst b/BUILD.rst index b865d1d..7826146 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -15,6 +15,11 @@ Run tests:: python -m tox - runs all tests against all configured environments in the pyproject.toml +Run tests under code coverage:: + python -m coverage run -m pytest + python -m coverage report -m + python -m coverage html + Publish to PyPI:: python -m build diff --git a/CHANGES.txt b/CHANGES.txt index 96a3d61..259700b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v5.0.0b1 +* Added initial feature flagging support + v4.11.1 * Loosen requirements for `requests` lib to >=2.4.2 to keep compatible with 2.10 diff --git a/demo/local_flags.py b/demo/local_flags.py new file mode 100644 index 0000000..397db43 --- /dev/null +++ b/demo/local_flags.py @@ -0,0 +1,31 @@ +import os +import asyncio +import mixpanel +import logging + +logging.basicConfig(level=logging.INFO) + +# Configure your project token, the feature flag to test, and user context to evaluate. +PROJECT_TOKEN = "" +FLAG_KEY = "sample-flag" +FLAG_FALLBACK_VARIANT = "control" +USER_CONTEXT = { "distinct_id": "sample-distinct-id" } + +# If False, the flag definitions are fetched just once on SDK initialization. Otherwise, will poll +SHOULD_POLL_CONTINOUSLY = False +POLLING_INTERVAL_IN_SECONDS = 90 + +# Use the correct data residency endpoint for your project. +API_HOST = "api-eu.mixpanel.com" + +async def main(): + local_config = mixpanel.LocalFlagsConfig(api_host=API_HOST, enable_polling=SHOULD_POLL_CONTINOUSLY, polling_interval_in_seconds=POLLING_INTERVAL_IN_SECONDS) + + # Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging + async with mixpanel.Mixpanel(PROJECT_TOKEN, local_flags_config=local_config) as mp: + await mp.local_flags.astart_polling_for_definitions() + variant_value = mp.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + print(f"Variant value: {variant_value}") + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/demo/remote_flags.py b/demo/remote_flags.py new file mode 100644 index 0000000..bb78703 --- /dev/null +++ b/demo/remote_flags.py @@ -0,0 +1,35 @@ +import asyncio +import mixpanel +import logging + +logging.basicConfig(level=logging.INFO) + +# Configure your project token, the feature flag to test, and user context to evaluate. +PROJECT_TOKEN = "" +FLAG_KEY = "sample-flag" +FLAG_FALLBACK_VARIANT = "control" +USER_CONTEXT = { "distinct_id": "sample-distinct-id" } + +# Use the correct data residency endpoint for your project. +API_HOST = "api-eu.mixpanel.com" + +DEMO_ASYNC = True + +async def async_demo(): + remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST) + # Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging + async with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp: + variant_value = await mp.remote_flags.aget_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + print(f"Variant value: {variant_value}") + +def sync_demo(): + remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST) + with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp: + variant_value = mp.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + print(f"Variant value: {variant_value}") + +if __name__ == '__main__': + if DEMO_ASYNC: + asyncio.run(async_demo()) + else: + sync_demo() \ No newline at end of file diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 1a9035a..8faac12 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -24,11 +24,15 @@ from requests.auth import HTTPBasicAuth import urllib3 -__version__ = '4.11.1' -VERSION = __version__ # TODO: remove when bumping major version. +from typing import Optional -logger = logging.getLogger(__name__) +from .flags.local_feature_flags import LocalFeatureFlagsProvider +from .flags.remote_feature_flags import RemoteFeatureFlagsProvider +from .flags.types import LocalFlagsConfig, RemoteFlagsConfig + +__version__ = '5.0.0b1' +logger = logging.getLogger(__name__) class DatetimeSerializer(json.JSONEncoder): def default(self, obj): @@ -44,7 +48,7 @@ def json_dumps(data, cls=None): return json.dumps(data, separators=(',', ':'), cls=cls) -class Mixpanel(object): +class Mixpanel(): """Instances of Mixpanel are used for all events and profile updates. :param str token: your project's Mixpanel token @@ -59,17 +63,40 @@ class Mixpanel(object): The *serializer* parameter. """ - def __init__(self, token, consumer=None, serializer=DatetimeSerializer): + def __init__(self, token, consumer=None, serializer=DatetimeSerializer, local_flags_config: Optional[LocalFlagsConfig] = None, remote_flags_config: Optional[RemoteFlagsConfig] = None): self._token = token self._consumer = consumer or Consumer() self._serializer = serializer + self._local_flags_provider = None + self._remote_flags_provider = None + + if local_flags_config: + self._local_flags_provider = LocalFeatureFlagsProvider(self._token, local_flags_config, __version__, self.track) + + if remote_flags_config: + self._remote_flags_provider = RemoteFeatureFlagsProvider(self._token, remote_flags_config, __version__, self.track) + def _now(self): return time.time() def _make_insert_id(self): return uuid.uuid4().hex + @property + def local_flags(self) -> LocalFeatureFlagsProvider: + """Get the local flags provider if configured for it""" + if self._local_flags_provider is None: + raise MixpanelException("No local flags provider initialized. Pass local_flags_config to constructor.") + return self._local_flags_provider + + @property + def remote_flags(self) -> RemoteFeatureFlagsProvider: + """Get the remote flags provider if configured for it""" + if self._remote_flags_provider is None: + raise MixpanelException("No remote_flags_config was passed to the consttructor") + return self._remote_flags_provider + def track(self, distinct_id, event_name, properties=None, meta=None): """Record an event. @@ -504,6 +531,24 @@ def group_update(self, message, meta=None): record.update(meta) self._consumer.send('groups', json_dumps(record, cls=self._serializer)) + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._local_flags_provider is not None: + self._local_flags_provider.__exit__(exc_type, exc_val, exc_tb) + if self._remote_flags_provider is not None: + self._remote_flags_provider.__exit__(exc_type, exc_val, exc_tb) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._local_flags_provider is not None: + await self._local_flags_provider.__aexit__(exc_type, exc_val, exc_tb) + if self._remote_flags_provider is not None: + await self._remote_flags_provider.__aexit__(exc_type, exc_val, exc_tb) + class MixpanelException(Exception): """Raised by consumers when unable to send messages. @@ -733,3 +778,4 @@ def _flush_endpoint(self, endpoint): raise mp_e from orig_e buf = buf[self._max_size:] self._buffers[endpoint] = buf + diff --git a/mixpanel/flags/__init__.py b/mixpanel/flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py new file mode 100644 index 0000000..40dc8b8 --- /dev/null +++ b/mixpanel/flags/local_feature_flags.py @@ -0,0 +1,270 @@ +import httpx +import logging +import asyncio +import time +import threading +from datetime import datetime, timedelta +from typing import Dict, Any, Callable, Optional +from concurrent.futures import Future, ThreadPoolExecutor +from .types import ExperimentationFlag, ExperimentationFlags, SelectedVariant, LocalFlagsConfig, Rollout +from .utils import REQUEST_HEADERS, normalized_hash, prepare_common_query_params, EXPOSURE_EVENT + +logger = logging.getLogger(__name__) +logging.getLogger("httpx").setLevel(logging.ERROR) + +class LocalFeatureFlagsProvider: + FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" + + def __init__(self, token: str, config: LocalFlagsConfig, version: str, tracker: Callable) -> None: + self._token: str = token + self._config: LocalFlagsConfig = config + self._version = version + self._tracker: Callable = tracker + self._executor: ThreadPoolExecutor = config.custom_executor or ThreadPoolExecutor(max_workers=5) + + self._flag_definitions: Dict[str, ExperimentationFlag] = dict() + + httpx_client_parameters = { + "base_url": f"https://{config.api_host}", + "headers": REQUEST_HEADERS, + "auth": httpx.BasicAuth(token, ""), + "timeout": httpx.Timeout(config.request_timeout_in_seconds), + } + + self._request_params = prepare_common_query_params(self._token, self._version) + + self._async_client: httpx.AsyncClient = httpx.AsyncClient(**httpx_client_parameters) + self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) + + self._async_polling_task: Optional[asyncio.Task] = None + self._sync_polling_task: Optional[Future] = None + + self._sync_stop_event = threading.Event() + + def start_polling_for_definitions(self): + self._fetch_flag_definitions() + + if self._config.enable_polling: + if not self._sync_polling_task and not self._async_polling_task: + self._sync_stop_event.clear() + self._sync_polling_task = self._executor.submit(self._start_continuous_polling) + else: + logging.error("A polling task is already running") + + def stop_polling_for_definitions(self): + if self._sync_polling_task: + self._sync_stop_event.set() + self._sync_polling_task.cancel() + self._sync_polling_task = None + else: + logging.info("There is no polling task to cancel.") + + async def astart_polling_for_definitions(self): + await self._afetch_flag_definitions() + + if self._config.enable_polling: + if not self._sync_polling_task and not self._async_polling_task: + self._async_polling_task = asyncio.create_task(self._astart_continuous_polling()) + else: + logging.error("A polling task is already running") + + async def astop_polling_for_definitions(self): + if self._async_polling_task: + self._async_polling_task.cancel() + self._async_polling_task = None + else: + logging.info("There is no polling task to cancel.") + + async def _astart_continuous_polling(self): + logging.info(f"Initialized async polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds") + try: + while True: + await asyncio.sleep(self._config.polling_interval_in_seconds) + await self._afetch_flag_definitions() + except asyncio.CancelledError: + logging.info("Async polling was cancelled") + + def _start_continuous_polling(self): + logging.info(f"Initialized sync polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds") + while not self._sync_stop_event.is_set(): + if self._sync_stop_event.wait(timeout=self._config.polling_interval_in_seconds): + break + + self._fetch_flag_definitions() + + def are_flags_ready(self) -> bool: + """ + Check if flag definitions have been loaded and are ready for use. + :return: True if flag definitions are populated, False otherwise. + """ + return bool(self._flag_definitions) + + def get_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: + variant = self.get_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + return variant.variant_value + + def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + variant_value = self.get_variant_value(flag_key, False, context) + return bool(variant_value) + + def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + start_time = time.perf_counter() + flag_definition = self._flag_definitions.get(flag_key) + + if not flag_definition: + logger.warning(f"Cannot find flag definition for key: '{flag_key}'") + return fallback_value + + if not(context_value := context.get(flag_definition.context)): + logger.warning(f"The rollout context, '{flag_definition.context}' for flag, '{flag_key}' is not present in the supplied context dictionary") + return fallback_value + + if test_user_variant := self._get_variant_override_for_test_user(flag_definition, context): + return test_user_variant + + if rollout := self._get_assigned_rollout(flag_definition, context_value, context): + variant = self._get_assigned_variant(flag_definition, context_value, flag_key, rollout) + end_time = time.perf_counter() + self.track_exposure(flag_key, variant, end_time - start_time, context) + return variant + + logger.info(f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}") + return fallback_value + + def _get_variant_override_for_test_user(self, flag_definition: ExperimentationFlag, context: Dict[str, Any]) -> Optional[SelectedVariant]: + """""" + if not flag_definition.ruleset.test or not flag_definition.ruleset.test.users: + return None + + if not (distinct_id := context.get("distinct_id")): + return None + + if not (variant_key := flag_definition.ruleset.test.users.get(distinct_id)): + return None + + return self._get_matching_variant(variant_key, flag_definition) + + def _get_assigned_variant(self, flag_definition: ExperimentationFlag, context_value: Any, flag_name: str, rollout: Rollout) -> SelectedVariant: + if rollout.variant_override: + if variant := self._get_matching_variant(rollout.variant_override.key, flag_definition): + return variant + + variants = flag_definition.ruleset.variants + + hash_input = str(context_value) + flag_name + + variant_hash = normalized_hash(hash_input, "variant") + + selected = variants[0] + cumulative = 0.0 + for variant in variants: + selected = variant + cumulative += variant.split + if variant_hash < cumulative: + break + + return SelectedVariant(variant_key=selected.key, variant_value=selected.value) + + def _get_assigned_rollout(self, flag_definition: ExperimentationFlag, context_value: Any, context: Dict[str, Any]) -> Optional[Rollout]: + hash_input = str(context_value) + flag_definition.key + + rollout_hash = normalized_hash(hash_input, "rollout") + + for rollout in flag_definition.ruleset.rollout: + if rollout_hash < rollout.rollout_percentage and self._is_runtime_evaluation_satisfied(rollout, context): + return rollout + + return None + + def _is_runtime_evaluation_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + if not rollout.runtime_evaluation_definition: + return True + + if not (custom_properties := context.get("custom_properties")): + return False + + if not isinstance(custom_properties, dict): + return False + + for key, expected_value in rollout.runtime_evaluation_definition.items(): + if key not in custom_properties: + return False + + actual_value = custom_properties[key] + if actual_value.casefold() != expected_value.casefold(): + return False + + return True + + def _get_matching_variant(self, variant_key: str, flag: ExperimentationFlag) -> Optional[SelectedVariant]: + for variant in flag.ruleset.variants: + if variant_key.casefold() == variant.key.casefold(): + return SelectedVariant(variant_key=variant.key, variant_value=variant.value) + return None + + async def _afetch_flag_definitions(self) -> None: + try: + start_time = datetime.now() + response = await self._async_client.get(self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params) + end_time = datetime.now() + self._handle_response(response, start_time, end_time) + except Exception: + logger.exception("Failed to fetch feature flag definitions") + + def _fetch_flag_definitions(self) -> None: + try: + start_time = datetime.now() + response = self._sync_client.get(self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params) + end_time = datetime.now() + self._handle_response(response, start_time, end_time) + except Exception: + logger.exception("Failed to fetch feature flag definitions") + + def _handle_response(self, response: httpx.Response, start_time: datetime, end_time: datetime) -> None: + request_duration: timedelta = end_time - start_time + logging.info(f"Request started at '{start_time.isoformat()}', completed at '{end_time.isoformat()}', duration: '{request_duration.total_seconds():.3f}s'") + + response.raise_for_status() + + flags = {} + try: + json_data = response.json() + experimentation_flags = ExperimentationFlags.model_validate(json_data) + for flag in experimentation_flags.flags: + flag.ruleset.variants.sort(key=lambda variant: variant.key) + flags[flag.key] = flag + except Exception: + logger.exception("Failed to parse flag definitions") + + self._flag_definitions = flags + logger.info(f"Successfully fetched {len(self._flag_definitions)} flag definitions") + + + def track_exposure(self, flag_key: str, variant: SelectedVariant, latency_in_seconds: float, context: Dict[str, Any]): + if distinct_id := context.get("distinct_id"): + properties = { + 'Experiment name': flag_key, + 'Variant name': variant.variant_key, + '$experiment_type': 'feature_flag', + "Flag evaluation mode": "local", + "Variant fetch latency (ms)": latency_in_seconds * 1000 + } + self._executor.submit(self._tracker, distinct_id, EXPOSURE_EVENT, properties) + else: + logging.error("Cannot track exposure event without a distinct_id in the context") + + async def __aenter__(self): + return self + + def __enter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + logging.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") + await self.astop_polling_for_definitions() + await self._async_client.aclose() + + def __exit__(self, exc_type, exc_val, exc_tb): + logging.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") + self.stop_polling_for_definitions() + self._sync_client.close() diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py new file mode 100644 index 0000000..1969bb1 --- /dev/null +++ b/mixpanel/flags/remote_feature_flags.py @@ -0,0 +1,145 @@ +import httpx +import logging +import json +import urllib.parse +import asyncio +from datetime import datetime +from typing import Dict, Any, Callable +from asgiref.sync import sync_to_async + +from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse +from concurrent.futures import ThreadPoolExecutor +from .utils import REQUEST_HEADERS, EXPOSURE_EVENT, prepare_common_query_params + +logger = logging.getLogger(__name__) +logging.getLogger("httpx").setLevel(logging.ERROR) + +class RemoteFeatureFlagsProvider: + FLAGS_URL_PATH = "/flags" + + def __init__(self, token: str, config: RemoteFlagsConfig, version: str, tracker: Callable) -> None: + self._token: str = token + self._config: RemoteFlagsConfig = config + self._version: str = version + self._tracker: Callable = tracker + self._executor: ThreadPoolExecutor = config.custom_executor or ThreadPoolExecutor(max_workers=5) + + httpx_client_parameters = { + "base_url": f"https://{config.api_host}", + "headers": REQUEST_HEADERS, + "auth": httpx.BasicAuth(token, ""), + "timeout": httpx.Timeout(config.request_timeout_in_seconds), + } + + self._async_client: httpx.AsyncClient = httpx.AsyncClient(**httpx_client_parameters) + self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) + self._request_params_base = prepare_common_query_params(self._token, version) + + async def aget_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: + variant = await self.aget_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + return variant.variant_value + + async def aget_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + try: + params = self._prepare_query_params(flag_key, context) + start_time = datetime.now() + response = await self._async_client.get(self.FLAGS_URL_PATH, params=params) + end_time = datetime.now() + self._instrument_call(start_time, end_time) + selected_variant, is_fallback = self._handle_response(flag_key, fallback_value, response) + + if not is_fallback and (distinct_id := context.get("distinct_id")): + properties = self._build_tracking_properties(flag_key, selected_variant, start_time, end_time) + asyncio.create_task( + sync_to_async(self._tracker, executor=self._executor, thread_sensitive=False)(distinct_id, EXPOSURE_EVENT, properties)) + + return selected_variant + except Exception: + logging.exception(f"Failed to get remote variant for flag '{flag_key}'") + return fallback_value + + async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + variant_value = await self.aget_variant_value(flag_key, False, context) + return bool(variant_value) + + def get_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: + variant = self.get_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + return variant.variant_value + + def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + try: + params = self._prepare_query_params(flag_key, context) + start_time = datetime.now() + response = self._sync_client.get(self.FLAGS_URL_PATH, params=params) + end_time = datetime.now() + self._instrument_call(start_time, end_time) + selected_variant, is_fallback = self._handle_response(flag_key, fallback_value, response) + + if not is_fallback and (distinct_id := context.get("distinct_id")): + properties = self._build_tracking_properties(flag_key, selected_variant, start_time, end_time) + self._executor.submit(self._tracker, distinct_id, EXPOSURE_EVENT, properties) + + return selected_variant + except Exception: + logging.exception(f"Failed to get remote variant for flag '{flag_key}'") + return fallback_value + + def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + variant_value = self.get_variant_value(flag_key, False, context) + return bool(variant_value) + + def _prepare_query_params(self, flag_key: str, context: Dict[str, Any]) -> Dict[str, str]: + params = self._request_params_base.copy() + context_json = json.dumps(context).encode('utf-8') + url_encoded_context = urllib.parse.quote(context_json) + params.update({ + 'flag_key': flag_key, + 'context': url_encoded_context + }) + return params + + def _instrument_call(self, start_time: datetime, end_time: datetime) -> None: + request_duration = end_time - start_time + formatted_start_time = start_time.isoformat() + formatted_end_time = end_time.isoformat() + logging.info(f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'") + + def _build_tracking_properties(self, flag_key: str, variant: SelectedVariant, start_time: datetime, end_time: datetime) -> Dict[str, Any]: + request_duration = end_time - start_time + formatted_start_time = start_time.isoformat() + formatted_end_time = end_time.isoformat() + + return { + 'Experiment name': flag_key, + 'Variant name': variant.variant_key, + '$experiment_type': 'feature_flag', + "Flag evaluation mode": "remote", + "Variant fetch start time": formatted_start_time, + "Variant fetch complete time": formatted_end_time, + "Variant fetch latency (ms)": request_duration.total_seconds() * 1000, + } + + def _handle_response(self, flag_key: str, fallback_value: SelectedVariant, response: httpx.Response) -> tuple[SelectedVariant, bool]: + response.raise_for_status() + + flags_response = RemoteFlagsResponse.model_validate(response.json()) + + if flag_key in flags_response.flags: + return flags_response.flags[flag_key], False + else: + logging.warning(f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'") + return fallback_value, True + + def __enter__(self): + return self + + async def __aenter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") + self._sync_client.close() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") + await self._async_client.aclose() diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py new file mode 100644 index 0000000..63a437f --- /dev/null +++ b/mixpanel/flags/test_local_feature_flags.py @@ -0,0 +1,340 @@ +import asyncio +import pytest +import respx +import httpx +import threading +from unittest.mock import Mock, patch +from typing import Dict, Optional, List +from itertools import chain, repeat +from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride +from .local_feature_flags import LocalFeatureFlagsProvider + +def create_test_flag( + flag_key: str = "test_flag", + context: str = "distinct_id", + variants: Optional[list[Variant]] = None, + variant_override: Optional[VariantOverride] = None, + rollout_percentage: float = 100.0, + runtime_evaluation: Optional[Dict] = None, + test_users: Optional[Dict[str, str]] = None) -> ExperimentationFlag: + + if variants is None: + variants = [ + Variant(key="control", value="control", is_control=True, split=50.0), + Variant(key="treatment", value="treatment", is_control=False, split=50.0) + ] + + rollouts = [Rollout( + rollout_percentage=rollout_percentage, + runtime_evaluation_definition=runtime_evaluation, + variant_override=variant_override + )] + + test_config = None + if test_users: + test_config = FlagTestUsers(users=test_users) + + ruleset = RuleSet( + variants=variants, + rollout=rollouts, + test=test_config + ) + + return ExperimentationFlag( + id="test-id", + name="Test Flag", + key=flag_key, + status="active", + project_id=123, + ruleset=ruleset, + context=context + ) + + +def create_flags_response(flags: List[ExperimentationFlag]) -> httpx.Response: + if flags is None: + flags = [] + response_data = ExperimentationFlags(flags=flags).model_dump() + return httpx.Response(status_code=200, json=response_data) + + +@pytest.mark.asyncio +class TestLocalFeatureFlagsProviderAsync: + async def get_flags_provider(self, config: LocalFlagsConfig) -> LocalFeatureFlagsProvider: + mock_tracker = Mock() + flags_provider = LocalFeatureFlagsProvider("test-token", config, "1.0.0", mock_tracker) + await flags_provider.astart_polling_for_definitions() + return flags_provider + + async def setup_flags(self, flags: List[ExperimentationFlag]): + respx.get("https://api.mixpanel.com/flags/definitions").mock( + return_value=create_flags_response(flags)) + + return await self.get_flags_provider(LocalFlagsConfig(enable_polling=False)) + + async def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): + responses = [create_flags_response(flag) for flag in flags_in_order] + + respx.get("https://api.mixpanel.com/flags/definitions").mock( + side_effect=chain( + responses, + repeat(responses[-1]), + ) + ) + + return await self.get_flags_provider(LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0)) + + + @respx.mock + async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): + flags = await self.setup_flags([]) + result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails(self): + respx.get("https://api.mixpanel.com/flags/definitions").mock( + return_value=httpx.Response(status_code=500) + ) + + flags = await self.get_flags_provider(LocalFlagsConfig(enable_polling=False)) + result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): + other_flag = create_test_flag("other_flag") + flags = await self.setup_flags([other_flag]) + result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_no_context(self): + flag = create_test_flag(context="distinct_id") + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "fallback", {}) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): + flag = create_test_flag(context="user_id") + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_returns_test_user_variant_when_configured(self): + variants = [ + Variant(key="control", value="false", is_control=True, split=50.0), + Variant(key="treatment", value="true", is_control=False, split=50.0) + ] + flag = create_test_flag( + variants=variants, + test_users={"test_user": "treatment"} + ) + + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) + assert result == "true" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_test_user_variant_not_configured(self): + variants = [ + Variant(key="control", value="false", is_control=True, split=50.0), + Variant(key="treatment", value="true", is_control=False, split=50.0) + ] + flag = create_test_flag( + variants=variants, + test_users={"test_user": "nonexistent_variant"} + ) + flags = await self.setup_flags([flag]) + with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + mock_hash.return_value = 0.5 + result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) + assert result == "false" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): + flag = create_test_flag(rollout_percentage=0.0) + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): + flag = create_test_flag(rollout_percentage=100.0) + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation=runtime_eval) + flags = await self.setup_flags([flag]) + context = { + "distinct_id": "user123", + "custom_properties": { + "plan": "premium", + "region": "US" + } + } + result = flags.get_variant_value("test_flag", "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation=runtime_eval) + flags = await self.setup_flags([flag]) + context = { + "distinct_id": "user123", + "custom_properties": { + "plan": "basic", + "region": "US" + } + } + result = flags.get_variant_value("test_flag", "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_picks_correct_variant_with_hundred_percent_split(self): + variants = [ + Variant(key="A", value="variant_a", is_control=False, split=100.0), + Variant(key="B", value="variant_b", is_control=False, split=0.0), + Variant(key="C", value="variant_c", is_control=False, split=0.0) + ] + flag = create_test_flag(variants=variants, rollout_percentage=100.0) + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result == "variant_a" + + @respx.mock + async def test_get_variant_value_picks_overriden_variant(self): + variants = [ + Variant(key="A", value="variant_a", is_control=False, split=100.0), + Variant(key="B", value="variant_b", is_control=False, split=0.0), + ] + flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) + flags = await self.setup_flags([flag]) + result = flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "variant_b" + + @respx.mock + async def test_get_variant_value_tracks_exposure_when_variant_selected(self): + flag = create_test_flag() + flags = await self.setup_flags([flag]) + with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + mock_hash.return_value = 0.5 + _ = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + flags._executor.shutdown() + flags._tracker.assert_called_once() + + @respx.mock + async def test_get_variant_value_does_not_track_exposure_on_fallback(self): + flags = await self.setup_flags([]) + _ = flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) + flags._executor.shutdown() + flags._tracker.assert_not_called() + + @respx.mock + async def test_get_variant_value_does_not_track_exposure_without_distinct_id(self): + flag = create_test_flag(context="company") + flags = await self.setup_flags([flag]) + _ = flags.get_variant_value("nonexistent_flag", "fallback", {"company_id": "company123"}) + flags._executor.shutdown() + flags._tracker.assert_not_called() + + @respx.mock + async def test_are_flags_ready_returns_true_when_flags_loaded(self): + flag = create_test_flag() + flags = await self.setup_flags([flag]) + assert flags.are_flags_ready() == True + + @respx.mock + async def test_is_enabled_returns_false_for_nonexistent_flag(self): + flags = await self.setup_flags([]) + result = flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) + assert result == False + + @respx.mock + async def test_is_enabled_returns_true_for_true_variant_value(self): + variants = [ + Variant(key="treatment", value=True, is_control=False, split=100.0) + ] + flag = create_test_flag(variants=variants, rollout_percentage=100.0) + flags = await self.setup_flags([flag]) + result = flags.is_enabled("test_flag", {"distinct_id": "user123"}) + assert result == True + + @respx.mock + async def test_get_variant_value_uses_most_recent_polled_flag(self): + polling_iterations = 0 + polling_limit_check = asyncio.Condition() + original_fetch = LocalFeatureFlagsProvider._afetch_flag_definitions + + async def track_fetch_calls(self): + nonlocal polling_iterations + async with polling_limit_check: + polling_iterations += 1 + polling_limit_check.notify_all() + return await original_fetch(self) + + with patch.object(LocalFeatureFlagsProvider, '_afetch_flag_definitions', track_fetch_calls): + flag_v1 = create_test_flag(rollout_percentage=0.0) + flag_v2 = create_test_flag(rollout_percentage=100.0) + + flags_in_order=[[flag_v1], [flag_v2]] + flags = await self.setup_flags_with_polling(flags_in_order) + async with polling_limit_check: + await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) + + result2 = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result2 != "fallback" + + await flags.astop_polling_for_definitions() + +class TestLocalFeatureFlagsProviderSync: + def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): + responses = [create_flags_response(flag) for flag in flags_in_order] + + respx.get("https://api.mixpanel.com/flags/definitions").mock( + side_effect=chain( + responses, + repeat(responses[-1]), + ) + ) + + return self.get_flags_provider(LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0)) + + def get_flags_provider(self, config: LocalFlagsConfig) -> LocalFeatureFlagsProvider: + mock_tracker = Mock() + flags_provider = LocalFeatureFlagsProvider("test-token", config, "1.0.0", mock_tracker) + flags_provider.start_polling_for_definitions() + return flags_provider + + @respx.mock + def test_get_variant_value_uses_most_recent_polled_flag(self): + flag_v1 = create_test_flag(rollout_percentage=0.0) + flag_v2 = create_test_flag(rollout_percentage=100.0) + flags_in_order=[[flag_v1], [flag_v2]] + + polling_iterations = 0 + polling_event = threading.Event() + original_fetch = LocalFeatureFlagsProvider._fetch_flag_definitions + + # Hook into the fetch method to signal when we've polled multiple times. + def track_fetch_calls(self): + nonlocal polling_iterations + polling_iterations += 1 + if polling_iterations >= 3: + polling_event.set() + return original_fetch(self) + + with patch.object(LocalFeatureFlagsProvider, '_fetch_flag_definitions', track_fetch_calls): + flags = self.setup_flags_with_polling(flags_in_order) + polling_event.wait(timeout=5.0) + flags.stop_polling_for_definitions() + assert (polling_iterations >= 3 ) + result2 = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result2 != "fallback" diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py new file mode 100644 index 0000000..e7fff93 --- /dev/null +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -0,0 +1,163 @@ +import pytest +import httpx +import respx +import asyncio +from typing import Dict +from unittest.mock import Mock +from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant +from .remote_feature_flags import RemoteFeatureFlagsProvider + +ENDPOINT = "https://api.mixpanel.com/flags" + +def create_success_response(assigned_variants_per_flag: Dict[str, SelectedVariant]) -> httpx.Response: + serialized_response = RemoteFlagsResponse(code=200, flags=assigned_variants_per_flag).model_dump() + return httpx.Response(status_code=200, json=serialized_response) + +class TestRemoteFeatureFlagsProviderAsync: + @pytest.fixture(autouse=True) + async def setup_method(self): + config = RemoteFlagsConfig() + self.mock_tracker = Mock() + self._flags = RemoteFeatureFlagsProvider("test-token", config, "1.0.0", self.mock_tracker) + yield + await self._flags.__aexit__(None, None, None) + + @respx.mock + @pytest.mark.asyncio + async def test_get_variant_value_is_fallback_if_call_fails(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + + result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_is_fallback_if_bad_response_format(self): + respx.get(ENDPOINT).mock(return_value=httpx.Response(200, text="invalid json")) + + result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_is_fallback_if_success_but_no_flag_found(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({})) + + result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + async def test_get_variant_value_returns_expected_variant_from_api(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) + + result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "treatment" + + @respx.mock + async def test_get_variant_value_tracks_exposure_event_if_variant_selected(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) + + await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + + pending = [task for task in asyncio.all_tasks() if not task.done() and task != asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + self._flags._executor.shutdown() + self.mock_tracker.assert_called_once() + + @respx.mock + async def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + self._flags._executor.shutdown() + self.mock_tracker.assert_not_called() + + @respx.mock + async def test_ais_enabled_returns_true_for_true_variant_value(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="enabled", variant_value=True)})) + + result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) + assert result == True + + @respx.mock + async def test_ais_enabled_returns_false_for_false_variant_value(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="disabled", variant_value=False)})) + + result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) + assert result == False + +class TestRemoteFeatureFlagsProviderSync: + def setup_method(self): + config = RemoteFlagsConfig() + self.mock_tracker = Mock() + self._flags = RemoteFeatureFlagsProvider("test-token", config, "1.0.0", self.mock_tracker) + + def teardown_method(self): + self._flags.__exit__(None, None, None) + + @respx.mock + def test_get_variant_value_is_fallback_if_call_fails(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + def test_get_variant_value_is_fallback_if_bad_response_format(self): + respx.get(ENDPOINT).mock(return_value=httpx.Response(200, text="invalid json")) + + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + def test_get_variant_value_is_fallback_if_success_but_no_flag_found(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({})) + + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "control" + + @respx.mock + def test_get_variant_value_returns_expected_variant_from_api(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) + + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + assert result == "treatment" + + @respx.mock + def test_get_variant_value_tracks_exposure_event_if_variant_selected(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) + + self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + self._flags._executor.shutdown() + self.mock_tracker.assert_called_once() + + @respx.mock + def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + self._flags._executor.shutdown() + self.mock_tracker.assert_not_called() + + @respx.mock + def test_is_enabled_returns_true_for_true_variant_value(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="enabled", variant_value=True)})) + + result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) + assert result == True + + @respx.mock + def test_is_enabled_returns_false_for_false_variant_value(self): + respx.get(ENDPOINT).mock( + return_value=create_success_response({"test_flag": SelectedVariant(variant_key="disabled", variant_value=False)})) + + result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) + assert result == False + diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py new file mode 100644 index 0000000..b71eec6 --- /dev/null +++ b/mixpanel/flags/types.py @@ -0,0 +1,62 @@ +from typing import Optional, List, Dict, Any +from concurrent.futures import ThreadPoolExecutor +from pydantic import BaseModel, ConfigDict + +MIXPANEL_DEFAULT_API_ENDPOINT = "api.mixpanel.com" + +class FlagsConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + api_host: str = "api.mixpanel.com" + request_timeout_in_seconds: int = 10 + custom_executor: Optional[ThreadPoolExecutor] = None + +class LocalFlagsConfig(FlagsConfig): + enable_polling: bool = True + polling_interval_in_seconds: int = 60 + +class RemoteFlagsConfig(FlagsConfig): + pass + +class Variant(BaseModel): + key: str + value: Any + is_control: bool + split: float + +class FlagTestUsers(BaseModel): + users: Dict[str, str] + +class VariantOverride(BaseModel): + key: str + +class Rollout(BaseModel): + rollout_percentage: float + runtime_evaluation_definition: Optional[Dict[str, str]] = None + variant_override: Optional[VariantOverride] = None + +class RuleSet(BaseModel): + variants: List[Variant] + rollout: List[Rollout] + test: Optional[FlagTestUsers] = None + +class ExperimentationFlag(BaseModel): + id: str + name: str + key: str + status: str + project_id: int + ruleset: RuleSet + context: str + +class SelectedVariant(BaseModel): + # variant_key can be None if being used as a fallback + variant_key: Optional[str] = None + variant_value: Any + +class ExperimentationFlags(BaseModel): + flags: List[ExperimentationFlag] + +class RemoteFlagsResponse(BaseModel): + code: int + flags: Dict[str, SelectedVariant] \ No newline at end of file diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py new file mode 100644 index 0000000..987392b --- /dev/null +++ b/mixpanel/flags/utils.py @@ -0,0 +1,50 @@ +from typing import Dict + +EXPOSURE_EVENT = "$experiment_started" + +REQUEST_HEADERS: Dict[str, str] = { + 'X-Scheme': 'https', + 'X-Forwarded-Proto': 'https', + 'Content-Type': 'application/json' +} + +def normalized_hash(key: str, salt: str) -> float: + """Compute a normalized hash using FNV-1a algorithm. + + :param key: The key to hash + :param salt: Salt to add to the hash + :return: Normalized hash value between 0.0 and 1.0 + """ + hash_value = _fnv1a64(key.encode("utf-8") + salt.encode("utf-8")) + return (hash_value % 100) / 100.0 + +def _fnv1a64(data: bytes) -> int: + """FNV-1a 64-bit hash function. + + :param data: Bytes to hash + :return: 64-bit hash value + """ + FNV_prime = 0x100000001b3 + hash_value = 0xcbf29ce484222325 + + for byte in data: + hash_value ^= byte + hash_value *= FNV_prime + hash_value &= 0xffffffffffffffff # Keep it 64-bit + + return hash_value + +def prepare_common_query_params(token: str, sdk_version: str) -> Dict[str, str]: + """Prepare common query string parameters for feature flag evaluation. + + :param token: The project token + :param sdk_version: The SDK version + :return: Dictionary of common query parameters + """ + params = { + 'mp_lib': 'python', + 'lib_version': sdk_version, + 'token': token + } + + return params \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c10e9e3..4bee8d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ authors = [ requires-python = ">=3.9" dependencies = [ "requests>=2.4.2, <3", + "httpx>=0.27.0", + "pydantic>=2.0.0", + "asgiref>=3.0.0", ] keywords = ["mixpanel", "analytics"] classifiers = [ @@ -32,7 +35,10 @@ Homepage = "https://github.com/mixpanel/mixpanel-python" [project.optional-dependencies] test = [ "pytest>=8.4.1", + "pytest-asyncio>=0.23.0", "responses>=0.25.8", + "respx>=0.21.0", + "pytest-cov" ] dev = [ "tox>=4.28.4", @@ -49,10 +55,13 @@ version = {attr = "mixpanel.__version__"} exclude = ["demo", "docs"] [tool.tox] -envlist = ["py39", "py310", "py311", "py312"] +envlist = ["py39", "py310", "py311", "py312", "pypy39", "pypy311"] [tool.tox.env_run_base] extras = ["test"] commands = [ ["pytest", "{posargs}"], ] + +[tool.pytest.ini_options] +asyncio_mode = "auto" From 139e3988e1c1c4b7ff52a8d68307891196106e61 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:20:39 -0700 Subject: [PATCH 172/208] Update py sdk exposure event tracking and add docs (#144) * Update exposure event tracking and add docs * refactor tests using setup & teardown * Update log level for polling thread to limit log noise * fixing typos from copilot suggestions --------- Co-authored-by: Kwame Efah --- CHANGES.txt | 3 + mixpanel/__init__.py | 2 +- mixpanel/flags/local_feature_flags.py | 223 +++++++++++++++----- mixpanel/flags/remote_feature_flags.py | 139 +++++++++--- mixpanel/flags/test_local_feature_flags.py | 144 +++++++------ mixpanel/flags/test_remote_feature_flags.py | 4 - mixpanel/flags/types.py | 2 - 7 files changed, 363 insertions(+), 154 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 259700b..cfdb755 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +v5.0.0b2 +* Update local flags evaluation to not use threadpool for exposure event tracking and add some docs + v5.0.0b1 * Added initial feature flagging support diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 8faac12..decc461 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -30,7 +30,7 @@ from .flags.remote_feature_flags import RemoteFeatureFlagsProvider from .flags.types import LocalFlagsConfig, RemoteFlagsConfig -__version__ = '5.0.0b1' +__version__ = '5.0.0b2' logger = logging.getLogger(__name__) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 40dc8b8..6d95334 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -5,24 +5,44 @@ import threading from datetime import datetime, timedelta from typing import Dict, Any, Callable, Optional -from concurrent.futures import Future, ThreadPoolExecutor -from .types import ExperimentationFlag, ExperimentationFlags, SelectedVariant, LocalFlagsConfig, Rollout -from .utils import REQUEST_HEADERS, normalized_hash, prepare_common_query_params, EXPOSURE_EVENT +from .types import ( + ExperimentationFlag, + ExperimentationFlags, + SelectedVariant, + LocalFlagsConfig, + Rollout, +) +from .utils import ( + REQUEST_HEADERS, + normalized_hash, + prepare_common_query_params, + EXPOSURE_EVENT, +) logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) + class LocalFeatureFlagsProvider: FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" - def __init__(self, token: str, config: LocalFlagsConfig, version: str, tracker: Callable) -> None: + def __init__( + self, token: str, config: LocalFlagsConfig, version: str, tracker: Callable + ) -> None: + """ + Initializes the LocalFeatureFlagsProvider + :param str token: your project's Mixpanel token + :param LocalFlagsConfig config: configuration options for the local feature flags provider + :param str version: the version of the Mixpanel library being used, just for tracking + :param Callable tracker: A function used to track flags exposure events to mixpanel + """ self._token: str = token self._config: LocalFlagsConfig = config self._version = version self._tracker: Callable = tracker - self._executor: ThreadPoolExecutor = config.custom_executor or ThreadPoolExecutor(max_workers=5) self._flag_definitions: Dict[str, ExperimentationFlag] = dict() + self._are_flags_ready = False httpx_client_parameters = { "base_url": f"https://{config.api_host}", @@ -33,42 +53,63 @@ def __init__(self, token: str, config: LocalFlagsConfig, version: str, tracker: self._request_params = prepare_common_query_params(self._token, self._version) - self._async_client: httpx.AsyncClient = httpx.AsyncClient(**httpx_client_parameters) + self._async_client: httpx.AsyncClient = httpx.AsyncClient( + **httpx_client_parameters + ) self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) self._async_polling_task: Optional[asyncio.Task] = None - self._sync_polling_task: Optional[Future] = None + self._sync_polling_task: Optional[threading.Thread] = None self._sync_stop_event = threading.Event() def start_polling_for_definitions(self): + """ + Fetches flag definitions for the current project. + If configured by the caller, starts a background thread to poll for updates at regular intervals, if one does not already exist. + """ self._fetch_flag_definitions() if self._config.enable_polling: if not self._sync_polling_task and not self._async_polling_task: self._sync_stop_event.clear() - self._sync_polling_task = self._executor.submit(self._start_continuous_polling) + self._sync_polling_task = threading.Thread( + target=self._start_continuous_polling, daemon=True + ) + self._sync_polling_task.start() else: - logging.error("A polling task is already running") + logging.warning("A polling task is already running") def stop_polling_for_definitions(self): + """ + If there exists a reference to a background thread polling for flag definition updates, signal it to stop and clear the reference. + Once stopped, the polling thread cannot be restarted. + """ if self._sync_polling_task: self._sync_stop_event.set() - self._sync_polling_task.cancel() self._sync_polling_task = None else: logging.info("There is no polling task to cancel.") async def astart_polling_for_definitions(self): + """ + Fetches flag definitions for the current project. + If configured by the caller, starts an async task on the event loop to poll for updates at regular intervals, if one does not already exist. + """ await self._afetch_flag_definitions() if self._config.enable_polling: if not self._sync_polling_task and not self._async_polling_task: - self._async_polling_task = asyncio.create_task(self._astart_continuous_polling()) + self._async_polling_task = asyncio.create_task( + self._astart_continuous_polling() + ) else: logging.error("A polling task is already running") async def astop_polling_for_definitions(self): + """ + If there exists an async task to poll for flag definition updates, cancel the task and clear the reference to it. + """ if self._async_polling_task: self._async_polling_task.cancel() self._async_polling_task = None @@ -76,7 +117,9 @@ async def astop_polling_for_definitions(self): logging.info("There is no polling task to cancel.") async def _astart_continuous_polling(self): - logging.info(f"Initialized async polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds") + logging.info( + f"Initialized async polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" + ) try: while True: await asyncio.sleep(self._config.polling_interval_in_seconds) @@ -85,29 +128,58 @@ async def _astart_continuous_polling(self): logging.info("Async polling was cancelled") def _start_continuous_polling(self): - logging.info(f"Initialized sync polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds") + logging.info( + f"Initialized sync polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" + ) while not self._sync_stop_event.is_set(): - if self._sync_stop_event.wait(timeout=self._config.polling_interval_in_seconds): + if self._sync_stop_event.wait( + timeout=self._config.polling_interval_in_seconds + ): break self._fetch_flag_definitions() def are_flags_ready(self) -> bool: """ - Check if flag definitions have been loaded and are ready for use. - :return: True if flag definitions are populated, False otherwise. + Check if the call to fetch flag definitions has been made successfully. """ - return bool(self._flag_definitions) + return self._are_flags_ready - def get_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: - variant = self.get_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + def get_variant_value( + self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + ) -> Any: + """ + Get the value of a feature flag variant. + + :param str flag_key: The key of the feature flag to evaluate + :param Any fallback_value: The default value to return if the flag is not found or evaluation fails + :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation + """ + variant = self.get_variant( + flag_key, SelectedVariant(variant_value=fallback_value), context + ) return variant.variant_value def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + """ + Check if a feature flag is enabled for the given context. + + :param str flag_key: The key of the feature flag to check + :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation + """ variant_value = self.get_variant_value(flag_key, False, context) return bool(variant_value) - def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + def get_variant( + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + ) -> SelectedVariant: + """ + Gets the selected variant for a feature flag + + :param str flag_key: The key of the feature flag to evaluate + :param SelectedVariant fallback_value: The default variant to return if evaluation fails + :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation + """ start_time = time.perf_counter() flag_definition = self._flag_definitions.get(flag_key) @@ -115,23 +187,35 @@ def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: D logger.warning(f"Cannot find flag definition for key: '{flag_key}'") return fallback_value - if not(context_value := context.get(flag_definition.context)): - logger.warning(f"The rollout context, '{flag_definition.context}' for flag, '{flag_key}' is not present in the supplied context dictionary") + if not (context_value := context.get(flag_definition.context)): + logger.warning( + f"The rollout context, '{flag_definition.context}' for flag, '{flag_key}' is not present in the supplied context dictionary" + ) return fallback_value - if test_user_variant := self._get_variant_override_for_test_user(flag_definition, context): + if test_user_variant := self._get_variant_override_for_test_user( + flag_definition, context + ): return test_user_variant - if rollout := self._get_assigned_rollout(flag_definition, context_value, context): - variant = self._get_assigned_variant(flag_definition, context_value, flag_key, rollout) + if rollout := self._get_assigned_rollout( + flag_definition, context_value, context + ): + variant = self._get_assigned_variant( + flag_definition, context_value, flag_key, rollout + ) end_time = time.perf_counter() - self.track_exposure(flag_key, variant, end_time - start_time, context) + self._track_exposure(flag_key, variant, end_time - start_time, context) return variant - logger.info(f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}") + logger.info( + f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}" + ) return fallback_value - def _get_variant_override_for_test_user(self, flag_definition: ExperimentationFlag, context: Dict[str, Any]) -> Optional[SelectedVariant]: + def _get_variant_override_for_test_user( + self, flag_definition: ExperimentationFlag, context: Dict[str, Any] + ) -> Optional[SelectedVariant]: """""" if not flag_definition.ruleset.test or not flag_definition.ruleset.test.users: return None @@ -144,9 +228,17 @@ def _get_variant_override_for_test_user(self, flag_definition: ExperimentationFl return self._get_matching_variant(variant_key, flag_definition) - def _get_assigned_variant(self, flag_definition: ExperimentationFlag, context_value: Any, flag_name: str, rollout: Rollout) -> SelectedVariant: + def _get_assigned_variant( + self, + flag_definition: ExperimentationFlag, + context_value: Any, + flag_name: str, + rollout: Rollout, + ) -> SelectedVariant: if rollout.variant_override: - if variant := self._get_matching_variant(rollout.variant_override.key, flag_definition): + if variant := self._get_matching_variant( + rollout.variant_override.key, flag_definition + ): return variant variants = flag_definition.ruleset.variants @@ -165,18 +257,28 @@ def _get_assigned_variant(self, flag_definition: ExperimentationFlag, context_va return SelectedVariant(variant_key=selected.key, variant_value=selected.value) - def _get_assigned_rollout(self, flag_definition: ExperimentationFlag, context_value: Any, context: Dict[str, Any]) -> Optional[Rollout]: + def _get_assigned_rollout( + self, + flag_definition: ExperimentationFlag, + context_value: Any, + context: Dict[str, Any], + ) -> Optional[Rollout]: hash_input = str(context_value) + flag_definition.key rollout_hash = normalized_hash(hash_input, "rollout") for rollout in flag_definition.ruleset.rollout: - if rollout_hash < rollout.rollout_percentage and self._is_runtime_evaluation_satisfied(rollout, context): + if ( + rollout_hash < rollout.rollout_percentage + and self._is_runtime_evaluation_satisfied(rollout, context) + ): return rollout return None - def _is_runtime_evaluation_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + def _is_runtime_evaluation_satisfied( + self, rollout: Rollout, context: Dict[str, Any] + ) -> bool: if not rollout.runtime_evaluation_definition: return True @@ -196,16 +298,22 @@ def _is_runtime_evaluation_satisfied(self, rollout: Rollout, context: Dict[str, return True - def _get_matching_variant(self, variant_key: str, flag: ExperimentationFlag) -> Optional[SelectedVariant]: + def _get_matching_variant( + self, variant_key: str, flag: ExperimentationFlag + ) -> Optional[SelectedVariant]: for variant in flag.ruleset.variants: if variant_key.casefold() == variant.key.casefold(): - return SelectedVariant(variant_key=variant.key, variant_value=variant.value) + return SelectedVariant( + variant_key=variant.key, variant_value=variant.value + ) return None async def _afetch_flag_definitions(self) -> None: try: start_time = datetime.now() - response = await self._async_client.get(self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params) + response = await self._async_client.get( + self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params + ) end_time = datetime.now() self._handle_response(response, start_time, end_time) except Exception: @@ -214,15 +322,21 @@ async def _afetch_flag_definitions(self) -> None: def _fetch_flag_definitions(self) -> None: try: start_time = datetime.now() - response = self._sync_client.get(self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params) + response = self._sync_client.get( + self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params + ) end_time = datetime.now() self._handle_response(response, start_time, end_time) except Exception: logger.exception("Failed to fetch feature flag definitions") - def _handle_response(self, response: httpx.Response, start_time: datetime, end_time: datetime) -> None: + def _handle_response( + self, response: httpx.Response, start_time: datetime, end_time: datetime + ) -> None: request_duration: timedelta = end_time - start_time - logging.info(f"Request started at '{start_time.isoformat()}', completed at '{end_time.isoformat()}', duration: '{request_duration.total_seconds():.3f}s'") + logging.info( + f"Request started at '{start_time.isoformat()}', completed at '{end_time.isoformat()}', duration: '{request_duration.total_seconds():.3f}s'" + ) response.raise_for_status() @@ -237,21 +351,32 @@ def _handle_response(self, response: httpx.Response, start_time: datetime, end_t logger.exception("Failed to parse flag definitions") self._flag_definitions = flags - logger.info(f"Successfully fetched {len(self._flag_definitions)} flag definitions") - - - def track_exposure(self, flag_key: str, variant: SelectedVariant, latency_in_seconds: float, context: Dict[str, Any]): + self._are_flags_ready = True + logger.debug( + f"Successfully fetched {len(self._flag_definitions)} flag definitions" + ) + + def _track_exposure( + self, + flag_key: str, + variant: SelectedVariant, + latency_in_seconds: float, + context: Dict[str, Any], + ): if distinct_id := context.get("distinct_id"): properties = { - 'Experiment name': flag_key, - 'Variant name': variant.variant_key, - '$experiment_type': 'feature_flag', + "Experiment name": flag_key, + "Variant name": variant.variant_key, + "$experiment_type": "feature_flag", "Flag evaluation mode": "local", - "Variant fetch latency (ms)": latency_in_seconds * 1000 + "Variant fetch latency (ms)": latency_in_seconds * 1000, } - self._executor.submit(self._tracker, distinct_id, EXPOSURE_EVENT, properties) + + self._tracker(distinct_id, EXPOSURE_EVENT, properties) else: - logging.error("Cannot track exposure event without a distinct_id in the context") + logging.error( + "Cannot track exposure event without a distinct_id in the context" + ) async def __aenter__(self): return self diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 1969bb1..5d7f5d9 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -3,26 +3,27 @@ import json import urllib.parse import asyncio -from datetime import datetime +from datetime import datetime from typing import Dict, Any, Callable from asgiref.sync import sync_to_async from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse -from concurrent.futures import ThreadPoolExecutor from .utils import REQUEST_HEADERS, EXPOSURE_EVENT, prepare_common_query_params logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) + class RemoteFeatureFlagsProvider: FLAGS_URL_PATH = "/flags" - def __init__(self, token: str, config: RemoteFlagsConfig, version: str, tracker: Callable) -> None: + def __init__( + self, token: str, config: RemoteFlagsConfig, version: str, tracker: Callable + ) -> None: self._token: str = token self._config: RemoteFlagsConfig = config self._version: str = version self._tracker: Callable = tracker - self._executor: ThreadPoolExecutor = config.custom_executor or ThreadPoolExecutor(max_workers=5) httpx_client_parameters = { "base_url": f"https://{config.api_host}", @@ -31,27 +32,56 @@ def __init__(self, token: str, config: RemoteFlagsConfig, version: str, tracker: "timeout": httpx.Timeout(config.request_timeout_in_seconds), } - self._async_client: httpx.AsyncClient = httpx.AsyncClient(**httpx_client_parameters) + self._async_client: httpx.AsyncClient = httpx.AsyncClient( + **httpx_client_parameters + ) self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) self._request_params_base = prepare_common_query_params(self._token, version) - async def aget_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: - variant = await self.aget_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + async def aget_variant_value( + self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + ) -> Any: + """ + Gets the selected variant value of a feature flag variant for the current user context from remote server. + + :param str flag_key: The key of the feature flag to evaluate + :param Any fallback_value: The default value to return if the flag is not found or evaluation fails + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ + variant = await self.aget_variant( + flag_key, SelectedVariant(variant_value=fallback_value), context + ) return variant.variant_value - async def aget_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + async def aget_variant( + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + ) -> SelectedVariant: + """ + Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server. + + :param str flag_key: The key of the feature flag to evaluate + :param SelectedVariant fallback_value: The default variant to return if evaluation fails + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ try: params = self._prepare_query_params(flag_key, context) start_time = datetime.now() response = await self._async_client.get(self.FLAGS_URL_PATH, params=params) end_time = datetime.now() self._instrument_call(start_time, end_time) - selected_variant, is_fallback = self._handle_response(flag_key, fallback_value, response) + selected_variant, is_fallback = self._handle_response( + flag_key, fallback_value, response + ) if not is_fallback and (distinct_id := context.get("distinct_id")): - properties = self._build_tracking_properties(flag_key, selected_variant, start_time, end_time) + properties = self._build_tracking_properties( + flag_key, selected_variant, start_time, end_time + ) asyncio.create_task( - sync_to_async(self._tracker, executor=self._executor, thread_sensitive=False)(distinct_id, EXPOSURE_EVENT, properties)) + sync_to_async(self._tracker, thread_sensitive=False)( + distinct_id, EXPOSURE_EVENT, properties + ) + ) return selected_variant except Exception: @@ -59,25 +89,55 @@ async def aget_variant(self, flag_key: str, fallback_value: SelectedVariant, con return fallback_value async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + """ + Asynchronously checks if a feature flag is enabled for the given context. + + :param str flag_key: The key of the feature flag to check + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ variant_value = await self.aget_variant_value(flag_key, False, context) return bool(variant_value) - def get_variant_value(self, flag_key: str, fallback_value: Any, context: Dict[str, Any]) -> Any: - variant = self.get_variant(flag_key, SelectedVariant(variant_value=fallback_value), context) + def get_variant_value( + self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + ) -> Any: + """ + Synchronously gets the value of a feature flag variant from remote server. + + :param str flag_key: The key of the feature flag to evaluate + :param Any fallback_value: The default value to return if the flag is not found or evaluation fails + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ + variant = self.get_variant( + flag_key, SelectedVariant(variant_value=fallback_value), context + ) return variant.variant_value - def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any]) -> SelectedVariant: + def get_variant( + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + ) -> SelectedVariant: + """ + Synchronously gets the selected variant for a feature flag from remote server. + + :param str flag_key: The key of the feature flag to evaluate + :param SelectedVariant fallback_value: The default variant to return if evaluation fails + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ try: params = self._prepare_query_params(flag_key, context) start_time = datetime.now() response = self._sync_client.get(self.FLAGS_URL_PATH, params=params) end_time = datetime.now() self._instrument_call(start_time, end_time) - selected_variant, is_fallback = self._handle_response(flag_key, fallback_value, response) + selected_variant, is_fallback = self._handle_response( + flag_key, fallback_value, response + ) if not is_fallback and (distinct_id := context.get("distinct_id")): - properties = self._build_tracking_properties(flag_key, selected_variant, start_time, end_time) - self._executor.submit(self._tracker, distinct_id, EXPOSURE_EVENT, properties) + properties = self._build_tracking_properties( + flag_key, selected_variant, start_time, end_time + ) + self._tracker(distinct_id, EXPOSURE_EVENT, properties) return selected_variant except Exception: @@ -85,41 +145,56 @@ def get_variant(self, flag_key: str, fallback_value: SelectedVariant, context: D return fallback_value def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: + """ + Synchronously checks if a feature flag is enabled for the given context. + + :param str flag_key: The key of the feature flag to check + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + """ variant_value = self.get_variant_value(flag_key, False, context) return bool(variant_value) - def _prepare_query_params(self, flag_key: str, context: Dict[str, Any]) -> Dict[str, str]: + def _prepare_query_params( + self, flag_key: str, context: Dict[str, Any] + ) -> Dict[str, str]: params = self._request_params_base.copy() - context_json = json.dumps(context).encode('utf-8') + context_json = json.dumps(context).encode("utf-8") url_encoded_context = urllib.parse.quote(context_json) - params.update({ - 'flag_key': flag_key, - 'context': url_encoded_context - }) + params.update({"flag_key": flag_key, "context": url_encoded_context}) return params def _instrument_call(self, start_time: datetime, end_time: datetime) -> None: request_duration = end_time - start_time formatted_start_time = start_time.isoformat() formatted_end_time = end_time.isoformat() - logging.info(f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'") - - def _build_tracking_properties(self, flag_key: str, variant: SelectedVariant, start_time: datetime, end_time: datetime) -> Dict[str, Any]: + logging.info( + f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'" + ) + + def _build_tracking_properties( + self, + flag_key: str, + variant: SelectedVariant, + start_time: datetime, + end_time: datetime, + ) -> Dict[str, Any]: request_duration = end_time - start_time formatted_start_time = start_time.isoformat() formatted_end_time = end_time.isoformat() return { - 'Experiment name': flag_key, - 'Variant name': variant.variant_key, - '$experiment_type': 'feature_flag', + "Experiment name": flag_key, + "Variant name": variant.variant_key, + "$experiment_type": "feature_flag", "Flag evaluation mode": "remote", "Variant fetch start time": formatted_start_time, "Variant fetch complete time": formatted_end_time, "Variant fetch latency (ms)": request_duration.total_seconds() * 1000, } - def _handle_response(self, flag_key: str, fallback_value: SelectedVariant, response: httpx.Response) -> tuple[SelectedVariant, bool]: + def _handle_response( + self, flag_key: str, fallback_value: SelectedVariant, response: httpx.Response + ) -> tuple[SelectedVariant, bool]: response.raise_for_status() flags_response = RemoteFlagsResponse.model_validate(response.json()) @@ -127,7 +202,9 @@ def _handle_response(self, flag_key: str, fallback_value: SelectedVariant, respo if flag_key in flags_response.flags: return flags_response.flags[flag_key], False else: - logging.warning(f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'") + logging.warning( + f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'" + ) return fallback_value, True def __enter__(self): diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 63a437f..9019a01 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -60,17 +60,25 @@ def create_flags_response(flags: List[ExperimentationFlag]) -> httpx.Response: @pytest.mark.asyncio class TestLocalFeatureFlagsProviderAsync: - async def get_flags_provider(self, config: LocalFlagsConfig) -> LocalFeatureFlagsProvider: - mock_tracker = Mock() - flags_provider = LocalFeatureFlagsProvider("test-token", config, "1.0.0", mock_tracker) - await flags_provider.astart_polling_for_definitions() - return flags_provider + @pytest.fixture(autouse=True) + async def setup_method(self): + self._mock_tracker = Mock() + + config_no_polling = LocalFlagsConfig(enable_polling=False) + self._flags = LocalFeatureFlagsProvider("test-token", config_no_polling, "1.0.0", self._mock_tracker) + + config_with_polling = LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0) + self._flags_with_polling = LocalFeatureFlagsProvider("test-token", config_with_polling, "1.0.0", self._mock_tracker) + + yield + + await self._flags.__aexit__(None, None, None) + await self._flags_with_polling.__aexit__(None, None, None) async def setup_flags(self, flags: List[ExperimentationFlag]): respx.get("https://api.mixpanel.com/flags/definitions").mock( return_value=create_flags_response(flags)) - - return await self.get_flags_provider(LocalFlagsConfig(enable_polling=False)) + await self._flags.astart_polling_for_definitions() async def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): responses = [create_flags_response(flag) for flag in flags_in_order] @@ -81,14 +89,13 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati repeat(responses[-1]), ) ) - - return await self.get_flags_provider(LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0)) + await self._flags_with_polling.astart_polling_for_definitions() @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): - flags = await self.setup_flags([]) - result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + await self.setup_flags([]) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock @@ -97,29 +104,29 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( return_value=httpx.Response(status_code=500) ) - flags = await self.get_flags_provider(LocalFlagsConfig(enable_polling=False)) - result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + await self._flags.astart_polling_for_definitions() + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") - flags = await self.setup_flags([other_flag]) - result = flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + await self.setup_flags([other_flag]) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_no_context(self): flag = create_test_flag(context="distinct_id") - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "fallback", {}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock @@ -133,8 +140,8 @@ async def test_get_variant_value_returns_test_user_variant_when_configured(self) test_users={"test_user": "treatment"} ) - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) assert result == "true" @respx.mock @@ -147,31 +154,31 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con variants=variants, test_users={"test_user": "nonexistent_variant"} ) - flags = await self.setup_flags([flag]) + await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) assert result == "false" @respx.mock async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result != "fallback" @respx.mock async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation=runtime_eval) - flags = await self.setup_flags([flag]) + await self.setup_flags([flag]) context = { "distinct_id": "user123", "custom_properties": { @@ -179,14 +186,14 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): "region": "US" } } - result = flags.get_variant_value("test_flag", "fallback", context) + result = self._flags.get_variant_value("test_flag", "fallback", context) assert result != "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation=runtime_eval) - flags = await self.setup_flags([flag]) + await self.setup_flags([flag]) context = { "distinct_id": "user123", "custom_properties": { @@ -194,7 +201,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa "region": "US" } } - result = flags.get_variant_value("test_flag", "fallback", context) + result = self._flags.get_variant_value("test_flag", "fallback", context) assert result == "fallback" @respx.mock @@ -205,8 +212,8 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli Variant(key="C", value="variant_c", is_control=False, split=0.0) ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "variant_a" @respx.mock @@ -216,45 +223,49 @@ async def test_get_variant_value_picks_overriden_variant(self): Variant(key="B", value="variant_b", is_control=False, split=0.0), ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) - flags = await self.setup_flags([flag]) - result = flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock async def test_get_variant_value_tracks_exposure_when_variant_selected(self): flag = create_test_flag() - flags = await self.setup_flags([flag]) + await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) - flags._executor.shutdown() - flags._tracker.assert_called_once() + _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + self._mock_tracker.assert_called_once() @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): - flags = await self.setup_flags([]) - _ = flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) - flags._executor.shutdown() - flags._tracker.assert_not_called() + await self.setup_flags([]) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) + self._mock_tracker.assert_not_called() @respx.mock async def test_get_variant_value_does_not_track_exposure_without_distinct_id(self): flag = create_test_flag(context="company") - flags = await self.setup_flags([flag]) - _ = flags.get_variant_value("nonexistent_flag", "fallback", {"company_id": "company123"}) - flags._executor.shutdown() - flags._tracker.assert_not_called() + await self.setup_flags([flag]) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"company_id": "company123"}) + self._mock_tracker.assert_not_called() @respx.mock async def test_are_flags_ready_returns_true_when_flags_loaded(self): flag = create_test_flag() - flags = await self.setup_flags([flag]) - assert flags.are_flags_ready() == True + await self.setup_flags([flag]) + assert self._flags.are_flags_ready() == True + + @respx.mock + async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): + flag = create_test_flag() + await self.setup_flags([]) + assert self._flags.are_flags_ready() == True + @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): - flags = await self.setup_flags([]) - result = flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) + await self.setup_flags([]) + result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) assert result == False @respx.mock @@ -263,8 +274,8 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): Variant(key="treatment", value=True, is_control=False, split=100.0) ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) - flags = await self.setup_flags([flag]) - result = flags.is_enabled("test_flag", {"distinct_id": "user123"}) + await self.setup_flags([flag]) + result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) assert result == True @respx.mock @@ -285,16 +296,22 @@ async def track_fetch_calls(self): flag_v2 = create_test_flag(rollout_percentage=100.0) flags_in_order=[[flag_v1], [flag_v2]] - flags = await self.setup_flags_with_polling(flags_in_order) + await self.setup_flags_with_polling(flags_in_order) async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" - await flags.astop_polling_for_definitions() - class TestLocalFeatureFlagsProviderSync: + def setup_method(self): + self.mock_tracker = Mock() + config_with_polling = LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0) + self._flags_with_polling = LocalFeatureFlagsProvider("test-token", config_with_polling, "1.0.0", self.mock_tracker) + + def teardown_method(self): + self._flags_with_polling.__exit__(None, None, None) + def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): responses = [create_flags_response(flag) for flag in flags_in_order] @@ -305,13 +322,7 @@ def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag ) ) - return self.get_flags_provider(LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0)) - - def get_flags_provider(self, config: LocalFlagsConfig) -> LocalFeatureFlagsProvider: - mock_tracker = Mock() - flags_provider = LocalFeatureFlagsProvider("test-token", config, "1.0.0", mock_tracker) - flags_provider.start_polling_for_definitions() - return flags_provider + self._flags_with_polling.start_polling_for_definitions() @respx.mock def test_get_variant_value_uses_most_recent_polled_flag(self): @@ -332,9 +343,8 @@ def track_fetch_calls(self): return original_fetch(self) with patch.object(LocalFeatureFlagsProvider, '_fetch_flag_definitions', track_fetch_calls): - flags = self.setup_flags_with_polling(flags_in_order) + self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) - flags.stop_polling_for_definitions() assert (polling_iterations >= 3 ) - result2 = flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index e7fff93..def080c 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -64,14 +64,12 @@ async def test_get_variant_value_tracks_exposure_event_if_variant_selected(self) if pending: await asyncio.gather(*pending, return_exceptions=True) - self._flags._executor.shutdown() self.mock_tracker.assert_called_once() @respx.mock async def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) - self._flags._executor.shutdown() self.mock_tracker.assert_not_called() @respx.mock @@ -135,14 +133,12 @@ def test_get_variant_value_tracks_exposure_event_if_variant_selected(self): return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) - self._flags._executor.shutdown() self.mock_tracker.assert_called_once() @respx.mock def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) - self._flags._executor.shutdown() self.mock_tracker.assert_not_called() @respx.mock diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index b71eec6..186371d 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -1,5 +1,4 @@ from typing import Optional, List, Dict, Any -from concurrent.futures import ThreadPoolExecutor from pydantic import BaseModel, ConfigDict MIXPANEL_DEFAULT_API_ENDPOINT = "api.mixpanel.com" @@ -9,7 +8,6 @@ class FlagsConfig(BaseModel): api_host: str = "api.mixpanel.com" request_timeout_in_seconds: int = 10 - custom_executor: Optional[ThreadPoolExecutor] = None class LocalFlagsConfig(FlagsConfig): enable_polling: bool = True From c801c77283eb6ee3e4105d6ccabbfd8b4992a62b Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:03:33 -0700 Subject: [PATCH 173/208] Updates to flag providers (#146) * Updates to flag providers * Add additional updates * Add tests * Update test utils * Update test utils * default split --- mixpanel/flags/local_feature_flags.py | 51 ++++++++++----- mixpanel/flags/remote_feature_flags.py | 12 ++-- mixpanel/flags/test_local_feature_flags.py | 76 +++++++++++++++++++++- mixpanel/flags/test_utils.py | 23 +++++++ mixpanel/flags/types.py | 14 +++- mixpanel/flags/utils.py | 18 ++++- 6 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 mixpanel/flags/test_utils.py diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 6d95334..4b70132 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -17,6 +17,7 @@ normalized_hash, prepare_common_query_params, EXPOSURE_EVENT, + generate_traceparent ) logger = logging.getLogger(__name__) @@ -168,10 +169,10 @@ def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation """ variant_value = self.get_variant_value(flag_key, False, context) - return bool(variant_value) + return variant_value == True def get_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True ) -> SelectedVariant: """ Gets the selected variant for a feature flag @@ -179,6 +180,7 @@ def get_variant( :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation + :param bool report_exposure: Whether to track an exposure event for this flag evaluation. Defaults to True. """ start_time = time.perf_counter() flag_definition = self._flag_definitions.get(flag_key) @@ -193,20 +195,21 @@ def get_variant( ) return fallback_value + selected_variant: Optional[SelectedVariant] = None + if test_user_variant := self._get_variant_override_for_test_user( flag_definition, context ): - return test_user_variant - - if rollout := self._get_assigned_rollout( - flag_definition, context_value, context - ): - variant = self._get_assigned_variant( + selected_variant = test_user_variant + elif rollout := self._get_assigned_rollout(flag_definition, context_value, context): + selected_variant = self._get_assigned_variant( flag_definition, context_value, flag_key, rollout ) + + if report_exposure and selected_variant is not None: end_time = time.perf_counter() - self._track_exposure(flag_key, variant, end_time - start_time, context) - return variant + self._track_exposure(flag_key, selected_variant, end_time - start_time, context) + return selected_variant logger.info( f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}" @@ -241,12 +244,17 @@ def _get_assigned_variant( ): return variant - variants = flag_definition.ruleset.variants hash_input = str(context_value) + flag_name variant_hash = normalized_hash(hash_input, "variant") + variants = [variant.model_copy(deep=True) for variant in flag_definition.ruleset.variants] + if rollout.variant_splits: + for variant in variants: + if variant.key in rollout.variant_splits: + variant.split = rollout.variant_splits[variant.key] + selected = variants[0] cumulative = 0.0 for variant in variants: @@ -255,7 +263,11 @@ def _get_assigned_variant( if variant_hash < cumulative: break - return SelectedVariant(variant_key=selected.key, variant_value=selected.value) + return SelectedVariant( + variant_key=selected.key, + variant_value=selected.value, + experiment_id=flag_definition.experiment_id, + is_experiment_active=flag_definition.is_experiment_active) def _get_assigned_rollout( self, @@ -304,15 +316,20 @@ def _get_matching_variant( for variant in flag.ruleset.variants: if variant_key.casefold() == variant.key.casefold(): return SelectedVariant( - variant_key=variant.key, variant_value=variant.value + variant_key=variant.key, + variant_value=variant.value, + experiment_id=flag.experiment_id, + is_experiment_active=flag.is_experiment_active, + is_qa_tester=True, ) return None async def _afetch_flag_definitions(self) -> None: try: start_time = datetime.now() + headers = {"traceparent": generate_traceparent()} response = await self._async_client.get( - self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params + self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers ) end_time = datetime.now() self._handle_response(response, start_time, end_time) @@ -322,8 +339,9 @@ async def _afetch_flag_definitions(self) -> None: def _fetch_flag_definitions(self) -> None: try: start_time = datetime.now() + headers = {"traceparent": generate_traceparent()} response = self._sync_client.get( - self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params + self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers ) end_time = datetime.now() self._handle_response(response, start_time, end_time) @@ -370,6 +388,9 @@ def _track_exposure( "$experiment_type": "feature_flag", "Flag evaluation mode": "local", "Variant fetch latency (ms)": latency_in_seconds * 1000, + "$experiment_id": variant.experiment_id, + "$is_experiment_active": variant.is_experiment_active, + "$is_qa_tester": variant.is_qa_tester, } self._tracker(distinct_id, EXPOSURE_EVENT, properties) diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 5d7f5d9..af62c74 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -8,7 +8,7 @@ from asgiref.sync import sync_to_async from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse -from .utils import REQUEST_HEADERS, EXPOSURE_EVENT, prepare_common_query_params +from .utils import REQUEST_HEADERS, EXPOSURE_EVENT, prepare_common_query_params, generate_traceparent logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) @@ -66,7 +66,8 @@ async def aget_variant( try: params = self._prepare_query_params(flag_key, context) start_time = datetime.now() - response = await self._async_client.get(self.FLAGS_URL_PATH, params=params) + headers = {"traceparent": generate_traceparent()} + response = await self._async_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) end_time = datetime.now() self._instrument_call(start_time, end_time) selected_variant, is_fallback = self._handle_response( @@ -96,7 +97,7 @@ async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context """ variant_value = await self.aget_variant_value(flag_key, False, context) - return bool(variant_value) + return variant_value == True def get_variant_value( self, flag_key: str, fallback_value: Any, context: Dict[str, Any] @@ -126,7 +127,8 @@ def get_variant( try: params = self._prepare_query_params(flag_key, context) start_time = datetime.now() - response = self._sync_client.get(self.FLAGS_URL_PATH, params=params) + headers = {"traceparent": generate_traceparent()} + response = self._sync_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) end_time = datetime.now() self._instrument_call(start_time, end_time) selected_variant, is_fallback = self._handle_response( @@ -152,7 +154,7 @@ def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context """ variant_value = self.get_variant_value(flag_key, False, context) - return bool(variant_value) + return variant_value == True def _prepare_query_params( self, flag_key: str, context: Dict[str, Any] diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 9019a01..dba1d20 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -9,6 +9,7 @@ from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride from .local_feature_flags import LocalFeatureFlagsProvider + def create_test_flag( flag_key: str = "test_flag", context: str = "distinct_id", @@ -16,7 +17,10 @@ def create_test_flag( variant_override: Optional[VariantOverride] = None, rollout_percentage: float = 100.0, runtime_evaluation: Optional[Dict] = None, - test_users: Optional[Dict[str, str]] = None) -> ExperimentationFlag: + test_users: Optional[Dict[str, str]] = None, + experiment_id: Optional[str] = None, + is_experiment_active: Optional[bool] = None, + variant_splits: Optional[Dict[str, float]] = None) -> ExperimentationFlag: if variants is None: variants = [ @@ -27,7 +31,8 @@ def create_test_flag( rollouts = [Rollout( rollout_percentage=rollout_percentage, runtime_evaluation_definition=runtime_evaluation, - variant_override=variant_override + variant_override=variant_override, + variant_splits=variant_splits )] test_config = None @@ -47,7 +52,9 @@ def create_test_flag( status="active", project_id=123, ruleset=ruleset, - context=context + context=context, + experiment_id=experiment_id, + is_experiment_active=is_experiment_active ) @@ -216,6 +223,32 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "variant_a" + @respx.mock + async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_splits(self): + variants = [ + Variant(key="A", value="variant_a", is_control=False, split=100.0), + Variant(key="B", value="variant_b", is_control=False, split=0.0), + Variant(key="C", value="variant_c", is_control=False, split=0.0) + ] + variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} + flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result == "variant_b" + + @respx.mock + async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_splits(self): + variants = [ + Variant(key="A", value="variant_a", is_control=False), + Variant(key="B", value="variant_b", is_control=False), + Variant(key="C", value="variant_c", is_control=False), + ] + variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} + flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) + await self.setup_flags([flag]) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + assert result == "variant_c" + @respx.mock async def test_get_variant_value_picks_overriden_variant(self): variants = [ @@ -236,6 +269,43 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() + @respx.mock + @pytest.mark.parametrize("experiment_id,is_experiment_active,use_qa_user", [ + ("exp-123", True, True), # QA tester with active experiment + ("exp-456", False, True), # QA tester with inactive experiment + ("exp-789", True, False), # Regular user with active experiment + ("exp-000", False, False), # Regular user with inactive experiment + (None, None, True), # QA tester with no experiment + (None, None, False), # Regular user with no experiment + ]) + async def test_get_variant_value_tracks_exposure_with_correct_properties(self, experiment_id, is_experiment_active, use_qa_user): + flag = create_test_flag( + experiment_id=experiment_id, + is_experiment_active=is_experiment_active, + test_users={"qa_user": "treatment"} + ) + + await self.setup_flags([flag]) + + distinct_id = "qa_user" if use_qa_user else "regular_user" + + with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + mock_hash.return_value = 0.5 + _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": distinct_id}) + + self._mock_tracker.assert_called_once() + + call_args = self._mock_tracker.call_args + properties = call_args[0][2] + + assert properties["$experiment_id"] == experiment_id + assert properties["$is_experiment_active"] == is_experiment_active + + if use_qa_user: + assert properties["$is_qa_tester"] == True + else: + assert properties.get("$is_qa_tester") is None + @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py new file mode 100644 index 0000000..b60b514 --- /dev/null +++ b/mixpanel/flags/test_utils.py @@ -0,0 +1,23 @@ +import re +import pytest +import random +import string +from .utils import generate_traceparent, normalized_hash + +class TestUtils: + def test_traceparent_format_is_correct(self): + traceparent = generate_traceparent() + + # W3C traceparent format: 00-{32 hex chars}-{16 hex chars}-{2 hex chars} + # https://www.w3.org/TR/trace-context/#traceparent-header + pattern = r'^00-[0-9a-f]{32}-[0-9a-f]{16}-01$' + + assert re.match(pattern, traceparent), f"Traceparent '{traceparent}' does not match W3C format" + + @pytest.mark.parametrize("key,salt,expected_hash", [ + ("abc", "variant", 0.72), + ("def", "variant", 0.21), + ]) + def test_normalized_hash_for_known_inputs(self, key, salt, expected_hash): + result = normalized_hash(key, salt) + assert result == expected_hash, f"Expected hash of {expected_hash} for '{key}' with salt '{salt}', got {result}" \ No newline at end of file diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 186371d..20fe6ad 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -20,7 +20,7 @@ class Variant(BaseModel): key: str value: Any is_control: bool - split: float + split: Optional[float] = 0.0 class FlagTestUsers(BaseModel): users: Dict[str, str] @@ -32,6 +32,7 @@ class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None variant_override: Optional[VariantOverride] = None + variant_splits: Optional[Dict[str,float]] = None class RuleSet(BaseModel): variants: List[Variant] @@ -41,16 +42,23 @@ class RuleSet(BaseModel): class ExperimentationFlag(BaseModel): id: str name: str - key: str + key: str status: str project_id: int - ruleset: RuleSet + ruleset: RuleSet context: str + experiment_id: Optional[str] = None + is_experiment_active: Optional[bool] = None + class SelectedVariant(BaseModel): # variant_key can be None if being used as a fallback variant_key: Optional[str] = None variant_value: Any + experiment_id: Optional[str] = None + is_experiment_active: Optional[bool] = None + is_qa_tester: Optional[bool] = None + class ExperimentationFlags(BaseModel): flags: List[ExperimentationFlag] diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 987392b..863a705 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -1,3 +1,5 @@ +import uuid +import httpx from typing import Dict EXPOSURE_EVENT = "$experiment_started" @@ -47,4 +49,18 @@ def prepare_common_query_params(token: str, sdk_version: str) -> Dict[str, str]: 'token': token } - return params \ No newline at end of file + return params + +def generate_traceparent() -> str: + """Generates a W3C traceparent header for easy interop with distributed tracing systems i.e Open Telemetry + https://www.w3.org/TR/trace-context/#traceparent-header + :return: A traceparent string + """ + trace_id = uuid.uuid4().hex + span_id = uuid.uuid4().hex[:16] + + # Trace flags: '01' for sampled + trace_flags = '01' + + traceparent = f"00-{trace_id}-{span_id}-{trace_flags}" + return traceparent \ No newline at end of file From 7a860044bfa1d3b108af6b5d5b883f868e2f9497 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:34:38 -0800 Subject: [PATCH 174/208] Updated salt support and convenience methods for flag providers (#147) * Updated salt support and convenience methods for flag providers * update salt * fixing copilot comments * update logger --- mixpanel/__init__.py | 2 +- mixpanel/flags/local_feature_flags.py | 84 ++++++---- mixpanel/flags/remote_feature_flags.py | 163 ++++++++++++++++---- mixpanel/flags/test_local_feature_flags.py | 57 ++++++- mixpanel/flags/test_remote_feature_flags.py | 100 ++++++++++++ mixpanel/flags/types.py | 1 + 6 files changed, 342 insertions(+), 65 deletions(-) diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index decc461..abc9aca 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -30,7 +30,7 @@ from .flags.remote_feature_flags import RemoteFeatureFlagsProvider from .flags.types import LocalFlagsConfig, RemoteFlagsConfig -__version__ = '5.0.0b2' +__version__ = '5.0.0' logger = logging.getLogger(__name__) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 4b70132..5bc441e 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -79,7 +79,7 @@ def start_polling_for_definitions(self): ) self._sync_polling_task.start() else: - logging.warning("A polling task is already running") + logger.warning("A polling task is already running") def stop_polling_for_definitions(self): """ @@ -90,7 +90,7 @@ def stop_polling_for_definitions(self): self._sync_stop_event.set() self._sync_polling_task = None else: - logging.info("There is no polling task to cancel.") + logger.info("There is no polling task to cancel.") async def astart_polling_for_definitions(self): """ @@ -105,7 +105,7 @@ async def astart_polling_for_definitions(self): self._astart_continuous_polling() ) else: - logging.error("A polling task is already running") + logger.error("A polling task is already running") async def astop_polling_for_definitions(self): """ @@ -115,10 +115,10 @@ async def astop_polling_for_definitions(self): self._async_polling_task.cancel() self._async_polling_task = None else: - logging.info("There is no polling task to cancel.") + logger.info("There is no polling task to cancel.") async def _astart_continuous_polling(self): - logging.info( + logger.info( f"Initialized async polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" ) try: @@ -126,10 +126,10 @@ async def _astart_continuous_polling(self): await asyncio.sleep(self._config.polling_interval_in_seconds) await self._afetch_flag_definitions() except asyncio.CancelledError: - logging.info("Async polling was cancelled") + logger.info("Async polling was cancelled") def _start_continuous_polling(self): - logging.info( + logger.info( f"Initialized sync polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" ) while not self._sync_stop_event.is_set(): @@ -146,6 +146,22 @@ def are_flags_ready(self) -> bool: """ return self._are_flags_ready + def get_all_variants(self, context: Dict[str, Any]) -> Dict[str, SelectedVariant]: + """ + Gets the selected variant for all feature flags that the current user context is in the rollout for. + Exposure events are not automatically tracked when this method is used. + :param Dict[str, Any] context: The user context to evaluate against the feature flags + """ + variants: Dict[str, SelectedVariant] = {} + fallback = SelectedVariant(variant_key=None, variant_value=None) + + for flag_key in self._flag_definitions.keys(): + variant = self.get_variant(flag_key, fallback, context, report_exposure=False) + if variant.variant_key is not None: + variants[flag_key] = variant + + return variants + def get_variant_value( self, flag_key: str, fallback_value: Any, context: Dict[str, Any] ) -> Any: @@ -206,16 +222,28 @@ def get_variant( flag_definition, context_value, flag_key, rollout ) - if report_exposure and selected_variant is not None: - end_time = time.perf_counter() - self._track_exposure(flag_key, selected_variant, end_time - start_time, context) + if selected_variant is not None: + if report_exposure: + end_time = time.perf_counter() + self._track_exposure(flag_key, selected_variant, context, end_time - start_time) return selected_variant - logger.info( + logger.debug( f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}" ) return fallback_value + def track_exposure_event(self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any]): + """ + Manually tracks a feature flagging exposure event to Mixpanel. + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + + :param str flag_key: The key of the feature flag + :param SelectedVariant variant: The selected variant for the feature flag + :param Dict[str, Any] context: The user context used to evaluate the feature flag + """ + self._track_exposure(flag_key, variant, context) + def _get_variant_override_for_test_user( self, flag_definition: ExperimentationFlag, context: Dict[str, Any] ) -> Optional[SelectedVariant]: @@ -244,10 +272,9 @@ def _get_assigned_variant( ): return variant - - hash_input = str(context_value) + flag_name - - variant_hash = normalized_hash(hash_input, "variant") + stored_salt = flag_definition.hash_salt if flag_definition.hash_salt is not None else "" + salt = flag_name + stored_salt + "variant" + variant_hash = normalized_hash(str(context_value), salt) variants = [variant.model_copy(deep=True) for variant in flag_definition.ruleset.variants] if rollout.variant_splits: @@ -275,13 +302,16 @@ def _get_assigned_rollout( context_value: Any, context: Dict[str, Any], ) -> Optional[Rollout]: - hash_input = str(context_value) + flag_definition.key + for index, rollout in enumerate(flag_definition.ruleset.rollout): + salt = None + if flag_definition.hash_salt is not None: + salt = flag_definition.key + flag_definition.hash_salt + str(index) + else: + salt = flag_definition.key + "rollout" - rollout_hash = normalized_hash(hash_input, "rollout") + rollout_hash = normalized_hash(str(context_value), salt) - for rollout in flag_definition.ruleset.rollout: - if ( - rollout_hash < rollout.rollout_percentage + if (rollout_hash < rollout.rollout_percentage and self._is_runtime_evaluation_satisfied(rollout, context) ): return rollout @@ -352,7 +382,7 @@ def _handle_response( self, response: httpx.Response, start_time: datetime, end_time: datetime ) -> None: request_duration: timedelta = end_time - start_time - logging.info( + logger.debug( f"Request started at '{start_time.isoformat()}', completed at '{end_time.isoformat()}', duration: '{request_duration.total_seconds():.3f}s'" ) @@ -378,8 +408,8 @@ def _track_exposure( self, flag_key: str, variant: SelectedVariant, - latency_in_seconds: float, context: Dict[str, Any], + latency_in_seconds: Optional[float]=None, ): if distinct_id := context.get("distinct_id"): properties = { @@ -387,15 +417,17 @@ def _track_exposure( "Variant name": variant.variant_key, "$experiment_type": "feature_flag", "Flag evaluation mode": "local", - "Variant fetch latency (ms)": latency_in_seconds * 1000, "$experiment_id": variant.experiment_id, "$is_experiment_active": variant.is_experiment_active, "$is_qa_tester": variant.is_qa_tester, } + if latency_in_seconds is not None: + properties["Variant fetch latency (ms)"] = latency_in_seconds * 1000 + self._tracker(distinct_id, EXPOSURE_EVENT, properties) else: - logging.error( + logger.error( "Cannot track exposure event without a distinct_id in the context" ) @@ -406,11 +438,11 @@ def __enter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): - logging.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") + logger.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") await self.astop_polling_for_definitions() await self._async_client.aclose() def __exit__(self, exc_type, exc_val, exc_tb): - logging.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") + logger.info("Exiting the LocalFeatureFlagsProvider and cleaning up resources") self.stop_polling_for_definitions() self._sync_client.close() diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index af62c74..8d265ae 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -4,7 +4,7 @@ import urllib.parse import asyncio from datetime import datetime -from typing import Dict, Any, Callable +from typing import Dict, Any, Callable, Tuple, Optional from asgiref.sync import sync_to_async from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse @@ -38,6 +38,26 @@ def __init__( self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) self._request_params_base = prepare_common_query_params(self._token, version) + async def aget_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, SelectedVariant]]: + """ + Asynchronously gets all feature flag variants for the current user context from remote server. + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + :return: A dictionary mapping flag keys to their selected variants, or None if the call fails + """ + flags: Optional[Dict[str, SelectedVariant]] = None + try: + params = self._prepare_query_params(context) + start_time = datetime.now() + headers = {"traceparent": generate_traceparent()} + response = await self._async_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + end_time = datetime.now() + self._instrument_call(start_time, end_time) + flags = self._handle_response(response) + except Exception: + logger.exception(f"Failed to get remote variants") + + return flags + async def aget_variant_value( self, flag_key: str, fallback_value: Any, context: Dict[str, Any] ) -> Any: @@ -54,7 +74,7 @@ async def aget_variant_value( return variant.variant_value async def aget_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True ) -> SelectedVariant: """ Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server. @@ -62,19 +82,19 @@ async def aget_variant( :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + :param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved """ try: - params = self._prepare_query_params(flag_key, context) + params = self._prepare_query_params(context, flag_key) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} response = await self._async_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) end_time = datetime.now() self._instrument_call(start_time, end_time) - selected_variant, is_fallback = self._handle_response( - flag_key, fallback_value, response - ) + flags = self._handle_response(response) + selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value) - if not is_fallback and (distinct_id := context.get("distinct_id")): + if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -86,7 +106,7 @@ async def aget_variant( return selected_variant except Exception: - logging.exception(f"Failed to get remote variant for flag '{flag_key}'") + logger.exception(f"Failed to get remote variant for flag '{flag_key}'") return fallback_value async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: @@ -99,6 +119,51 @@ async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: variant_value = await self.aget_variant_value(flag_key, False, context) return variant_value == True + async def atrack_exposure_event( + self, + flag_key: str, + variant: SelectedVariant, + context: Dict[str, Any]): + """ + Manually tracks a feature flagging exposure event asynchronously to Mixpanel. + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + + :param str flag_key: The key of the feature flag + :param SelectedVariant variant: The selected variant for the feature flag + :param Dict[str, Any] context: The user context used to evaluate the feature flag + """ + if (distinct_id := context.get("distinct_id")): + properties = self._build_tracking_properties(flag_key, variant) + + await sync_to_async(self._tracker, thread_sensitive=False)( + distinct_id, EXPOSURE_EVENT, properties + ) + else: + logger.error( + "Cannot track exposure event without a distinct_id in the context" + ) + + + def get_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, SelectedVariant]]: + """ + Synchronously gets all feature flag variants for the current user context from remote server. + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + :return: A dictionary mapping flag keys to their selected variants, or None if the call fails + """ + flags: Optional[Dict[str, SelectedVariant]] = None + try: + params = self._prepare_query_params(context) + start_time = datetime.now() + headers = {"traceparent": generate_traceparent()} + response = self._sync_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + end_time = datetime.now() + self._instrument_call(start_time, end_time) + flags = self._handle_response(response) + except Exception: + logger.exception(f"Failed to get remote variants") + + return flags + def get_variant_value( self, flag_key: str, fallback_value: Any, context: Dict[str, Any] ) -> Any: @@ -115,7 +180,7 @@ def get_variant_value( return variant.variant_value def get_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True ) -> SelectedVariant: """ Synchronously gets the selected variant for a feature flag from remote server. @@ -123,19 +188,20 @@ def get_variant( :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context + :param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved """ try: - params = self._prepare_query_params(flag_key, context) + params = self._prepare_query_params(context, flag_key) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} response = self._sync_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) end_time = datetime.now() self._instrument_call(start_time, end_time) - selected_variant, is_fallback = self._handle_response( - flag_key, fallback_value, response - ) - if not is_fallback and (distinct_id := context.get("distinct_id")): + flags = self._handle_response(response) + selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value) + + if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -156,20 +222,43 @@ def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: variant_value = self.get_variant_value(flag_key, False, context) return variant_value == True + def track_exposure_event( + self, + flag_key: str, + variant: SelectedVariant, + context: Dict[str, Any]): + """ + Manually tracks a feature flagging exposure event synchronously to Mixpanel. + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + + :param str flag_key: The key of the feature flag + :param SelectedVariant variant: The selected variant for the feature flag + :param Dict[str, Any] context: The user context used to evaluate the feature flag + """ + if (distinct_id := context.get("distinct_id")): + properties = self._build_tracking_properties(flag_key, variant) + self._tracker(distinct_id, EXPOSURE_EVENT, properties) + else: + logging.error( + "Cannot track exposure event without a distinct_id in the context" + ) + def _prepare_query_params( - self, flag_key: str, context: Dict[str, Any] + self, context: Dict[str, Any], flag_key: Optional[str] = None ) -> Dict[str, str]: params = self._request_params_base.copy() context_json = json.dumps(context).encode("utf-8") url_encoded_context = urllib.parse.quote(context_json) - params.update({"flag_key": flag_key, "context": url_encoded_context}) + params["context"] = url_encoded_context + if flag_key is not None: + params["flag_key"] = flag_key return params def _instrument_call(self, start_time: datetime, end_time: datetime) -> None: request_duration = end_time - start_time formatted_start_time = start_time.isoformat() formatted_end_time = end_time.isoformat() - logging.info( + logging.debug( f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'" ) @@ -177,38 +266,44 @@ def _build_tracking_properties( self, flag_key: str, variant: SelectedVariant, - start_time: datetime, - end_time: datetime, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, ) -> Dict[str, Any]: - request_duration = end_time - start_time - formatted_start_time = start_time.isoformat() - formatted_end_time = end_time.isoformat() - - return { + tracking_properties: Dict[str, Any] = { "Experiment name": flag_key, "Variant name": variant.variant_key, "$experiment_type": "feature_flag", "Flag evaluation mode": "remote", - "Variant fetch start time": formatted_start_time, - "Variant fetch complete time": formatted_end_time, - "Variant fetch latency (ms)": request_duration.total_seconds() * 1000, } - def _handle_response( - self, flag_key: str, fallback_value: SelectedVariant, response: httpx.Response - ) -> tuple[SelectedVariant, bool]: - response.raise_for_status() + if start_time is not None and end_time is not None: + request_duration = end_time - start_time + formatted_start_time = start_time.isoformat() + formatted_end_time = end_time.isoformat() + + tracking_properties.update({ + "Variant fetch start time": formatted_start_time, + "Variant fetch complete time": formatted_end_time, + "Variant fetch latency (ms)": request_duration.total_seconds() * 1000, + }) + return tracking_properties + + def _handle_response(self, response: httpx.Response) -> Dict[str, SelectedVariant]: + response.raise_for_status() flags_response = RemoteFlagsResponse.model_validate(response.json()) + return flags_response.flags - if flag_key in flags_response.flags: - return flags_response.flags[flag_key], False + def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVariant], fallback_value: SelectedVariant) -> Tuple[SelectedVariant, bool]: + if flag_key in flags: + return flags[flag_key], False else: - logging.warning( + logging.debug( f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'" ) return fallback_value, True + def __enter__(self): return self diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index dba1d20..fed3f57 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch from typing import Dict, Optional, List from itertools import chain, repeat -from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride +from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider @@ -20,8 +20,8 @@ def create_test_flag( test_users: Optional[Dict[str, str]] = None, experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, - variant_splits: Optional[Dict[str, float]] = None) -> ExperimentationFlag: - + variant_splits: Optional[Dict[str, float]] = None, + hash_salt: Optional[str] = None) -> ExperimentationFlag: if variants is None: variants = [ Variant(key="control", value="control", is_control=True, split=50.0), @@ -54,7 +54,8 @@ def create_test_flag( ruleset=ruleset, context=context, experiment_id=experiment_id, - is_experiment_active=is_experiment_active + is_experiment_active=is_experiment_active, + hash_salt=hash_salt ) @@ -319,6 +320,54 @@ async def test_get_variant_value_does_not_track_exposure_without_distinct_id(sel _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"company_id": "company123"}) self._mock_tracker.assert_not_called() + @respx.mock + async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): + flag1 = create_test_flag(flag_key="flag1", rollout_percentage=100.0) + flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) + await self.setup_flags([flag1, flag2]) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert len(result) == 2 and "flag1" in result and "flag2" in result + + @respx.mock + async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollout(self): + flag1 = create_test_flag(flag_key="flag1", rollout_percentage=100.0) + flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) + await self.setup_flags([flag1, flag2]) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert len(result) == 1 and "flag1" in result and "flag2" not in result + + @respx.mock + async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): + await self.setup_flags([]) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert result == {} + + @respx.mock + async def test_get_all_variants_does_not_track_exposure_events(self): + flag1 = create_test_flag(flag_key="flag1", rollout_percentage=100.0) + flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) + await self.setup_flags([flag1, flag2]) + + _ = self._flags.get_all_variants({"distinct_id": "user123"}) + + self._mock_tracker.assert_not_called() + + @respx.mock + async def test_track_exposure_event_successfully_tracks(self): + flag = create_test_flag() + await self.setup_flags([flag]) + + variant = SelectedVariant(key="treatment", variant_value="treatment") + self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + + self._mock_tracker.assert_called_once() + @respx.mock async def test_are_flags_ready_returns_true_when_flags_loaded(self): flag = create_test_flag() diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index def080c..c2e312e 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -88,6 +88,58 @@ async def test_ais_enabled_returns_false_for_false_variant_value(self): result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) assert result == False + @respx.mock + async def test_aget_all_variants_returns_all_variants_from_api(self): + variants = { + "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + } + respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) + + result = await self._flags.aget_all_variants({"distinct_id": "user123"}) + + assert result == variants + + @respx.mock + async def test_aget_all_variants_returns_none_on_network_error(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + + result = await self._flags.aget_all_variants({"distinct_id": "user123"}) + + assert result is None + + @respx.mock + async def test_aget_all_variants_does_not_track_exposure_events(self): + variants = { + "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + } + respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) + + await self._flags.aget_all_variants({"distinct_id": "user123"}) + + self.mock_tracker.assert_not_called() + + @respx.mock + async def test_aget_all_variants_handles_empty_response(self): + respx.get(ENDPOINT).mock(return_value=create_success_response({})) + + result = await self._flags.aget_all_variants({"distinct_id": "user123"}) + + assert result == {} + + @respx.mock + async def test_atrack_exposure_event_successfully_tracks(self): + variant = SelectedVariant(variant_key="treatment", variant_value="treatment") + + await self._flags.atrack_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + + pending = [task for task in asyncio.all_tasks() if not task.done() and task != asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + self.mock_tracker.assert_called_once() + class TestRemoteFeatureFlagsProviderSync: def setup_method(self): config = RemoteFlagsConfig() @@ -157,3 +209,51 @@ def test_is_enabled_returns_false_for_false_variant_value(self): result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) assert result == False + @respx.mock + def test_get_all_variants_returns_all_variants_from_api(self): + variants = { + "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + } + respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert result == variants + + @respx.mock + def test_get_all_variants_returns_none_on_network_error(self): + respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert result is None + + @respx.mock + def test_get_all_variants_does_not_track_exposure_events(self): + variants = { + "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + } + respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) + + self._flags.get_all_variants({"distinct_id": "user123"}) + + self.mock_tracker.assert_not_called() + + @respx.mock + def test_get_all_variants_handles_empty_response(self): + respx.get(ENDPOINT).mock(return_value=create_success_response({})) + + result = self._flags.get_all_variants({"distinct_id": "user123"}) + + assert result == {} + + @respx.mock + def test_track_exposure_event_successfully_tracks(self): + variant = SelectedVariant(variant_key="treatment", variant_value="treatment") + + self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + + self.mock_tracker.assert_called_once() + diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 20fe6ad..3f2d6b7 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -49,6 +49,7 @@ class ExperimentationFlag(BaseModel): context: str experiment_id: Optional[str] = None is_experiment_active: Optional[bool] = None + hash_salt: Optional[str] = None class SelectedVariant(BaseModel): From 2eee115fb3c54f262eebe8bb9ac7ca5f0fb25190 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Wed, 12 Nov 2025 15:29:39 -0600 Subject: [PATCH 175/208] =?UTF-8?q?runtime=20rule=20NO=20MATCH=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mixpanel/flags/test_local_feature_flags.py | 81 ++++++++++++++++------ mixpanel/flags/types.py | 1 + 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index fed3f57..1567af2 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -9,14 +9,16 @@ from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider +TEST_FLAG_KEY = "test_flag" def create_test_flag( - flag_key: str = "test_flag", + flag_key: str = TEST_FLAG_KEY, context: str = "distinct_id", variants: Optional[list[Variant]] = None, variant_override: Optional[VariantOverride] = None, rollout_percentage: float = 100.0, - runtime_evaluation: Optional[Dict] = None, + runtime_evaluation_legacy_definition: Optional[Dict] = None, + runtime_evaluation_rule: Optional[Dict] = None, test_users: Optional[Dict[str, str]] = None, experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, @@ -30,7 +32,8 @@ def create_test_flag( rollouts = [Rollout( rollout_percentage=rollout_percentage, - runtime_evaluation_definition=runtime_evaluation, + runtime_evaluation_definition=runtime_evaluation_legacy_definition, + runtime_evaluation_rule=runtime_evaluation_rule, variant_override=variant_override, variant_splits=variant_splits )] @@ -127,14 +130,14 @@ async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self) async def test_get_variant_value_returns_fallback_when_no_context(self): flag = create_test_flag(context="distinct_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock @@ -149,7 +152,7 @@ async def test_get_variant_value_returns_test_user_variant_when_configured(self) ) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "test_user"}) assert result == "true" @respx.mock @@ -165,27 +168,59 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"}) assert result == "false" @respx.mock async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result != "fallback" + # TODO Joshua start here + # TODO problem test doesn't fail + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): + runtime_eval = {"oops": "sorry"} + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = { + "distinct_id": "user123", + "custom_properties": { + "plan": "premium", + "region": "US" + } + } + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): + runtime_eval = {"oops": "sorry"} + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = { + "distinct_id": "user123", + "custom_properties": { + "plan": "premium", + "region": "US" + } + } + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + @respx.mock async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { "distinct_id": "user123", @@ -194,13 +229,13 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): "region": "US" } } - result = self._flags.get_variant_value("test_flag", "fallback", context) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { "distinct_id": "user123", @@ -209,7 +244,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa "region": "US" } } - result = self._flags.get_variant_value("test_flag", "fallback", context) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock @@ -221,7 +256,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_a" @respx.mock @@ -234,7 +269,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -247,7 +282,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_c" @respx.mock @@ -258,7 +293,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -267,7 +302,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @respx.mock @@ -292,7 +327,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": distinct_id}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id}) self._mock_tracker.assert_called_once() @@ -364,7 +399,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @@ -394,7 +429,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": "user123"}) assert result == True @respx.mock @@ -419,7 +454,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -465,5 +500,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3f2d6b7..2c50b24 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,6 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None + runtime_evaluation_rule: Optional[Dict[str, str]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None From 7f89ebd1a747616a445b74f23b2247dc0aa87877 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 08:43:34 -0600 Subject: [PATCH 176/208] Add json-logic lib --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4bee8d1..097733a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", + "json-logic>=0.6.3" ] keywords = ["mixpanel", "analytics"] classifiers = [ From cfa2de7d446aced3c13b3594b033ae8d8726f909 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:13:14 -0600 Subject: [PATCH 177/208] bump to alpha version for python3 support This is the "official" repo, but has been virtually abandoned. the 2017 alpha release is what we need though --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 097733a..d1164b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", - "json-logic>=0.6.3" + "json-logic>=0.7.0a0" ] keywords = ["mixpanel", "analytics"] classifiers = [ From ed0f59e9fd12e7d6b847da30fa1625a865f2052b Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:14:43 -0600 Subject: [PATCH 178/208] =?UTF-8?q?runtime=20rule=20NO=20MATCH=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mixpanel/flags/local_feature_flags.py | 17 ++++++++++++++++- mixpanel/flags/test_local_feature_flags.py | 8 ++++++-- mixpanel/flags/types.py | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5bc441e..ec9c9ff 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -312,11 +312,26 @@ def _get_assigned_rollout( rollout_hash = normalized_hash(str(context_value), salt) if (rollout_hash < rollout.rollout_percentage - and self._is_runtime_evaluation_satisfied(rollout, context) + and self._is_runtime_rules_engine_satisfied(rollout, context) ): return rollout return None + + def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + if not rollout.runtime_evaluation_rule: + return self._is_runtime_evaluation_satisfied(rollout, context) + if not (custom_properties := context.get("custom_properties")): + return False + if not isinstance(custom_properties, dict): + return False + import json_logic + try: + result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) + return bool(result) + except Exception as e: + logger.exception("Error evaluating runtime evaluation rule", e) + return False def _is_runtime_evaluation_satisfied( self, rollout: Rollout, context: Dict[str, Any] diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 1567af2..f8e09bd 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -189,7 +189,9 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred # TODO problem test doesn't fail @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): - runtime_eval = {"oops": "sorry"} + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { @@ -204,7 +206,9 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): - runtime_eval = {"oops": "sorry"} + runtime_eval = { + "==": [{"var": "plan"}, "basic"] + } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 2c50b24..9a76f4e 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,7 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None - runtime_evaluation_rule: Optional[Dict[str, str]] = None + runtime_evaluation_rule: Optional[Dict[Any, Any]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None From 4aeb853a37f7585f32d010dac3722d042d3bb6ca Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:15:45 -0600 Subject: [PATCH 179/208] dry distinct id --- mixpanel/flags/test_local_feature_flags.py | 51 +++++++++++----------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index f8e09bd..e56d257 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -10,6 +10,7 @@ from .local_feature_flags import LocalFeatureFlagsProvider TEST_FLAG_KEY = "test_flag" +DISTINCT_ID = "user123" def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -106,7 +107,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -116,14 +117,14 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -137,7 +138,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "fallback" @respx.mock @@ -175,14 +176,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result != "fallback" # TODO Joshua start here @@ -195,7 +196,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -212,7 +213,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -227,7 +228,7 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -242,7 +243,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "basic", "region": "US" @@ -260,7 +261,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_a" @respx.mock @@ -273,7 +274,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -286,7 +287,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_c" @respx.mock @@ -297,7 +298,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -306,7 +307,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @respx.mock @@ -349,7 +350,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @respx.mock @@ -365,7 +366,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -375,7 +376,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -383,7 +384,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert result == {} @@ -393,7 +394,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants({"distinct_id": "user123"}) + _ = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @@ -403,7 +404,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @@ -423,7 +424,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": DISTINCT_ID}) assert result == False @respx.mock @@ -433,7 +434,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": "user123"}) + result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": DISTINCT_ID}) assert result == True @respx.mock @@ -458,7 +459,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -504,5 +505,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" From 13bb958735afb5210c765aa5c054fdfee3cd65be Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:16:35 -0600 Subject: [PATCH 180/208] DRY user context --- mixpanel/flags/test_local_feature_flags.py | 43 +++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e56d257..c2c0892 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -11,6 +11,7 @@ TEST_FLAG_KEY = "test_flag" DISTINCT_ID = "user123" +USER_CONTEXT = {"distinct_id": DISTINCT_ID} def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -107,7 +108,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -117,14 +118,14 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -138,7 +139,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock @@ -176,14 +177,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" # TODO Joshua start here @@ -261,7 +262,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_a" @respx.mock @@ -274,7 +275,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -287,7 +288,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_c" @respx.mock @@ -298,7 +299,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -307,7 +308,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) self._mock_tracker.assert_called_once() @respx.mock @@ -350,7 +351,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": DISTINCT_ID}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", USER_CONTEXT) self._mock_tracker.assert_not_called() @respx.mock @@ -366,7 +367,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -376,7 +377,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -384,7 +385,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert result == {} @@ -394,7 +395,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + _ = self._flags.get_all_variants(USER_CONTEXT) self._mock_tracker.assert_not_called() @@ -404,7 +405,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": DISTINCT_ID}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, USER_CONTEXT) self._mock_tracker.assert_called_once() @@ -424,7 +425,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": DISTINCT_ID}) + result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) assert result == False @respx.mock @@ -434,7 +435,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": DISTINCT_ID}) + result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) assert result == True @respx.mock @@ -459,7 +460,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -505,5 +506,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" From 651700da4f4b8bfcaa1b29c1d105e1b9fedf4abf Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:18:11 -0600 Subject: [PATCH 181/208] helper to build context with runtime data --- mixpanel/flags/test_local_feature_flags.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index c2c0892..a0f0670 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -4,7 +4,7 @@ import httpx import threading from unittest.mock import Mock, patch -from typing import Dict, Optional, List +from typing import Any, Dict, Optional, List from itertools import chain, repeat from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider @@ -222,19 +222,20 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" + + def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: + context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} + return context @respx.mock async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "premium", + "region": "US" + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" From 5926052223feee64f7059f15322983ae565d4282 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:20:22 -0600 Subject: [PATCH 182/208] use helper everywhere for runtime data --- mixpanel/flags/test_local_feature_flags.py | 33 ++++++++-------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index a0f0670..13870a4 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -196,30 +196,22 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "premium", + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): runtime_eval = { - "==": [{"var": "plan"}, "basic"] + "==": [{"var": "plan"}, "premium"] } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "basic", + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @@ -244,13 +236,10 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "basic", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "basic", + "region": "US" + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" From e335e632f9c5b53f387477dc7217efea15200b6e Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:27:02 -0600 Subject: [PATCH 183/208] ensure priority is given to new rule --- mixpanel/flags/test_local_feature_flags.py | 35 +++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 13870a4..8435e75 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -187,8 +187,7 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" - # TODO Joshua start here - # TODO problem test doesn't fail + @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): runtime_eval = { @@ -220,7 +219,35 @@ def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, return context @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "premium"] + } + legacy_runtime_definition = {"plan": "basic"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "basic"] + } + legacy_runtime_definition = {"plan": "premium"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) @@ -232,7 +259,7 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): + async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) From 9536f22252c07ebc40f645835b21e82114d528af Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:32:49 -0600 Subject: [PATCH 184/208] test all use-cases --- mixpanel/flags/test_local_feature_flags.py | 126 ++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 8435e75..e6f9708 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -213,7 +213,131 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" - + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Springfield/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Boston/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "b", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "d", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "Deutschland", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "France", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 30, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 20, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} return context From 51d6b75647b6365bb7bdb5d3a568d4a9a3d0c422 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:13 -0600 Subject: [PATCH 185/208] Revert "test all use-cases" This reverts commit 9536f22252c07ebc40f645835b21e82114d528af. --- mixpanel/flags/test_local_feature_flags.py | 126 +-------------------- 1 file changed, 1 insertion(+), 125 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e6f9708..8435e75 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -213,131 +213,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied(self): - runtime_eval = { - "in": ["Springfield", {"var": "url"}] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "url": "https://helloworld.com/Springfield/all-about-it", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied(self): - runtime_eval = { - "in": ["Springfield", {"var": "url"}] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "url": "https://helloworld.com/Boston/all-about-it", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied(self): - runtime_eval = { - "in": [ - {"var": "name"}, - ["a", "b", "c", "all-from-the-ui"] - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "b", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied(self): - runtime_eval = { - "in": [ - {"var": "name"}, - ["a", "b", "c", "all-from-the-ui"] - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "d", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied(self): - runtime_eval = { - "and": [ - {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "Johannes", - "country": "Deutschland", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied(self): - runtime_eval = { - "and": [ - {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "Johannes", - "country": "France", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied(self): - runtime_eval = { - ">": [ - {"var": "queries_ran"}, - 25 - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "queries_ran": 30, - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied(self): - runtime_eval = { - ">": [ - {"var": "queries_ran"}, - 25 - ] - } - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "queries_ran": 20, - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - + def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} return context From d9815bfa3e962e190bbf0d2b4204b164cb60ac0f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:16 -0600 Subject: [PATCH 186/208] Revert "ensure priority is given to new rule" This reverts commit e335e632f9c5b53f387477dc7217efea15200b6e. --- mixpanel/flags/test_local_feature_flags.py | 35 +++------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 8435e75..13870a4 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -187,7 +187,8 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" - + # TODO Joshua start here + # TODO problem test doesn't fail @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): runtime_eval = { @@ -219,35 +220,7 @@ def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, return context @respx.mock - async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied(self): - runtime_rule = { - "==": [{"var": "plan"}, "premium"] - } - legacy_runtime_definition = {"plan": "basic"} - flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied(self): - runtime_rule = { - "==": [{"var": "plan"}, "basic"] - } - legacy_runtime_definition = {"plan": "premium"} - flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) - await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - - @respx.mock - async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(self): + async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) @@ -259,7 +232,7 @@ async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(se assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied(self): + async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) From 5f1468bd40123828992cf473018f4bab79705eb7 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:18 -0600 Subject: [PATCH 187/208] Revert "use helper everywhere for runtime data" This reverts commit 5926052223feee64f7059f15322983ae565d4282. --- mixpanel/flags/test_local_feature_flags.py | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 13870a4..a0f0670 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -196,22 +196,30 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = { + "distinct_id": DISTINCT_ID, + "custom_properties": { + "plan": "premium", + "region": "US" + } + } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): runtime_eval = { - "==": [{"var": "plan"}, "premium"] + "==": [{"var": "plan"}, "basic"] } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "basic", - }) + context = { + "distinct_id": DISTINCT_ID, + "custom_properties": { + "plan": "premium", + "region": "US" + } + } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @@ -236,10 +244,13 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "basic", - "region": "US" - }) + context = { + "distinct_id": DISTINCT_ID, + "custom_properties": { + "plan": "basic", + "region": "US" + } + } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" From 06acf6e078115ccaef76e470624266edf05139f4 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:19 -0600 Subject: [PATCH 188/208] Revert "helper to build context with runtime data" This reverts commit 651700da4f4b8bfcaa1b29c1d105e1b9fedf4abf. --- mixpanel/flags/test_local_feature_flags.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index a0f0670..c2c0892 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -4,7 +4,7 @@ import httpx import threading from unittest.mock import Mock, patch -from typing import Any, Dict, Optional, List +from typing import Dict, Optional, List from itertools import chain, repeat from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider @@ -222,20 +222,19 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" - - def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: - context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} - return context @respx.mock async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - "region": "US" - }) + context = { + "distinct_id": DISTINCT_ID, + "custom_properties": { + "plan": "premium", + "region": "US" + } + } result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" From f59ff28c649fc3e819387f01e71e8a853dc540a2 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:19 -0600 Subject: [PATCH 189/208] Revert "DRY user context" This reverts commit 13bb958735afb5210c765aa5c054fdfee3cd65be. --- mixpanel/flags/test_local_feature_flags.py | 43 +++++++++++----------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index c2c0892..e56d257 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -11,7 +11,6 @@ TEST_FLAG_KEY = "test_flag" DISTINCT_ID = "user123" -USER_CONTEXT = {"distinct_id": DISTINCT_ID} def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -108,7 +107,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -118,14 +117,14 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -139,7 +138,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "fallback" @respx.mock @@ -177,14 +176,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result != "fallback" # TODO Joshua start here @@ -262,7 +261,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_a" @respx.mock @@ -275,7 +274,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -288,7 +287,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_c" @respx.mock @@ -299,7 +298,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -308,7 +307,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @respx.mock @@ -351,7 +350,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", USER_CONTEXT) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @respx.mock @@ -367,7 +366,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants(USER_CONTEXT) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -377,7 +376,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants(USER_CONTEXT) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -385,7 +384,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants(USER_CONTEXT) + result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) assert result == {} @@ -395,7 +394,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants(USER_CONTEXT) + _ = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @@ -405,7 +404,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event(TEST_FLAG_KEY, variant, USER_CONTEXT) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @@ -425,7 +424,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) + result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": DISTINCT_ID}) assert result == False @respx.mock @@ -435,7 +434,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) + result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": DISTINCT_ID}) assert result == True @respx.mock @@ -460,7 +459,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -506,5 +505,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" From ba9e5123224ab8ded44db604c78e13e087bfef22 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:22 -0600 Subject: [PATCH 190/208] Revert "dry distinct id" This reverts commit 4aeb853a37f7585f32d010dac3722d042d3bb6ca. --- mixpanel/flags/test_local_feature_flags.py | 51 +++++++++++----------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e56d257..f8e09bd 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -10,7 +10,6 @@ from .local_feature_flags import LocalFeatureFlagsProvider TEST_FLAG_KEY = "test_flag" -DISTINCT_ID = "user123" def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -107,7 +106,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock @@ -117,14 +116,14 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) assert result == "control" @respx.mock @@ -138,7 +137,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock @@ -176,14 +175,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result != "fallback" # TODO Joshua start here @@ -196,7 +195,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": DISTINCT_ID, + "distinct_id": "user123", "custom_properties": { "plan": "premium", "region": "US" @@ -213,7 +212,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": DISTINCT_ID, + "distinct_id": "user123", "custom_properties": { "plan": "premium", "region": "US" @@ -228,7 +227,7 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": DISTINCT_ID, + "distinct_id": "user123", "custom_properties": { "plan": "premium", "region": "US" @@ -243,7 +242,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": DISTINCT_ID, + "distinct_id": "user123", "custom_properties": { "plan": "basic", "region": "US" @@ -261,7 +260,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_a" @respx.mock @@ -274,7 +273,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -287,7 +286,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result == "variant_c" @respx.mock @@ -298,7 +297,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -307,7 +306,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @respx.mock @@ -350,7 +349,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": DISTINCT_ID}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) self._mock_tracker.assert_not_called() @respx.mock @@ -366,7 +365,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants({"distinct_id": "user123"}) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -376,7 +375,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants({"distinct_id": "user123"}) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -384,7 +383,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + result = self._flags.get_all_variants({"distinct_id": "user123"}) assert result == {} @@ -394,7 +393,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants({"distinct_id": DISTINCT_ID}) + _ = self._flags.get_all_variants({"distinct_id": "user123"}) self._mock_tracker.assert_not_called() @@ -404,7 +403,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": DISTINCT_ID}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @@ -424,7 +423,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": DISTINCT_ID}) + result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) assert result == False @respx.mock @@ -434,7 +433,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": DISTINCT_ID}) + result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": "user123"}) assert result == True @respx.mock @@ -459,7 +458,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -505,5 +504,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" From dda290172d184f924e3177fb92b79ebc8863ed3f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:23 -0600 Subject: [PATCH 191/208] =?UTF-8?q?Revert=20"runtime=20rule=20NO=20MATCH?= =?UTF-8?q?=20=E2=9C=85"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ed0f59e9fd12e7d6b847da30fa1625a865f2052b. --- mixpanel/flags/local_feature_flags.py | 17 +---------------- mixpanel/flags/test_local_feature_flags.py | 8 ++------ mixpanel/flags/types.py | 2 +- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index ec9c9ff..5bc441e 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -312,26 +312,11 @@ def _get_assigned_rollout( rollout_hash = normalized_hash(str(context_value), salt) if (rollout_hash < rollout.rollout_percentage - and self._is_runtime_rules_engine_satisfied(rollout, context) + and self._is_runtime_evaluation_satisfied(rollout, context) ): return rollout return None - - def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: - if not rollout.runtime_evaluation_rule: - return self._is_runtime_evaluation_satisfied(rollout, context) - if not (custom_properties := context.get("custom_properties")): - return False - if not isinstance(custom_properties, dict): - return False - import json_logic - try: - result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) - return bool(result) - except Exception as e: - logger.exception("Error evaluating runtime evaluation rule", e) - return False def _is_runtime_evaluation_satisfied( self, rollout: Rollout, context: Dict[str, Any] diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index f8e09bd..1567af2 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -189,9 +189,7 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred # TODO problem test doesn't fail @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "premium"] - } + runtime_eval = {"oops": "sorry"} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { @@ -206,9 +204,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "basic"] - } + runtime_eval = {"oops": "sorry"} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 9a76f4e..2c50b24 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,7 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None - runtime_evaluation_rule: Optional[Dict[Any, Any]] = None + runtime_evaluation_rule: Optional[Dict[str, str]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None From fc7b5f57445aa5a4ed3d336b04b98503036e4dca Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:23 -0600 Subject: [PATCH 192/208] Revert "bump to alpha version for python3 support" This reverts commit cfa2de7d446aced3c13b3594b033ae8d8726f909. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d1164b0..097733a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", - "json-logic>=0.7.0a0" + "json-logic>=0.6.3" ] keywords = ["mixpanel", "analytics"] classifiers = [ From 2cb92430b99583bc61f4f51a72eb40ea5d465413 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:24 -0600 Subject: [PATCH 193/208] Revert "Add json-logic lib" This reverts commit 7f89ebd1a747616a445b74f23b2247dc0aa87877. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 097733a..4bee8d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", - "json-logic>=0.6.3" ] keywords = ["mixpanel", "analytics"] classifiers = [ From 71c22a20f667c8407e1cb562656d78a20cdf07ab Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:37:25 -0600 Subject: [PATCH 194/208] =?UTF-8?q?Revert=20"runtime=20rule=20NO=20MATCH?= =?UTF-8?q?=20=E2=9D=8C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2eee115fb3c54f262eebe8bb9ac7ca5f0fb25190. --- mixpanel/flags/test_local_feature_flags.py | 81 ++++++---------------- mixpanel/flags/types.py | 1 - 2 files changed, 23 insertions(+), 59 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 1567af2..fed3f57 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -9,16 +9,14 @@ from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider -TEST_FLAG_KEY = "test_flag" def create_test_flag( - flag_key: str = TEST_FLAG_KEY, + flag_key: str = "test_flag", context: str = "distinct_id", variants: Optional[list[Variant]] = None, variant_override: Optional[VariantOverride] = None, rollout_percentage: float = 100.0, - runtime_evaluation_legacy_definition: Optional[Dict] = None, - runtime_evaluation_rule: Optional[Dict] = None, + runtime_evaluation: Optional[Dict] = None, test_users: Optional[Dict[str, str]] = None, experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, @@ -32,8 +30,7 @@ def create_test_flag( rollouts = [Rollout( rollout_percentage=rollout_percentage, - runtime_evaluation_definition=runtime_evaluation_legacy_definition, - runtime_evaluation_rule=runtime_evaluation_rule, + runtime_evaluation_definition=runtime_evaluation, variant_override=variant_override, variant_splits=variant_splits )] @@ -130,14 +127,14 @@ async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self) async def test_get_variant_value_returns_fallback_when_no_context(self): flag = create_test_flag(context="distinct_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {}) + result = self._flags.get_variant_value("test_flag", "fallback", {}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock @@ -152,7 +149,7 @@ async def test_get_variant_value_returns_test_user_variant_when_configured(self) ) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) assert result == "true" @respx.mock @@ -168,59 +165,27 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) assert result == "false" @respx.mock async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result != "fallback" - # TODO Joshua start here - # TODO problem test doesn't fail - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): - runtime_eval = {"oops": "sorry"} - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "premium", - "region": "US" - } - } - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result != "fallback" - - @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): - runtime_eval = {"oops": "sorry"} - flag = create_test_flag(runtime_evaluation_rule=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "premium", - "region": "US" - } - } - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) - assert result == "fallback" - @respx.mock async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + flag = create_test_flag(runtime_evaluation=runtime_eval) await self.setup_flags([flag]) context = { "distinct_id": "user123", @@ -229,13 +194,13 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): "region": "US" } } - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + result = self._flags.get_variant_value("test_flag", "fallback", context) assert result != "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + flag = create_test_flag(runtime_evaluation=runtime_eval) await self.setup_flags([flag]) context = { "distinct_id": "user123", @@ -244,7 +209,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa "region": "US" } } - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + result = self._flags.get_variant_value("test_flag", "fallback", context) assert result == "fallback" @respx.mock @@ -256,7 +221,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "variant_a" @respx.mock @@ -269,7 +234,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -282,7 +247,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result == "variant_c" @respx.mock @@ -293,7 +258,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) assert result == "variant_b" @respx.mock @@ -302,7 +267,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @respx.mock @@ -327,7 +292,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id}) + _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": distinct_id}) self._mock_tracker.assert_called_once() @@ -399,7 +364,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) self._mock_tracker.assert_called_once() @@ -429,7 +394,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": "user123"}) + result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) assert result == True @respx.mock @@ -454,7 +419,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -500,5 +465,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) assert result2 != "fallback" diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 2c50b24..3f2d6b7 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,7 +31,6 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None - runtime_evaluation_rule: Optional[Dict[str, str]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None From fcc255b52f7766922f54d20fc94e00eebb6707c5 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:06:04 -0600 Subject: [PATCH 195/208] Runtime rules engine (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bump to alpha version for python3 support This is the "official" repo, but has been virtually abandoned. the 2017 alpha release is what we need though * runtime rule NO MATCH ✅ * dry distinct id * DRY user context * helper to build context with runtime data * use helper everywhere for runtime data * ensure priority is given to new rule * test all use-cases * global import * Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update mixpanel/flags/test_local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * unnest prod vs legacy comparisons * case-insensitivity ❌ * case-insensitivity ✅ * add tests for error cases * only lowercase leaf nodes of rule --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mixpanel/flags/local_feature_flags.py | 71 ++++- mixpanel/flags/test_local_feature_flags.py | 346 ++++++++++++++++++--- mixpanel/flags/types.py | 1 + pyproject.toml | 1 + 4 files changed, 359 insertions(+), 60 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5bc441e..5730a9c 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -3,6 +3,7 @@ import asyncio import time import threading +import json_logic from datetime import datetime, timedelta from typing import Dict, Any, Callable, Optional from .types import ( @@ -23,7 +24,6 @@ logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) - class LocalFeatureFlagsProvider: FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" @@ -312,29 +312,82 @@ def _get_assigned_rollout( rollout_hash = normalized_hash(str(context_value), salt) if (rollout_hash < rollout.rollout_percentage - and self._is_runtime_evaluation_satisfied(rollout, context) + and self._is_runtime_rules_engine_satisfied(rollout, context) ): return rollout return None + + def lowercase_keys_and_values(self, val: Any) -> Any: + if isinstance(val, str): + return val.casefold() + elif isinstance(val, list): + return [self.lowercase_keys_and_values(item) for item in val] + elif isinstance(val, dict): + return { + (key.casefold() if isinstance(key, str) else key): + self.lowercase_keys_and_values(value) + for key, value in val.items() + } + else: + return val + + def lowercase_only_leaf_nodes(self, val: Any) -> Dict[str, Any]: + if isinstance(val, str): + return val.casefold() + elif isinstance(val, list): + return [self.lowercase_only_leaf_nodes(item) for item in val] + elif isinstance(val, dict): + return { + key: + self.lowercase_only_leaf_nodes(value) + for key, value in val.items() + } + else: + return val + + def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not (custom_properties := context.get("custom_properties")): + return None + if not isinstance(custom_properties, dict): + return None + return self.lowercase_keys_and_values(custom_properties) + + def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + if rollout.runtime_evaluation_rule: + parameters_for_runtime_rule = self._get_runtime_parameters(context) + if parameters_for_runtime_rule is None: + return False - def _is_runtime_evaluation_satisfied( + try: + rule = self.lowercase_only_leaf_nodes(rollout.runtime_evaluation_rule) + result = json_logic.jsonLogic(rule, parameters_for_runtime_rule) + return bool(result) + except Exception: + logger.exception("Error evaluating runtime evaluation rule") + return False + + elif rollout.runtime_evaluation_definition: # legacy field supporting only exact match conditions + return self._is_legacy_runtime_evaluation_rule_satisfied(rollout, context) + + else: + return True + + def _is_legacy_runtime_evaluation_rule_satisfied( self, rollout: Rollout, context: Dict[str, Any] ) -> bool: if not rollout.runtime_evaluation_definition: return True - if not (custom_properties := context.get("custom_properties")): - return False - - if not isinstance(custom_properties, dict): + parameters_for_runtime_rule = self._get_runtime_parameters(context) + if parameters_for_runtime_rule is None: return False for key, expected_value in rollout.runtime_evaluation_definition.items(): - if key not in custom_properties: + if key not in parameters_for_runtime_rule: return False - actual_value = custom_properties[key] + actual_value = parameters_for_runtime_rule[key] if actual_value.casefold() != expected_value.casefold(): return False diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index fed3f57..e4481c8 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -4,19 +4,23 @@ import httpx import threading from unittest.mock import Mock, patch -from typing import Dict, Optional, List +from typing import Any, Dict, Optional, List from itertools import chain, repeat from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider +TEST_FLAG_KEY = "test_flag" +DISTINCT_ID = "user123" +USER_CONTEXT = {"distinct_id": DISTINCT_ID} def create_test_flag( - flag_key: str = "test_flag", + flag_key: str = TEST_FLAG_KEY, context: str = "distinct_id", variants: Optional[list[Variant]] = None, variant_override: Optional[VariantOverride] = None, rollout_percentage: float = 100.0, - runtime_evaluation: Optional[Dict] = None, + runtime_evaluation_legacy_definition: Optional[Dict] = None, + runtime_evaluation_rule: Optional[Dict] = None, test_users: Optional[Dict[str, str]] = None, experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, @@ -30,7 +34,8 @@ def create_test_flag( rollouts = [Rollout( rollout_percentage=rollout_percentage, - runtime_evaluation_definition=runtime_evaluation, + runtime_evaluation_definition=runtime_evaluation_legacy_definition, + runtime_evaluation_rule=runtime_evaluation_rule, variant_override=variant_override, variant_splits=variant_splits )] @@ -103,7 +108,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -113,28 +118,28 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_no_context(self): flag = create_test_flag(context="distinct_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock @@ -149,7 +154,7 @@ async def test_get_variant_value_returns_test_user_variant_when_configured(self) ) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "test_user"}) assert result == "true" @respx.mock @@ -165,51 +170,290 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"}) assert result == "false" @respx.mock async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): - runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "premium", - "region": "US" - } + async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] } - result = self._flags.get_variant_value("test_flag", "fallback", context) + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): - runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "basic", - "region": "US" - } + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_invalid_runtime_rule_resorts_to_fallback(self): + runtime_eval = { + "=oops=": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_when_no_custom_properties_provided(self): + runtime_eval = { + "=": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_param_value__satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "PremIum", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_varnames__satisfied(self): + runtime_eval = { + "==": [{"var": "Plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_rule_value__satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "pREMIUm"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Springfield/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Boston/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "b", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "d", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "Deutschland", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "France", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 30, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] } - result = self._flags.get_variant_value("test_flag", "fallback", context) + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 20, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: + context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} + return context + + @respx.mock + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "premium"] + } + legacy_runtime_definition = {"plan": "basic"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "basic"] + } + legacy_runtime_definition = {"plan": "premium"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + "region": "US" + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + "region": "US" + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock @@ -221,7 +465,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_a" @respx.mock @@ -234,7 +478,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -247,7 +491,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_c" @respx.mock @@ -258,7 +502,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -267,7 +511,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) self._mock_tracker.assert_called_once() @respx.mock @@ -292,7 +536,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": distinct_id}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id}) self._mock_tracker.assert_called_once() @@ -310,7 +554,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", USER_CONTEXT) self._mock_tracker.assert_not_called() @respx.mock @@ -326,7 +570,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -336,7 +580,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -344,7 +588,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert result == {} @@ -354,7 +598,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants({"distinct_id": "user123"}) + _ = self._flags.get_all_variants(USER_CONTEXT) self._mock_tracker.assert_not_called() @@ -364,7 +608,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, USER_CONTEXT) self._mock_tracker.assert_called_once() @@ -384,7 +628,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) assert result == False @respx.mock @@ -394,7 +638,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) assert result == True @respx.mock @@ -419,7 +663,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -465,5 +709,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3f2d6b7..9a76f4e 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,6 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None + runtime_evaluation_rule: Optional[Dict[Any, Any]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None diff --git a/pyproject.toml b/pyproject.toml index 4bee8d1..d1164b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", + "json-logic>=0.7.0a0" ] keywords = ["mixpanel", "analytics"] classifiers = [ From 3b33b5b69b393bb864c169ef74b436fd7aa0766a Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Fri, 14 Nov 2025 14:42:49 -0800 Subject: [PATCH 196/208] add CLAUDE.md and copilot-instructions.md (#149) --- .github/copilot-instructions.md | 91 ++++++++++++++++++++++++ CLAUDE.md | 122 ++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b9b253c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,91 @@ +# Copilot Instructions for Mixpanel Python SDK + +## Project Overview +This is the official Mixpanel Python library for server-side analytics integration. It provides event tracking, user profile updates, group analytics, and feature flags with both synchronous and asynchronous support. + +## Core Architecture + +### Main Components +- **Mixpanel class** (`mixpanel/__init__.py`): Primary entry point supporting both sync/async operations +- **Consumer pattern**: `Consumer` (immediate) vs `BufferedConsumer` (batched, default 50 messages) +- **Feature Flags**: Local (client-side evaluation) vs Remote (server-side) providers in `mixpanel/flags/` +- **Dual sync/async API**: Most flag operations have both variants (e.g., `get_variant`/`aget_variant`) + +### Key Design Patterns +```python +# Context manager pattern for resource cleanup +async with Mixpanel(token, local_flags_config=config) as mp: + await mp.local_flags.astart_polling_for_definitions() + +# Consumer customization for delivery behavior +mp = Mixpanel(token, consumer=BufferedConsumer()) + +# Custom serialization via DatetimeSerializer +mp = Mixpanel(token, serializer=CustomSerializer) +``` + +## Development Workflows + +### Testing +- **Run tests**: `pytest` (current Python) or `python -m tox` (all supported versions 3.9-3.13) +- **Async testing**: Uses `pytest-asyncio` with `asyncio_mode = "auto"` in pyproject.toml +- **HTTP mocking**: `responses` library for sync code, `respx` for async code +- **Test structure**: `test_*.py` files in root and package directories + +### Building & Publishing +```bash +pip install -e .[test,dev] # Development setup +python -m build # Build distributions +python -m twine upload dist/* # Publish to PyPI +``` + +## Important Conventions + +### API Endpoints & Authentication +- Default endpoint: `api.mixpanel.com` (override via `api_host` parameter) +- **API secret** (not key) required for `import` and `merge` endpoints +- Feature flags use `/decide` endpoint; events use `/track` + +### Error Handling & Retries +- All consumers use urllib3.Retry with exponential backoff (default 4 retries) +- `MixpanelException` for domain-specific errors +- Feature flag operations degrade gracefully with fallback values + +### Version & Dependencies Management +- Version defined in `mixpanel/__init__.py` as `__version__` +- Uses Pydantic v2+ for data validation (`mixpanel/flags/types.py`) +- json-logic library for runtime flag evaluation rules + +## Feature Flag Specifics + +### Local Flags (Client-side evaluation) +- Require explicit polling: `start_polling_for_definitions()` or context manager +- Default 60s polling interval, configurable via `LocalFlagsConfig` +- Runtime evaluation using json-logic for dynamic targeting + +### Remote Flags (Server-side evaluation) +- Each evaluation makes API call to Mixpanel +- Better for sensitive targeting logic +- Configure via `RemoteFlagsConfig` + +### Flag Configuration Pattern +```python +local_config = mixpanel.LocalFlagsConfig( + api_host="api-eu.mixpanel.com", # EU data residency + enable_polling=True, + polling_interval_in_seconds=90 +) +mp = Mixpanel(token, local_flags_config=local_config) +``` + +## Testing Patterns +- Mock HTTP with `responses.activate` decorator for sync tests +- Use `respx.mock` for async HTTP testing +- Test consumer behavior via `LogConsumer` pattern (see `test_mixpanel.py`) +- Always test both sync and async variants of flag operations + +## Critical Implementation Notes +- `alias()` method always uses synchronous Consumer regardless of main consumer type +- Local flags require explicit startup; use context managers for proper cleanup +- DateTime serialization handled by `DatetimeSerializer` class +- All flag providers support custom API endpoints for data residency requirements \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bcf4578 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the official Mixpanel Python library for server-side integration. It provides event tracking, user profile updates, group analytics, and feature flags functionality. The library supports both synchronous and asynchronous operations. + +## Development Commands + +### Environment Setup +```bash +# Install development and test dependencies +pip install -e .[test,dev] +``` + +### Testing +```bash +# Run all tests across all Python versions (3.9-3.13, PyPy) +python -m tox + +# Run tests for current Python version only +pytest + +# Run with coverage +python -m coverage run -m pytest +python -m coverage report -m +python -m coverage html + +# Run specific test file +pytest test_mixpanel.py +pytest mixpanel/flags/test_local_feature_flags.py +``` + +### Building and Publishing +```bash +# Build distribution packages +python -m build + +# Publish to PyPI +python -m twine upload dist/* +``` + +### Documentation +```bash +# Build documentation +python -m sphinx -b html docs docs/_build/html + +# Publish docs to GitHub Pages +python -m ghp_import -n -p docs/_build/html +``` + +## Architecture + +### Core Components + +**Mixpanel Class** (`mixpanel/__init__.py`) +- Main entry point for all tracking operations +- Supports context managers (both sync and async) +- Integrates with Consumer classes for message delivery +- Optional feature flags providers (local and remote) + +**Consumers** +- `Consumer`: Sends HTTP requests immediately (one per call) +- `BufferedConsumer`: Batches messages (default max 50) before sending +- Both support retry logic (default 4 retries with exponential backoff) +- All consumers support custom API endpoints via `api_host` parameter + +**Feature Flags** (`mixpanel/flags/`) +- `LocalFeatureFlagsProvider`: Client-side evaluation with polling (default 60s interval) +- `RemoteFeatureFlagsProvider`: Server-side evaluation via API calls +- Both providers support async operations +- Types defined in `mixpanel/flags/types.py` using Pydantic models + +### Key Design Patterns + +1. **Dual Sync/Async Support**: Most feature flag operations have both sync and async variants (e.g., `get_variant` / `aget_variant`) + +2. **Consumer Pattern**: Events/updates are sent via consumer objects, allowing customization of delivery behavior without changing tracking code + +3. **Context Managers**: The Mixpanel class supports both `with` and `async with` patterns to manage flag provider lifecycle + +4. **JSON Serialization**: Custom `DatetimeSerializer` handles datetime objects; extensible via `serializer` parameter + +5. **Runtime Rules Engine**: Local flags support runtime evaluation using json-logic library for dynamic targeting + +## Testing Patterns + +- Tests use `pytest` with `pytest-asyncio` for async support +- `responses` library mocks HTTP requests for sync code +- `respx` library mocks HTTP requests for async code +- Test files follow pattern: `test_*.py` in root or within package directories +- Pytest config: `asyncio_mode = "auto"` in pyproject.toml + +## Dependencies + +- `requests>=2.4.2, <3`: HTTP client (sync) +- `httpx>=0.27.0`: HTTP client (async) +- `pydantic>=2.0.0`: Data validation and types +- `asgiref>=3.0.0`: Async utilities +- `json-logic>=0.7.0a0`: Runtime rules evaluation + +## Version Management + +Version is defined in `mixpanel/__init__.py` as `__version__` and dynamically loaded by setuptools. + +## API Endpoints + +Default: `api.mixpanel.com` +- Events: `/track` +- People: `/engage` +- Groups: `/groups` +- Imports: `/import` +- Feature Flags: `/decide` + +## Important Notes + +- API secret (not API key) is required for `import` and `merge` endpoints +- `alias()` always uses synchronous Consumer regardless of main consumer type +- Feature flags require opt-in via constructor config parameters +- Local flags poll for updates; call `start_polling_for_definitions()` or use context manager +- Retry logic uses urllib3.Retry with exponential backoff From 7d93632a300feded5e3f7e2ddf5d4ad3a59c5822 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Wed, 19 Nov 2025 14:11:23 -0800 Subject: [PATCH 197/208] Bumpt to beta version 5.1.0b1 (#150) * bump version to 5.1.0b1 * bump docs version too --- docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 22ca672..33bc720 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '4.11.1' +version = release = '5.1.0b1' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index abc9aca..77164cc 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -30,7 +30,7 @@ from .flags.remote_feature_flags import RemoteFeatureFlagsProvider from .flags.types import LocalFlagsConfig, RemoteFlagsConfig -__version__ = '5.0.0' +__version__ = '5.1.0b1' logger = logging.getLogger(__name__) From f43ead4197e88bafafca1c93e8ba0dfafa27545c Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 9 Dec 2025 09:53:03 -0800 Subject: [PATCH 198/208] Convert README from reStructuredText to Markdown (#151) --- README.md | 48 +++++++++++++++++++++++++++++++++++++++ README.rst | 66 ------------------------------------------------------ 2 files changed, 48 insertions(+), 66 deletions(-) create mode 100644 README.md delete mode 100644 README.rst diff --git a/README.md b/README.md new file mode 100644 index 0000000..35586b5 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# mixpanel-python + +[![PyPI](https://img.shields.io/pypi/v/mixpanel)](https://pypi.org/project/mixpanel) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mixpanel)](https://pypi.org/project/mixpanel) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/mixpanel)](https://pypi.org/project/mixpanel) +![Tests](https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg) + +This is the official Mixpanel Python library. This library allows for +server-side integration of Mixpanel. + +To import, export, transform, or delete your Mixpanel data, please see our +[mixpanel-utils package](https://github.com/mixpanel/mixpanel-utils). + +## Installation + +The library can be installed using pip: + +```bash +pip install mixpanel +``` + +## Getting Started + +Typical usage usually looks like this: + +```python +from mixpanel import Mixpanel + +mp = Mixpanel(YOUR_TOKEN) + +# tracks an event with certain properties +mp.track(DISTINCT_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) + +# sends an update to a user profile +mp.people_set(DISTINCT_ID, {'$first_name' : 'Ilya', 'favorite pizza': 'margherita'}) +``` + +You can use an instance of the Mixpanel class for sending all of your events +and people updates. + +## Additional Information + +* [Help Docs](https://www.mixpanel.com/help/reference/python) +* [Full Documentation](http://mixpanel.github.io/mixpanel-python/) +* [mixpanel-python-async](https://github.com/jessepollak/mixpanel-python-async); a third party tool for sending data asynchronously +from the tracking python process. + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mixpanel/mixpanel-python) diff --git a/README.rst b/README.rst deleted file mode 100644 index 565ffab..0000000 --- a/README.rst +++ /dev/null @@ -1,66 +0,0 @@ -mixpanel-python -============================== - -.. image:: https://img.shields.io/pypi/v/mixpanel - :target: https://pypi.org/project/mixpanel - :alt: PyPI - -.. image:: https://img.shields.io/pypi/pyversions/mixpanel - :target: https://pypi.org/project/mixpanel - :alt: PyPI - Python Version - -.. image:: https://img.shields.io/pypi/dm/mixpanel - :target: https://pypi.org/project/mixpanel - :alt: PyPI - Downloads - -.. image:: https://github.com/mixpanel/mixpanel-python/workflows/Tests/badge.svg - -This is the official Mixpanel Python library. This library allows for -server-side integration of Mixpanel. - -To import, export, transform, or delete your Mixpanel data, please see our -`mixpanel-utils package`_. - - -Installation ------------- - -The library can be installed using pip:: - - pip install mixpanel - - -Getting Started ---------------- - -Typical usage usually looks like this:: - - from mixpanel import Mixpanel - - mp = Mixpanel(YOUR_TOKEN) - - # tracks an event with certain properties - mp.track(DISTINCT_ID, 'button clicked', {'color' : 'blue', 'size': 'large'}) - - # sends an update to a user profile - mp.people_set(DISTINCT_ID, {'$first_name' : 'Ilya', 'favorite pizza': 'margherita'}) - -You can use an instance of the Mixpanel class for sending all of your events -and people updates. - - -Additional Information ----------------------- - -* `Help Docs`_ -* `Full Documentation`_ -* mixpanel-python-async_; a third party tool for sending data asynchronously - from the tracking python process. - - -.. |travis-badge| image:: https://travis-ci.org/mixpanel/mixpanel-python.svg?branch=master - :target: https://travis-ci.org/mixpanel/mixpanel-python -.. _mixpanel-utils package: https://github.com/mixpanel/mixpanel-utils -.. _Help Docs: https://www.mixpanel.com/help/reference/python -.. _Full Documentation: http://mixpanel.github.io/mixpanel-python/ -.. _mixpanel-python-async: https://github.com/jessepollak/mixpanel-python-async From aea7fa1ca9f8b3007dcd01cad8ca1379a18527f5 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 12 Dec 2025 17:20:21 -0600 Subject: [PATCH 199/208] pin beta, don't use greater than on it (#152) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d1164b0..6399c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", - "json-logic>=0.7.0a0" + "json-logic==0.7.0a0" ] keywords = ["mixpanel", "analytics"] classifiers = [ From b0fc5e57e9a6bb47c06457d1b8a76d152756061d Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Tue, 6 Jan 2026 16:15:46 -0600 Subject: [PATCH 200/208] =?UTF-8?q?Release=20runtime=20engine=20?= =?UTF-8?q?=F0=9F=9A=A2=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pin beta, don't use greater than on it * bump version to non-beta --- docs/conf.py | 2 +- mixpanel/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 33bc720..9217d42 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ project = u'mixpanel' copyright = u' 2021, Mixpanel, Inc.' author = u'Mixpanel ' -version = release = '5.1.0b1' +version = release = '5.1.0' exclude_patterns = ['_build'] pygments_style = 'sphinx' diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 77164cc..e03abef 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -30,7 +30,7 @@ from .flags.remote_feature_flags import RemoteFeatureFlagsProvider from .flags.types import LocalFlagsConfig, RemoteFlagsConfig -__version__ = '5.1.0b1' +__version__ = '5.1.0' logger = logging.getLogger(__name__) From 7aa71235c50b5ee2dc9713febc2f3c4c9061809e Mon Sep 17 00:00:00 2001 From: Jerry Lin <44830071+jerrylin3321@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:36:08 -0700 Subject: [PATCH 201/208] add ruff formatting (#157) --- .github/workflows/test.yml | 13 +- demo/local_flags.py | 18 +- demo/post_an_event.py | 8 +- demo/remote_flags.py | 21 +- demo/subprocess_consumer.py | 39 +- docs/conf.py | 46 +- mixpanel/__init__.py | 415 +++++---- mixpanel/flags/local_feature_flags.py | 81 +- mixpanel/flags/remote_feature_flags.py | 105 ++- mixpanel/flags/test_local_feature_flags.py | 519 ++++++----- mixpanel/flags/test_remote_feature_flags.py | 188 +++- mixpanel/flags/test_utils.py | 22 +- mixpanel/flags/types.py | 16 +- mixpanel/flags/utils.py | 26 +- test_mixpanel.py | 912 ++++++++++++-------- 15 files changed, 1525 insertions(+), 904 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c1bd19..a72725d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,11 +1,20 @@ -name: Tests +name: CI -on: [push] +on: [push, pull_request] permissions: contents: read jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Check formatting + run: uvx ruff format --check . + test: runs-on: ubuntu-24.04 strategy: diff --git a/demo/local_flags.py b/demo/local_flags.py index 397db43..f9d8744 100644 --- a/demo/local_flags.py +++ b/demo/local_flags.py @@ -9,7 +9,7 @@ PROJECT_TOKEN = "" FLAG_KEY = "sample-flag" FLAG_FALLBACK_VARIANT = "control" -USER_CONTEXT = { "distinct_id": "sample-distinct-id" } +USER_CONTEXT = {"distinct_id": "sample-distinct-id"} # If False, the flag definitions are fetched just once on SDK initialization. Otherwise, will poll SHOULD_POLL_CONTINOUSLY = False @@ -18,14 +18,22 @@ # Use the correct data residency endpoint for your project. API_HOST = "api-eu.mixpanel.com" + async def main(): - local_config = mixpanel.LocalFlagsConfig(api_host=API_HOST, enable_polling=SHOULD_POLL_CONTINOUSLY, polling_interval_in_seconds=POLLING_INTERVAL_IN_SECONDS) + local_config = mixpanel.LocalFlagsConfig( + api_host=API_HOST, + enable_polling=SHOULD_POLL_CONTINOUSLY, + polling_interval_in_seconds=POLLING_INTERVAL_IN_SECONDS, + ) # Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging async with mixpanel.Mixpanel(PROJECT_TOKEN, local_flags_config=local_config) as mp: await mp.local_flags.astart_polling_for_definitions() - variant_value = mp.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + variant_value = mp.local_flags.get_variant_value( + FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT + ) print(f"Variant value: {variant_value}") -if __name__ == '__main__': - asyncio.run(main()) \ No newline at end of file + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/demo/post_an_event.py b/demo/post_an_event.py index c97aa2a..e487f43 100644 --- a/demo/post_an_event.py +++ b/demo/post_an_event.py @@ -1,13 +1,15 @@ from mixpanel import Mixpanel + def post_event(token): mixpanel = Mixpanel(token) - mixpanel.track('ID', 'Script run') + mixpanel.track("ID", "Script run") + -if __name__ == '__main__': +if __name__ == "__main__": # You'll want to change this to be the token # from your Mixpanel project. You can find your # project token in the project settings dialog # of the Mixpanel web application - demo_token = '0ba349286c780fe53d8b4617d90e2d01' + demo_token = "0ba349286c780fe53d8b4617d90e2d01" post_event(demo_token) diff --git a/demo/remote_flags.py b/demo/remote_flags.py index bb78703..66ebbf4 100644 --- a/demo/remote_flags.py +++ b/demo/remote_flags.py @@ -8,28 +8,37 @@ PROJECT_TOKEN = "" FLAG_KEY = "sample-flag" FLAG_FALLBACK_VARIANT = "control" -USER_CONTEXT = { "distinct_id": "sample-distinct-id" } +USER_CONTEXT = {"distinct_id": "sample-distinct-id"} # Use the correct data residency endpoint for your project. API_HOST = "api-eu.mixpanel.com" DEMO_ASYNC = True + async def async_demo(): remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST) # Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging - async with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp: - variant_value = await mp.remote_flags.aget_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + async with mixpanel.Mixpanel( + PROJECT_TOKEN, remote_flags_config=remote_config + ) as mp: + variant_value = await mp.remote_flags.aget_variant_value( + FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT + ) print(f"Variant value: {variant_value}") + def sync_demo(): remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST) with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp: - variant_value = mp.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT) + variant_value = mp.remote_flags.get_variant_value( + FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT + ) print(f"Variant value: {variant_value}") -if __name__ == '__main__': + +if __name__ == "__main__": if DEMO_ASYNC: asyncio.run(async_demo()) else: - sync_demo() \ No newline at end of file + sync_demo() diff --git a/demo/subprocess_consumer.py b/demo/subprocess_consumer.py index 5b2a015..69eb560 100644 --- a/demo/subprocess_consumer.py +++ b/demo/subprocess_consumer.py @@ -3,7 +3,7 @@ from mixpanel import Mixpanel, BufferedConsumer -''' +""" As your application scales, it's likely you'll want to to detect events in one place and send them somewhere else. For example, you might write the events to a queue @@ -12,15 +12,17 @@ This demo shows how you might do things, using a custom Consumer to consume events, and a and a BufferedConsumer to send them to Mixpanel -''' +""" -''' +""" You can provide custom communication behaviors by providing your own consumer object to the Mixpanel constructor. Consumers are expected to have a single method, 'send', that takes an endpoint and a json message. -''' +""" + + class QueueWriteConsumer(object): def __init__(self, queue): self.queue = queue @@ -28,31 +30,33 @@ def __init__(self, queue): def send(self, endpoint, json_message): self.queue.put((endpoint, json_message)) + def do_tracking(project_token, distinct_id, queue): - ''' + """ This process represents the work process where events and updates are generated. This might be the service thread of a web service, or some other process that is mostly concerned with getting time-sensitive work done. - ''' + """ consumer = QueueWriteConsumer(queue) mp = Mixpanel(project_token, consumer) for i in range(100): - event = 'Tick' - mp.track(distinct_id, event, {'Tick Number': i}) - print(f'tick {i}') + event = "Tick" + mp.track(distinct_id, event, {"Tick Number": i}) + print(f"tick {i}") queue.put(None) # tell worker we're out of jobs + def do_sending(queue): - ''' + """ This process is the analytics worker process- it can wait on HTTP responses to Mixpanel without blocking other jobs. This might be a queue consumer process or just a separate thread from the code that observes the things you want to measure. - ''' + """ consumer = BufferedConsumer() payload = queue.get() while payload is not None: @@ -61,14 +65,19 @@ def do_sending(queue): consumer.flush() -if __name__ == '__main__': + +if __name__ == "__main__": # replace token with your real project token - token = '0ba349286c780fe53d8b4617d90e2d01' - distinct_id = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for x in range(32)) + token = "0ba349286c780fe53d8b4617d90e2d01" + distinct_id = "".join( + random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for x in range(32) + ) queue = multiprocessing.Queue() sender = multiprocessing.Process(target=do_sending, args=(queue,)) - tracker = multiprocessing.Process(target=do_tracking, args=(token, distinct_id, queue)) + tracker = multiprocessing.Process( + target=do_tracking, args=(token, distinct_id, queue) + ) sender.start() tracker.start() diff --git a/docs/conf.py b/docs/conf.py index 9217d42..eb0cb82 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,51 +5,53 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) extensions = [ - 'sphinx.ext.autodoc', + "sphinx.ext.autodoc", ] -autodoc_member_order = 'bysource' +autodoc_member_order = "bysource" -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" # General information about the project. -project = u'mixpanel' -copyright = u' 2021, Mixpanel, Inc.' -author = u'Mixpanel ' -version = release = '5.1.0' -exclude_patterns = ['_build'] -pygments_style = 'sphinx' +project = "mixpanel" +copyright = " 2021, Mixpanel, Inc." +author = "Mixpanel " +version = release = "5.1.0" +exclude_patterns = ["_build"] +pygments_style = "sphinx" # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = "alabaster" html_theme_options = { - 'description': 'The official Mixpanel client library for Python.', - 'github_user': 'mixpanel', - 'github_repo': 'mixpanel-python', - 'github_button': False, - 'travis_button': True, + "description": "The official Mixpanel client library for Python.", + "github_user": "mixpanel", + "github_repo": "mixpanel-python", + "github_button": False, + "travis_button": True, } # Custom sidebar templates, maps document names to template names. html_sidebars = { - '**': [ - 'about.html', 'localtoc.html', 'searchbox.html', + "**": [ + "about.html", + "localtoc.html", + "searchbox.html", ] } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] -html_style = 'mixpanel.css' +html_static_path = ["_static"] +html_style = "mixpanel.css" # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index e03abef..3d16723 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -14,6 +14,7 @@ Analytics updates. :class:`~.Consumer` and :class:`~.BufferedConsumer` allow callers to customize the IO characteristics of their tracking. """ + import datetime import json import logging @@ -30,14 +31,15 @@ from .flags.remote_feature_flags import RemoteFeatureFlagsProvider from .flags.types import LocalFlagsConfig, RemoteFlagsConfig -__version__ = '5.1.0' +__version__ = "5.1.0" logger = logging.getLogger(__name__) + class DatetimeSerializer(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime.datetime): - fmt = '%Y-%m-%dT%H:%M:%S' + fmt = "%Y-%m-%dT%H:%M:%S" return obj.strftime(fmt) return json.JSONEncoder.default(self, obj) @@ -45,10 +47,10 @@ def default(self, obj): def json_dumps(data, cls=None): # Separators are specified to eliminate whitespace. - return json.dumps(data, separators=(',', ':'), cls=cls) + return json.dumps(data, separators=(",", ":"), cls=cls) -class Mixpanel(): +class Mixpanel: """Instances of Mixpanel are used for all events and profile updates. :param str token: your project's Mixpanel token @@ -63,7 +65,14 @@ class Mixpanel(): The *serializer* parameter. """ - def __init__(self, token, consumer=None, serializer=DatetimeSerializer, local_flags_config: Optional[LocalFlagsConfig] = None, remote_flags_config: Optional[RemoteFlagsConfig] = None): + def __init__( + self, + token, + consumer=None, + serializer=DatetimeSerializer, + local_flags_config: Optional[LocalFlagsConfig] = None, + remote_flags_config: Optional[RemoteFlagsConfig] = None, + ): self._token = token self._consumer = consumer or Consumer() self._serializer = serializer @@ -72,10 +81,14 @@ def __init__(self, token, consumer=None, serializer=DatetimeSerializer, local_fl self._remote_flags_provider = None if local_flags_config: - self._local_flags_provider = LocalFeatureFlagsProvider(self._token, local_flags_config, __version__, self.track) + self._local_flags_provider = LocalFeatureFlagsProvider( + self._token, local_flags_config, __version__, self.track + ) if remote_flags_config: - self._remote_flags_provider = RemoteFeatureFlagsProvider(self._token, remote_flags_config, __version__, self.track) + self._remote_flags_provider = RemoteFeatureFlagsProvider( + self._token, remote_flags_config, __version__, self.track + ) def _now(self): return time.time() @@ -87,14 +100,18 @@ def _make_insert_id(self): def local_flags(self) -> LocalFeatureFlagsProvider: """Get the local flags provider if configured for it""" if self._local_flags_provider is None: - raise MixpanelException("No local flags provider initialized. Pass local_flags_config to constructor.") + raise MixpanelException( + "No local flags provider initialized. Pass local_flags_config to constructor." + ) return self._local_flags_provider @property def remote_flags(self) -> RemoteFeatureFlagsProvider: """Get the remote flags provider if configured for it""" if self._remote_flags_provider is None: - raise MixpanelException("No remote_flags_config was passed to the consttructor") + raise MixpanelException( + "No remote_flags_config was passed to the consttructor" + ) return self._remote_flags_provider def track(self, distinct_id, event_name, properties=None, meta=None): @@ -111,25 +128,33 @@ def track(self, distinct_id, event_name, properties=None, meta=None): (rarely) to override special values sent in the event object. """ all_properties = { - 'token': self._token, - 'distinct_id': distinct_id, - 'time': self._now(), - '$insert_id': self._make_insert_id(), - 'mp_lib': 'python', - '$lib_version': __version__, + "token": self._token, + "distinct_id": distinct_id, + "time": self._now(), + "$insert_id": self._make_insert_id(), + "mp_lib": "python", + "$lib_version": __version__, } if properties: all_properties.update(properties) event = { - 'event': event_name, - 'properties': all_properties, + "event": event_name, + "properties": all_properties, } if meta: event.update(meta) - self._consumer.send('events', json_dumps(event, cls=self._serializer)) - - def import_data(self, api_key, distinct_id, event_name, timestamp, - properties=None, meta=None, api_secret=None): + self._consumer.send("events", json_dumps(event, cls=self._serializer)) + + def import_data( + self, + api_key, + distinct_id, + event_name, + timestamp, + properties=None, + meta=None, + api_secret=None, + ): """Record an event that occurred more than 5 days in the past. :param str api_key: (DEPRECATED) your Mixpanel project's API key @@ -159,26 +184,30 @@ def import_data(self, api_key, distinct_id, event_name, timestamp, """ if api_secret is None: - logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") + logger.warning( + "api_key will soon be removed from mixpanel-python; please use api_secret instead." + ) all_properties = { - 'token': self._token, - 'distinct_id': distinct_id, - 'time': timestamp, - '$insert_id': self._make_insert_id(), - 'mp_lib': 'python', - '$lib_version': __version__, + "token": self._token, + "distinct_id": distinct_id, + "time": timestamp, + "$insert_id": self._make_insert_id(), + "mp_lib": "python", + "$lib_version": __version__, } if properties: all_properties.update(properties) event = { - 'event': event_name, - 'properties': all_properties, + "event": event_name, + "properties": all_properties, } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) + self._consumer.send( + "imports", json_dumps(event, cls=self._serializer), (api_key, api_secret) + ) def alias(self, alias_id, original, meta=None): """Creates an alias which Mixpanel will use to remap one id to another. @@ -199,18 +228,18 @@ def alias(self, alias_id, original, meta=None): to Mixpanel servers, regardless of any custom consumer. """ event = { - 'event': '$create_alias', - 'properties': { - 'distinct_id': original, - 'alias': alias_id, - 'token': self._token, + "event": "$create_alias", + "properties": { + "distinct_id": original, + "alias": alias_id, + "token": self._token, }, } if meta: event.update(meta) sync_consumer = Consumer() - sync_consumer.send('events', json_dumps(event, cls=self._serializer)) + sync_consumer.send("events", json_dumps(event, cls=self._serializer)) def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): """ @@ -237,18 +266,22 @@ def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None) `__. """ if api_secret is None: - logger.warning("api_key will soon be removed from mixpanel-python; please use api_secret instead.") + logger.warning( + "api_key will soon be removed from mixpanel-python; please use api_secret instead." + ) event = { - 'event': '$merge', - 'properties': { - '$distinct_ids': [distinct_id1, distinct_id2], - 'token': self._token, + "event": "$merge", + "properties": { + "$distinct_ids": [distinct_id1, distinct_id2], + "token": self._token, }, } if meta: event.update(meta) - self._consumer.send('imports', json_dumps(event, cls=self._serializer), (api_key, api_secret)) + self._consumer.send( + "imports", json_dumps(event, cls=self._serializer), (api_key, api_secret) + ) def people_set(self, distinct_id, properties, meta=None): """Set properties of a people record. @@ -259,10 +292,13 @@ def people_set(self, distinct_id, properties, meta=None): If the profile does not exist, creates a new profile with these properties. """ - return self.people_update({ - '$distinct_id': distinct_id, - '$set': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$set": properties, + }, + meta=meta or {}, + ) def people_set_once(self, distinct_id, properties, meta=None): """Set properties of a people record if they are not already set. @@ -274,10 +310,13 @@ def people_set_once(self, distinct_id, properties, meta=None): overwritten. If the profile does not exist, creates a new profile with these properties. """ - return self.people_update({ - '$distinct_id': distinct_id, - '$set_once': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$set_once": properties, + }, + meta=meta or {}, + ) def people_increment(self, distinct_id, properties, meta=None): """Increment/decrement numerical properties of a people record. @@ -290,10 +329,13 @@ def people_increment(self, distinct_id, properties, meta=None): properties on the record default to zero. Negative values in ``properties`` will decrement the given property. """ - return self.people_update({ - '$distinct_id': distinct_id, - '$add': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$add": properties, + }, + meta=meta or {}, + ) def people_append(self, distinct_id, properties, meta=None): """Append to the list associated with a property. @@ -307,10 +349,13 @@ def people_append(self, distinct_id, properties, meta=None): mp.people_append('123', {'Items': 'Super Arm'}) """ - return self.people_update({ - '$distinct_id': distinct_id, - '$append': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$append": properties, + }, + meta=meta or {}, + ) def people_union(self, distinct_id, properties, meta=None): """Merge the values of a list associated with a property. @@ -324,10 +369,13 @@ def people_union(self, distinct_id, properties, meta=None): mp.people_union('123', {'Items': ['Super Arm', 'Fire Storm']}) """ - return self.people_update({ - '$distinct_id': distinct_id, - '$union': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$union": properties, + }, + meta=meta or {}, + ) def people_unset(self, distinct_id, properties, meta=None): """Permanently remove properties from a people record. @@ -335,10 +383,13 @@ def people_unset(self, distinct_id, properties, meta=None): :param str distinct_id: the profile to update :param list properties: property names to remove """ - return self.people_update({ - '$distinct_id': distinct_id, - '$unset': properties, - }, meta=meta) + return self.people_update( + { + "$distinct_id": distinct_id, + "$unset": properties, + }, + meta=meta, + ) def people_remove(self, distinct_id, properties, meta=None): """Permanently remove a value from the list associated with a property. @@ -351,23 +402,28 @@ def people_remove(self, distinct_id, properties, meta=None): mp.people_remove('123', {'Items': 'Super Arm'}) """ - return self.people_update({ - '$distinct_id': distinct_id, - '$remove': properties, - }, meta=meta or {}) + return self.people_update( + { + "$distinct_id": distinct_id, + "$remove": properties, + }, + meta=meta or {}, + ) def people_delete(self, distinct_id, meta=None): """Permanently delete a people record. :param str distinct_id: the profile to delete """ - return self.people_update({ - '$distinct_id': distinct_id, - '$delete': "", - }, meta=meta or None) + return self.people_update( + { + "$distinct_id": distinct_id, + "$delete": "", + }, + meta=meta or None, + ) - def people_track_charge(self, distinct_id, amount, - properties=None, meta=None): + def people_track_charge(self, distinct_id, amount, properties=None, meta=None): """Track a charge on a people record. :param str distinct_id: the profile with which to associate the charge @@ -380,9 +436,9 @@ def people_track_charge(self, distinct_id, amount, """ if properties is None: properties = {} - properties.update({'$amount': amount}) + properties.update({"$amount": amount}) return self.people_append( - distinct_id, {'$transactions': properties or {}}, meta=meta or {} + distinct_id, {"$transactions": properties or {}}, meta=meta or {} ) def people_clear_charges(self, distinct_id, meta=None): @@ -391,7 +447,9 @@ def people_clear_charges(self, distinct_id, meta=None): :param str distinct_id: the profile whose charges will be cleared """ return self.people_unset( - distinct_id, ["$transactions"], meta=meta or {}, + distinct_id, + ["$transactions"], + meta=meta or {}, ) def people_update(self, message, meta=None): @@ -407,13 +465,13 @@ def people_update(self, message, meta=None): .. _`user profiles documentation`: https://developer.mixpanel.com/reference/user-profiles """ record = { - '$token': self._token, - '$time': self._now(), + "$token": self._token, + "$time": self._now(), } record.update(message) if meta: record.update(meta) - self._consumer.send('people', json_dumps(record, cls=self._serializer)) + self._consumer.send("people", json_dumps(record, cls=self._serializer)) def group_set(self, group_key, group_id, properties, meta=None): """Set properties of a group profile. @@ -425,11 +483,14 @@ def group_set(self, group_key, group_id, properties, meta=None): If the profile does not exist, creates a new profile with these properties. """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$set': properties, - }, meta=meta or {}) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$set": properties, + }, + meta=meta or {}, + ) def group_set_once(self, group_key, group_id, properties, meta=None): """Set properties of a group profile if they are not already set. @@ -442,11 +503,14 @@ def group_set_once(self, group_key, group_id, properties, meta=None): overwritten. If the profile does not exist, creates a new profile with these properties. """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$set_once': properties, - }, meta=meta or {}) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$set_once": properties, + }, + meta=meta or {}, + ) def group_union(self, group_key, group_id, properties, meta=None): """Merge the values of a list associated with a property. @@ -461,11 +525,14 @@ def group_union(self, group_key, group_id, properties, meta=None): mp.group_union('company', 'Acme Inc.', {'Items': ['Super Arm', 'Fire Storm']}) """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$union': properties, - }, meta=meta or {}) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$union": properties, + }, + meta=meta or {}, + ) def group_unset(self, group_key, group_id, properties, meta=None): """Permanently remove properties from a group profile. @@ -474,11 +541,14 @@ def group_unset(self, group_key, group_id, properties, meta=None): :param str group_id: the group to update :param list properties: property names to remove """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$unset': properties, - }, meta=meta) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$unset": properties, + }, + meta=meta, + ) def group_remove(self, group_key, group_id, properties, meta=None): """Permanently remove a value from the list associated with a property. @@ -492,11 +562,14 @@ def group_remove(self, group_key, group_id, properties, meta=None): mp.group_remove('company', 'Acme Inc.', {'Items': 'Super Arm'}) """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$remove': properties, - }, meta=meta or {}) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$remove": properties, + }, + meta=meta or {}, + ) def group_delete(self, group_key, group_id, meta=None): """Permanently delete a group profile. @@ -504,11 +577,14 @@ def group_delete(self, group_key, group_id, meta=None): :param str group_key: the group key, e.g. 'company' :param str group_id: the group to delete """ - return self.group_update({ - '$group_key': group_key, - '$group_id': group_id, - '$delete': "", - }, meta=meta or None) + return self.group_update( + { + "$group_key": group_key, + "$group_id": group_id, + "$delete": "", + }, + meta=meta or None, + ) def group_update(self, message, meta=None): """Send a generic group profile update @@ -523,13 +599,13 @@ def group_update(self, message, meta=None): .. _`group profiles documentation`: https://developer.mixpanel.com/reference/group-profiles """ record = { - '$token': self._token, - '$time': self._now(), + "$token": self._token, + "$time": self._now(), } record.update(message) if meta: record.update(meta) - self._consumer.send('groups', json_dumps(record, cls=self._serializer)) + self._consumer.send("groups", json_dumps(record, cls=self._serializer)) def __enter__(self): return self @@ -556,6 +632,7 @@ class MixpanelException(Exception): This could be caused by a network outage or interruption, or by an invalid endpoint passed to :meth:`.Consumer.send`. """ + pass @@ -583,15 +660,24 @@ class Consumer(object): The *verify_cert* parameter. """ - def __init__(self, events_url=None, people_url=None, import_url=None, - request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): + def __init__( + self, + events_url=None, + people_url=None, + import_url=None, + request_timeout=None, + groups_url=None, + api_host="api.mixpanel.com", + retry_limit=4, + retry_backoff_factor=0.25, + verify_cert=True, + ): # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { - 'events': events_url or 'https://{}/track'.format(api_host), - 'people': people_url or 'https://{}/engage'.format(api_host), - 'groups': groups_url or 'https://{}/groups'.format(api_host), - 'imports': import_url or 'https://{}/import'.format(api_host), + "events": events_url or "https://{}/track".format(api_host), + "people": people_url or "https://{}/engage".format(api_host), + "groups": groups_url or "https://{}/groups".format(api_host), + "imports": import_url or "https://{}/import".format(api_host), } self._verify_cert = verify_cert @@ -614,7 +700,7 @@ def __init__(self, events_url=None, people_url=None, import_url=None, ) self._session = requests.Session() - self._session.mount('https://', adapter) + self._session.mount("https://", adapter) def send(self, endpoint, json_message, api_key=None, api_secret=None): """Immediately record an event or a profile update. @@ -631,9 +717,15 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): The *api_secret* parameter. """ if endpoint not in self._endpoints: - raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._endpoints.keys())) + raise MixpanelException( + 'No such endpoint "{0}". Valid endpoints are one of {1}'.format( + endpoint, self._endpoints.keys() + ) + ) - self._write_request(self._endpoints[endpoint], json_message, api_key, api_secret) + self._write_request( + self._endpoints[endpoint], json_message, api_key, api_secret + ) def _write_request(self, request_url, json_message, api_key=None, api_secret=None): if isinstance(api_key, tuple): @@ -642,16 +734,16 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non api_key, api_secret = api_key params = { - 'data': json_message, - 'verbose': 1, - 'ip': 0, + "data": json_message, + "verbose": 1, + "ip": 0, } if api_key: - params['api_key'] = api_key + params["api_key"] = api_key basic_auth = None if api_secret is not None: - basic_auth = HTTPBasicAuth(api_secret, '') + basic_auth = HTTPBasicAuth(api_secret, "") try: response = self._session.post( @@ -667,10 +759,14 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non try: response_dict = response.json() except ValueError: - raise MixpanelException('Cannot interpret Mixpanel server response: {0}'.format(response.text)) + raise MixpanelException( + "Cannot interpret Mixpanel server response: {0}".format(response.text) + ) - if response_dict['status'] != 1: - raise MixpanelException('Mixpanel error: {0}'.format(response_dict['error'])) + if response_dict["status"] != 1: + raise MixpanelException( + "Mixpanel error: {0}".format(response_dict["error"]) + ) return True # <- TODO: remove return val with major release. @@ -707,16 +803,36 @@ class BufferedConsumer(object): just before your program exits. Calls to :meth:`~.flush` will send all remaining unsent events being held by the instance. """ - def __init__(self, max_size=50, events_url=None, people_url=None, import_url=None, - request_timeout=None, groups_url=None, api_host="api.mixpanel.com", - retry_limit=4, retry_backoff_factor=0.25, verify_cert=True): - self._consumer = Consumer(events_url, people_url, import_url, request_timeout, - groups_url, api_host, retry_limit, retry_backoff_factor, verify_cert) + + def __init__( + self, + max_size=50, + events_url=None, + people_url=None, + import_url=None, + request_timeout=None, + groups_url=None, + api_host="api.mixpanel.com", + retry_limit=4, + retry_backoff_factor=0.25, + verify_cert=True, + ): + self._consumer = Consumer( + events_url, + people_url, + import_url, + request_timeout, + groups_url, + api_host, + retry_limit, + retry_backoff_factor, + verify_cert, + ) self._buffers = { - 'events': [], - 'people': [], - 'groups': [], - 'imports': [], + "events": [], + "people": [], + "groups": [], + "imports": [], } self._max_size = min(50, max_size) self._api_key = None @@ -741,7 +857,11 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): The *api_key* parameter. """ if endpoint not in self._buffers: - raise MixpanelException('No such endpoint "{0}". Valid endpoints are one of {1}'.format(endpoint, self._buffers.keys())) + raise MixpanelException( + 'No such endpoint "{0}". Valid endpoints are one of {1}'.format( + endpoint, self._buffers.keys() + ) + ) if not isinstance(api_key, tuple): api_key = (api_key, api_secret) @@ -767,8 +887,8 @@ def _flush_endpoint(self, endpoint): buf = self._buffers[endpoint] while buf: - batch = buf[:self._max_size] - batch_json = '[{0}]'.format(','.join(batch)) + batch = buf[: self._max_size] + batch_json = "[{0}]".format(",".join(batch)) try: self._consumer.send(endpoint, batch_json, api_key=self._api_key) except MixpanelException as orig_e: @@ -776,6 +896,5 @@ def _flush_endpoint(self, endpoint): mp_e.message = batch_json mp_e.endpoint = endpoint raise mp_e from orig_e - buf = buf[self._max_size:] + buf = buf[self._max_size :] self._buffers[endpoint] = buf - diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5730a9c..cb98e51 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -18,12 +18,13 @@ normalized_hash, prepare_common_query_params, EXPOSURE_EVENT, - generate_traceparent + generate_traceparent, ) logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) + class LocalFeatureFlagsProvider: FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" @@ -156,7 +157,9 @@ def get_all_variants(self, context: Dict[str, Any]) -> Dict[str, SelectedVariant fallback = SelectedVariant(variant_key=None, variant_value=None) for flag_key in self._flag_definitions.keys(): - variant = self.get_variant(flag_key, fallback, context, report_exposure=False) + variant = self.get_variant( + flag_key, fallback, context, report_exposure=False + ) if variant.variant_key is not None: variants[flag_key] = variant @@ -188,7 +191,11 @@ def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: return variant_value == True def get_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True + self, + flag_key: str, + fallback_value: SelectedVariant, + context: Dict[str, Any], + report_exposure: bool = True, ) -> SelectedVariant: """ Gets the selected variant for a feature flag @@ -217,7 +224,9 @@ def get_variant( flag_definition, context ): selected_variant = test_user_variant - elif rollout := self._get_assigned_rollout(flag_definition, context_value, context): + elif rollout := self._get_assigned_rollout( + flag_definition, context_value, context + ): selected_variant = self._get_assigned_variant( flag_definition, context_value, flag_key, rollout ) @@ -225,7 +234,9 @@ def get_variant( if selected_variant is not None: if report_exposure: end_time = time.perf_counter() - self._track_exposure(flag_key, selected_variant, context, end_time - start_time) + self._track_exposure( + flag_key, selected_variant, context, end_time - start_time + ) return selected_variant logger.debug( @@ -233,7 +244,9 @@ def get_variant( ) return fallback_value - def track_exposure_event(self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any]): + def track_exposure_event( + self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + ): """ Manually tracks a feature flagging exposure event to Mixpanel. This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting @@ -272,11 +285,16 @@ def _get_assigned_variant( ): return variant - stored_salt = flag_definition.hash_salt if flag_definition.hash_salt is not None else "" + stored_salt = ( + flag_definition.hash_salt if flag_definition.hash_salt is not None else "" + ) salt = flag_name + stored_salt + "variant" variant_hash = normalized_hash(str(context_value), salt) - variants = [variant.model_copy(deep=True) for variant in flag_definition.ruleset.variants] + variants = [ + variant.model_copy(deep=True) + for variant in flag_definition.ruleset.variants + ] if rollout.variant_splits: for variant in variants: if variant.key in rollout.variant_splits: @@ -294,7 +312,8 @@ def _get_assigned_variant( variant_key=selected.key, variant_value=selected.value, experiment_id=flag_definition.experiment_id, - is_experiment_active=flag_definition.is_experiment_active) + is_experiment_active=flag_definition.is_experiment_active, + ) def _get_assigned_rollout( self, @@ -311,49 +330,53 @@ def _get_assigned_rollout( rollout_hash = normalized_hash(str(context_value), salt) - if (rollout_hash < rollout.rollout_percentage + if ( + rollout_hash < rollout.rollout_percentage and self._is_runtime_rules_engine_satisfied(rollout, context) ): return rollout return None - + def lowercase_keys_and_values(self, val: Any) -> Any: - if isinstance(val, str): + if isinstance(val, str): return val.casefold() elif isinstance(val, list): return [self.lowercase_keys_and_values(item) for item in val] elif isinstance(val, dict): return { - (key.casefold() if isinstance(key, str) else key): - self.lowercase_keys_and_values(value) + ( + key.casefold() if isinstance(key, str) else key + ): self.lowercase_keys_and_values(value) for key, value in val.items() } else: return val - + def lowercase_only_leaf_nodes(self, val: Any) -> Dict[str, Any]: - if isinstance(val, str): + if isinstance(val, str): return val.casefold() elif isinstance(val, list): return [self.lowercase_only_leaf_nodes(item) for item in val] elif isinstance(val, dict): return { - key: - self.lowercase_only_leaf_nodes(value) - for key, value in val.items() + key: self.lowercase_only_leaf_nodes(value) for key, value in val.items() } else: return val - - def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + def _get_runtime_parameters( + self, context: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: if not (custom_properties := context.get("custom_properties")): return None if not isinstance(custom_properties, dict): return None return self.lowercase_keys_and_values(custom_properties) - def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + def _is_runtime_rules_engine_satisfied( + self, rollout: Rollout, context: Dict[str, Any] + ) -> bool: if rollout.runtime_evaluation_rule: parameters_for_runtime_rule = self._get_runtime_parameters(context) if parameters_for_runtime_rule is None: @@ -367,7 +390,9 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str logger.exception("Error evaluating runtime evaluation rule") return False - elif rollout.runtime_evaluation_definition: # legacy field supporting only exact match conditions + elif ( + rollout.runtime_evaluation_definition + ): # legacy field supporting only exact match conditions return self._is_legacy_runtime_evaluation_rule_satisfied(rollout, context) else: @@ -412,7 +437,9 @@ async def _afetch_flag_definitions(self) -> None: start_time = datetime.now() headers = {"traceparent": generate_traceparent()} response = await self._async_client.get( - self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers + self.FLAGS_DEFINITIONS_URL_PATH, + params=self._request_params, + headers=headers, ) end_time = datetime.now() self._handle_response(response, start_time, end_time) @@ -424,7 +451,9 @@ def _fetch_flag_definitions(self) -> None: start_time = datetime.now() headers = {"traceparent": generate_traceparent()} response = self._sync_client.get( - self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers + self.FLAGS_DEFINITIONS_URL_PATH, + params=self._request_params, + headers=headers, ) end_time = datetime.now() self._handle_response(response, start_time, end_time) @@ -462,7 +491,7 @@ def _track_exposure( flag_key: str, variant: SelectedVariant, context: Dict[str, Any], - latency_in_seconds: Optional[float]=None, + latency_in_seconds: Optional[float] = None, ): if distinct_id := context.get("distinct_id"): properties = { diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 8d265ae..886db2b 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -8,7 +8,12 @@ from asgiref.sync import sync_to_async from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse -from .utils import REQUEST_HEADERS, EXPOSURE_EVENT, prepare_common_query_params, generate_traceparent +from .utils import ( + REQUEST_HEADERS, + EXPOSURE_EVENT, + prepare_common_query_params, + generate_traceparent, +) logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) @@ -38,9 +43,11 @@ def __init__( self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) self._request_params_base = prepare_common_query_params(self._token, version) - async def aget_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, SelectedVariant]]: + async def aget_all_variants( + self, context: Dict[str, Any] + ) -> Optional[Dict[str, SelectedVariant]]: """ - Asynchronously gets all feature flag variants for the current user context from remote server. + Asynchronously gets all feature flag variants for the current user context from remote server. :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context :return: A dictionary mapping flag keys to their selected variants, or None if the call fails """ @@ -49,7 +56,9 @@ async def aget_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, params = self._prepare_query_params(context) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} - response = await self._async_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + response = await self._async_client.get( + self.FLAGS_URL_PATH, params=params, headers=headers + ) end_time = datetime.now() self._instrument_call(start_time, end_time) flags = self._handle_response(response) @@ -74,7 +83,11 @@ async def aget_variant_value( return variant.variant_value async def aget_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True + self, + flag_key: str, + fallback_value: SelectedVariant, + context: Dict[str, Any], + reportExposure: bool = True, ) -> SelectedVariant: """ Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server. @@ -88,13 +101,21 @@ async def aget_variant( params = self._prepare_query_params(context, flag_key) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} - response = await self._async_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + response = await self._async_client.get( + self.FLAGS_URL_PATH, params=params, headers=headers + ) end_time = datetime.now() self._instrument_call(start_time, end_time) flags = self._handle_response(response) - selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value) + selected_variant, is_fallback = self._lookup_flag_in_response( + flag_key, flags, fallback_value + ) - if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")): + if ( + not is_fallback + and reportExposure + and (distinct_id := context.get("distinct_id")) + ): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -120,10 +141,8 @@ async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: return variant_value == True async def atrack_exposure_event( - self, - flag_key: str, - variant: SelectedVariant, - context: Dict[str, Any]): + self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + ): """ Manually tracks a feature flagging exposure event asynchronously to Mixpanel. This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting @@ -132,7 +151,7 @@ async def atrack_exposure_event( :param SelectedVariant variant: The selected variant for the feature flag :param Dict[str, Any] context: The user context used to evaluate the feature flag """ - if (distinct_id := context.get("distinct_id")): + if distinct_id := context.get("distinct_id"): properties = self._build_tracking_properties(flag_key, variant) await sync_to_async(self._tracker, thread_sensitive=False)( @@ -143,10 +162,11 @@ async def atrack_exposure_event( "Cannot track exposure event without a distinct_id in the context" ) - - def get_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, SelectedVariant]]: + def get_all_variants( + self, context: Dict[str, Any] + ) -> Optional[Dict[str, SelectedVariant]]: """ - Synchronously gets all feature flag variants for the current user context from remote server. + Synchronously gets all feature flag variants for the current user context from remote server. :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context :return: A dictionary mapping flag keys to their selected variants, or None if the call fails """ @@ -155,7 +175,9 @@ def get_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, Select params = self._prepare_query_params(context) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} - response = self._sync_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + response = self._sync_client.get( + self.FLAGS_URL_PATH, params=params, headers=headers + ) end_time = datetime.now() self._instrument_call(start_time, end_time) flags = self._handle_response(response) @@ -180,7 +202,11 @@ def get_variant_value( return variant.variant_value def get_variant( - self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True + self, + flag_key: str, + fallback_value: SelectedVariant, + context: Dict[str, Any], + reportExposure: bool = True, ) -> SelectedVariant: """ Synchronously gets the selected variant for a feature flag from remote server. @@ -194,14 +220,22 @@ def get_variant( params = self._prepare_query_params(context, flag_key) start_time = datetime.now() headers = {"traceparent": generate_traceparent()} - response = self._sync_client.get(self.FLAGS_URL_PATH, params=params, headers=headers) + response = self._sync_client.get( + self.FLAGS_URL_PATH, params=params, headers=headers + ) end_time = datetime.now() self._instrument_call(start_time, end_time) flags = self._handle_response(response) - selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value) + selected_variant, is_fallback = self._lookup_flag_in_response( + flag_key, flags, fallback_value + ) - if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")): + if ( + not is_fallback + and reportExposure + and (distinct_id := context.get("distinct_id")) + ): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -223,10 +257,8 @@ def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: return variant_value == True def track_exposure_event( - self, - flag_key: str, - variant: SelectedVariant, - context: Dict[str, Any]): + self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + ): """ Manually tracks a feature flagging exposure event synchronously to Mixpanel. This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting @@ -235,7 +267,7 @@ def track_exposure_event( :param SelectedVariant variant: The selected variant for the feature flag :param Dict[str, Any] context: The user context used to evaluate the feature flag """ - if (distinct_id := context.get("distinct_id")): + if distinct_id := context.get("distinct_id"): properties = self._build_tracking_properties(flag_key, variant) self._tracker(distinct_id, EXPOSURE_EVENT, properties) else: @@ -281,11 +313,14 @@ def _build_tracking_properties( formatted_start_time = start_time.isoformat() formatted_end_time = end_time.isoformat() - tracking_properties.update({ - "Variant fetch start time": formatted_start_time, - "Variant fetch complete time": formatted_end_time, - "Variant fetch latency (ms)": request_duration.total_seconds() * 1000, - }) + tracking_properties.update( + { + "Variant fetch start time": formatted_start_time, + "Variant fetch complete time": formatted_end_time, + "Variant fetch latency (ms)": request_duration.total_seconds() + * 1000, + } + ) return tracking_properties @@ -294,7 +329,12 @@ def _handle_response(self, response: httpx.Response) -> Dict[str, SelectedVarian flags_response = RemoteFlagsResponse.model_validate(response.json()) return flags_response.flags - def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVariant], fallback_value: SelectedVariant) -> Tuple[SelectedVariant, bool]: + def _lookup_flag_in_response( + self, + flag_key: str, + flags: Dict[str, SelectedVariant], + fallback_value: SelectedVariant, + ) -> Tuple[SelectedVariant, bool]: if flag_key in flags: return flags[flag_key], False else: @@ -303,7 +343,6 @@ def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVaria ) return fallback_value, True - def __enter__(self): return self diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e4481c8..2d8af71 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -6,13 +6,24 @@ from unittest.mock import Mock, patch from typing import Any, Dict, Optional, List from itertools import chain, repeat -from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant +from .types import ( + LocalFlagsConfig, + ExperimentationFlag, + RuleSet, + Variant, + Rollout, + FlagTestUsers, + ExperimentationFlags, + VariantOverride, + SelectedVariant, +) from .local_feature_flags import LocalFeatureFlagsProvider TEST_FLAG_KEY = "test_flag" DISTINCT_ID = "user123" USER_CONTEXT = {"distinct_id": DISTINCT_ID} + def create_test_flag( flag_key: str = TEST_FLAG_KEY, context: str = "distinct_id", @@ -25,30 +36,29 @@ def create_test_flag( experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, variant_splits: Optional[Dict[str, float]] = None, - hash_salt: Optional[str] = None) -> ExperimentationFlag: + hash_salt: Optional[str] = None, +) -> ExperimentationFlag: if variants is None: variants = [ Variant(key="control", value="control", is_control=True, split=50.0), - Variant(key="treatment", value="treatment", is_control=False, split=50.0) + Variant(key="treatment", value="treatment", is_control=False, split=50.0), ] - rollouts = [Rollout( - rollout_percentage=rollout_percentage, - runtime_evaluation_definition=runtime_evaluation_legacy_definition, - runtime_evaluation_rule=runtime_evaluation_rule, - variant_override=variant_override, - variant_splits=variant_splits - )] + rollouts = [ + Rollout( + rollout_percentage=rollout_percentage, + runtime_evaluation_definition=runtime_evaluation_legacy_definition, + runtime_evaluation_rule=runtime_evaluation_rule, + variant_override=variant_override, + variant_splits=variant_splits, + ) + ] test_config = None if test_users: test_config = FlagTestUsers(users=test_users) - ruleset = RuleSet( - variants=variants, - rollout=rollouts, - test=test_config - ) + ruleset = RuleSet(variants=variants, rollout=rollouts, test=test_config) return ExperimentationFlag( id="test-id", @@ -60,7 +70,7 @@ def create_test_flag( context=context, experiment_id=experiment_id, is_experiment_active=is_experiment_active, - hash_salt=hash_salt + hash_salt=hash_salt, ) @@ -78,10 +88,16 @@ async def setup_method(self): self._mock_tracker = Mock() config_no_polling = LocalFlagsConfig(enable_polling=False) - self._flags = LocalFeatureFlagsProvider("test-token", config_no_polling, "1.0.0", self._mock_tracker) + self._flags = LocalFeatureFlagsProvider( + "test-token", config_no_polling, "1.0.0", self._mock_tracker + ) - config_with_polling = LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0) - self._flags_with_polling = LocalFeatureFlagsProvider("test-token", config_with_polling, "1.0.0", self._mock_tracker) + config_with_polling = LocalFlagsConfig( + enable_polling=True, polling_interval_in_seconds=0 + ) + self._flags_with_polling = LocalFeatureFlagsProvider( + "test-token", config_with_polling, "1.0.0", self._mock_tracker + ) yield @@ -90,10 +106,13 @@ async def setup_method(self): async def setup_flags(self, flags: List[ExperimentationFlag]): respx.get("https://api.mixpanel.com/flags/definitions").mock( - return_value=create_flags_response(flags)) + return_value=create_flags_response(flags) + ) await self._flags.astart_polling_for_definitions() - async def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): + async def setup_flags_with_polling( + self, flags_in_order: List[List[ExperimentationFlag]] = [[]] + ): responses = [create_flags_response(flag) for flag in flags_in_order] respx.get("https://api.mixpanel.com/flags/definitions").mock( @@ -104,28 +123,35 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati ) await self._flags_with_polling.astart_polling_for_definitions() - @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value( + "nonexistent_flag", "control", USER_CONTEXT + ) assert result == "control" @respx.mock - async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails(self): + async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( + self, + ): respx.get("https://api.mixpanel.com/flags/definitions").mock( return_value=httpx.Response(status_code=500) ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value( + "nonexistent_flag", "control", USER_CONTEXT + ) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) + result = self._flags.get_variant_value( + "nonexistent_flag", "control", USER_CONTEXT + ) assert result == "control" @respx.mock @@ -146,42 +172,50 @@ async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): async def test_get_variant_value_returns_test_user_variant_when_configured(self): variants = [ Variant(key="control", value="false", is_control=True, split=50.0), - Variant(key="treatment", value="true", is_control=False, split=50.0) + Variant(key="treatment", value="true", is_control=False, split=50.0), ] flag = create_test_flag( - variants=variants, - test_users={"test_user": "treatment"} + variants=variants, test_users={"test_user": "treatment"} ) await self.setup_flags([flag]) - result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value( + TEST_FLAG_KEY, "control", {"distinct_id": "test_user"} + ) assert result == "true" @respx.mock - async def test_get_variant_value_returns_fallback_when_test_user_variant_not_configured(self): + async def test_get_variant_value_returns_fallback_when_test_user_variant_not_configured( + self, + ): variants = [ Variant(key="control", value="false", is_control=True, split=50.0), - Variant(key="treatment", value="true", is_control=False, split=50.0) + Variant(key="treatment", value="true", is_control=False, split=50.0), ] flag = create_test_flag( - variants=variants, - test_users={"test_user": "nonexistent_variant"} + variants=variants, test_users={"test_user": "nonexistent_variant"} ) await self.setup_flags([flag]) - with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + with patch("mixpanel.flags.utils.normalized_hash") as mock_hash: mock_hash.return_value = 0.5 - result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value( + TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"} + ) assert result == "false" @respx.mock - async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): + async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero( + self, + ): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock - async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): + async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred( + self, + ): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) @@ -189,48 +223,50 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "premium"] - } + runtime_eval = {"==": [{"var": "plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = self.user_context_with_properties( + { + "plan": "premium", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "premium"] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( + self, + ): + runtime_eval = {"==": [{"var": "plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "basic", - }) + context = self.user_context_with_properties( + { + "plan": "basic", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock async def test_get_variant_value_invalid_runtime_rule_resorts_to_fallback(self): - runtime_eval = { - "=oops=": [{"var": "plan"}, "premium"] - } + runtime_eval = {"=oops=": [{"var": "plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "basic", - }) + context = self.user_context_with_properties( + { + "plan": "basic", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_when_no_custom_properties_provided(self): - runtime_eval = { - "=": [{"var": "plan"}, "premium"] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_when_no_custom_properties_provided( + self, + ): + runtime_eval = {"=": [{"var": "plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = self.user_context_with_properties({}) @@ -238,197 +274,223 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_ assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_param_value__satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "premium"] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_param_value__satisfied( + self, + ): + runtime_eval = {"==": [{"var": "plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "PremIum", - }) + context = self.user_context_with_properties( + { + "plan": "PremIum", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_varnames__satisfied(self): - runtime_eval = { - "==": [{"var": "Plan"}, "premium"] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_varnames__satisfied( + self, + ): + runtime_eval = {"==": [{"var": "Plan"}, "premium"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = self.user_context_with_properties( + { + "plan": "premium", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_rule_value__satisfied(self): - runtime_eval = { - "==": [{"var": "plan"}, "pREMIUm"] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_rule_value__satisfied( + self, + ): + runtime_eval = {"==": [{"var": "plan"}, "pREMIUm"]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = self.user_context_with_properties( + { + "plan": "premium", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied(self): - runtime_eval = { - "in": ["Springfield", {"var": "url"}] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied( + self, + ): + runtime_eval = {"in": ["Springfield", {"var": "url"}]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "url": "https://helloworld.com/Springfield/all-about-it", - }) + context = self.user_context_with_properties( + { + "url": "https://helloworld.com/Springfield/all-about-it", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied(self): - runtime_eval = { - "in": ["Springfield", {"var": "url"}] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied( + self, + ): + runtime_eval = {"in": ["Springfield", {"var": "url"}]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "url": "https://helloworld.com/Boston/all-about-it", - }) + context = self.user_context_with_properties( + { + "url": "https://helloworld.com/Boston/all-about-it", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied(self): - runtime_eval = { - "in": [ - {"var": "name"}, - ["a", "b", "c", "all-from-the-ui"] - ] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied( + self, + ): + runtime_eval = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "b", - }) + context = self.user_context_with_properties( + { + "name": "b", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied(self): - runtime_eval = { - "in": [ - {"var": "name"}, - ["a", "b", "c", "all-from-the-ui"] - ] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied( + self, + ): + runtime_eval = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "d", - }) + context = self.user_context_with_properties( + { + "name": "d", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied(self): + async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied( + self, + ): runtime_eval = { "and": [ {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} + {"==": [{"var": "country"}, "Deutschland"]}, ] } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "Johannes", - "country": "Deutschland", - }) + context = self.user_context_with_properties( + { + "name": "Johannes", + "country": "Deutschland", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied(self): + async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied( + self, + ): runtime_eval = { "and": [ {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} + {"==": [{"var": "country"}, "Deutschland"]}, ] } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "name": "Johannes", - "country": "France", - }) + context = self.user_context_with_properties( + { + "name": "Johannes", + "country": "France", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied(self): - runtime_eval = { - ">": [ - {"var": "queries_ran"}, - 25 - ] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied( + self, + ): + runtime_eval = {">": [{"var": "queries_ran"}, 25]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "queries_ran": 30, - }) + context = self.user_context_with_properties( + { + "queries_ran": 30, + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied(self): - runtime_eval = { - ">": [ - {"var": "queries_ran"}, - 25 - ] - } + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied( + self, + ): + runtime_eval = {">": [{"var": "queries_ran"}, 25]} flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "queries_ran": 20, - }) + context = self.user_context_with_properties( + { + "queries_ran": 20, + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" - def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: + def user_context_with_properties( + self, properties: Dict[str, Any] + ) -> Dict[str, Any]: context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} return context @respx.mock - async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied(self): - runtime_rule = { - "==": [{"var": "plan"}, "premium"] - } + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied( + self, + ): + runtime_rule = {"==": [{"var": "plan"}, "premium"]} legacy_runtime_definition = {"plan": "basic"} - flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + flag = create_test_flag( + runtime_evaluation_rule=runtime_rule, + runtime_evaluation_legacy_definition=legacy_runtime_definition, + ) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = self.user_context_with_properties( + { + "plan": "premium", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied(self): - runtime_rule = { - "==": [{"var": "plan"}, "basic"] - } + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied( + self, + ): + runtime_rule = {"==": [{"var": "plan"}, "basic"]} legacy_runtime_definition = {"plan": "premium"} - flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + flag = create_test_flag( + runtime_evaluation_rule=runtime_rule, + runtime_evaluation_legacy_definition=legacy_runtime_definition, + ) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - }) + context = self.user_context_with_properties( + { + "plan": "premium", + } + ) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @@ -437,31 +499,29 @@ async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(se runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "premium", - "region": "US" - }) + context = self.user_context_with_properties({"plan": "premium", "region": "US"}) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied(self): + async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied( + self, + ): runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = self.user_context_with_properties({ - "plan": "basic", - "region": "US" - }) + context = self.user_context_with_properties({"plan": "basic", "region": "US"}) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock - async def test_get_variant_value_picks_correct_variant_with_hundred_percent_split(self): + async def test_get_variant_value_picks_correct_variant_with_hundred_percent_split( + self, + ): variants = [ Variant(key="A", value="variant_a", is_control=False, split=100.0), Variant(key="B", value="variant_b", is_control=False, split=0.0), - Variant(key="C", value="variant_c", is_control=False, split=0.0) + Variant(key="C", value="variant_c", is_control=False, split=0.0), ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) @@ -469,27 +529,35 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli assert result == "variant_a" @respx.mock - async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_splits(self): + async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_splits( + self, + ): variants = [ Variant(key="A", value="variant_a", is_control=False, split=100.0), Variant(key="B", value="variant_b", is_control=False, split=0.0), - Variant(key="C", value="variant_c", is_control=False, split=0.0) + Variant(key="C", value="variant_c", is_control=False, split=0.0), ] variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} - flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) + flag = create_test_flag( + variants=variants, rollout_percentage=100.0, variant_splits=variant_splits + ) await self.setup_flags([flag]) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_b" @respx.mock - async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_splits(self): + async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_splits( + self, + ): variants = [ Variant(key="A", value="variant_a", is_control=False), Variant(key="B", value="variant_b", is_control=False), Variant(key="C", value="variant_c", is_control=False), ] variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} - flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) + flag = create_test_flag( + variants=variants, rollout_percentage=100.0, variant_splits=variant_splits + ) await self.setup_flags([flag]) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_c" @@ -500,7 +568,9 @@ async def test_get_variant_value_picks_overriden_variant(self): Variant(key="A", value="variant_a", is_control=False, split=100.0), Variant(key="B", value="variant_b", is_control=False, split=0.0), ] - flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) + flag = create_test_flag( + variants=variants, variant_override=VariantOverride(key="B") + ) await self.setup_flags([flag]) result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) assert result == "variant_b" @@ -509,34 +579,41 @@ async def test_get_variant_value_picks_overriden_variant(self): async def test_get_variant_value_tracks_exposure_when_variant_selected(self): flag = create_test_flag() await self.setup_flags([flag]) - with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + with patch("mixpanel.flags.utils.normalized_hash") as mock_hash: mock_hash.return_value = 0.5 _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) self._mock_tracker.assert_called_once() @respx.mock - @pytest.mark.parametrize("experiment_id,is_experiment_active,use_qa_user", [ - ("exp-123", True, True), # QA tester with active experiment - ("exp-456", False, True), # QA tester with inactive experiment - ("exp-789", True, False), # Regular user with active experiment - ("exp-000", False, False), # Regular user with inactive experiment - (None, None, True), # QA tester with no experiment - (None, None, False), # Regular user with no experiment - ]) - async def test_get_variant_value_tracks_exposure_with_correct_properties(self, experiment_id, is_experiment_active, use_qa_user): + @pytest.mark.parametrize( + "experiment_id,is_experiment_active,use_qa_user", + [ + ("exp-123", True, True), # QA tester with active experiment + ("exp-456", False, True), # QA tester with inactive experiment + ("exp-789", True, False), # Regular user with active experiment + ("exp-000", False, False), # Regular user with inactive experiment + (None, None, True), # QA tester with no experiment + (None, None, False), # Regular user with no experiment + ], + ) + async def test_get_variant_value_tracks_exposure_with_correct_properties( + self, experiment_id, is_experiment_active, use_qa_user + ): flag = create_test_flag( experiment_id=experiment_id, is_experiment_active=is_experiment_active, - test_users={"qa_user": "treatment"} + test_users={"qa_user": "treatment"}, ) await self.setup_flags([flag]) distinct_id = "qa_user" if use_qa_user else "regular_user" - with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: + with patch("mixpanel.flags.utils.normalized_hash") as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id}) + _ = self._flags.get_variant_value( + TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id} + ) self._mock_tracker.assert_called_once() @@ -561,7 +638,9 @@ async def test_get_variant_value_does_not_track_exposure_on_fallback(self): async def test_get_variant_value_does_not_track_exposure_without_distinct_id(self): flag = create_test_flag(context="company") await self.setup_flags([flag]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"company_id": "company123"}) + _ = self._flags.get_variant_value( + "nonexistent_flag", "fallback", {"company_id": "company123"} + ) self._mock_tracker.assert_not_called() @respx.mock @@ -575,7 +654,9 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): assert len(result) == 2 and "flag1" in result and "flag2" in result @respx.mock - async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollout(self): + async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollout( + self, + ): flag1 = create_test_flag(flag_key="flag1", rollout_percentage=100.0) flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) @@ -624,7 +705,6 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): await self.setup_flags([]) assert self._flags.are_flags_ready() == True - @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) @@ -633,9 +713,7 @@ async def test_is_enabled_returns_false_for_nonexistent_flag(self): @respx.mock async def test_is_enabled_returns_true_for_true_variant_value(self): - variants = [ - Variant(key="treatment", value=True, is_control=False, split=100.0) - ] + variants = [Variant(key="treatment", value=True, is_control=False, split=100.0)] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) @@ -654,28 +732,41 @@ async def track_fetch_calls(self): polling_limit_check.notify_all() return await original_fetch(self) - with patch.object(LocalFeatureFlagsProvider, '_afetch_flag_definitions', track_fetch_calls): + with patch.object( + LocalFeatureFlagsProvider, "_afetch_flag_definitions", track_fetch_calls + ): flag_v1 = create_test_flag(rollout_percentage=0.0) flag_v2 = create_test_flag(rollout_percentage=100.0) - flags_in_order=[[flag_v1], [flag_v2]] + flags_in_order = [[flag_v1], [flag_v2]] await self.setup_flags_with_polling(flags_in_order) async with polling_limit_check: - await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) + await polling_limit_check.wait_for( + lambda: polling_iterations >= len(flags_in_order) + ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + result2 = self._flags_with_polling.get_variant_value( + TEST_FLAG_KEY, "fallback", USER_CONTEXT + ) assert result2 != "fallback" + class TestLocalFeatureFlagsProviderSync: def setup_method(self): self.mock_tracker = Mock() - config_with_polling = LocalFlagsConfig(enable_polling=True, polling_interval_in_seconds=0) - self._flags_with_polling = LocalFeatureFlagsProvider("test-token", config_with_polling, "1.0.0", self.mock_tracker) + config_with_polling = LocalFlagsConfig( + enable_polling=True, polling_interval_in_seconds=0 + ) + self._flags_with_polling = LocalFeatureFlagsProvider( + "test-token", config_with_polling, "1.0.0", self.mock_tracker + ) def teardown_method(self): self._flags_with_polling.__exit__(None, None, None) - def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag]] = [[]]): + def setup_flags_with_polling( + self, flags_in_order: List[List[ExperimentationFlag]] = [[]] + ): responses = [create_flags_response(flag) for flag in flags_in_order] respx.get("https://api.mixpanel.com/flags/definitions").mock( @@ -691,7 +782,7 @@ def setup_flags_with_polling(self, flags_in_order: List[List[ExperimentationFlag def test_get_variant_value_uses_most_recent_polled_flag(self): flag_v1 = create_test_flag(rollout_percentage=0.0) flag_v2 = create_test_flag(rollout_percentage=100.0) - flags_in_order=[[flag_v1], [flag_v2]] + flags_in_order = [[flag_v1], [flag_v2]] polling_iterations = 0 polling_event = threading.Event() @@ -705,9 +796,13 @@ def track_fetch_calls(self): polling_event.set() return original_fetch(self) - with patch.object(LocalFeatureFlagsProvider, '_fetch_flag_definitions', track_fetch_calls): + with patch.object( + LocalFeatureFlagsProvider, "_fetch_flag_definitions", track_fetch_calls + ): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) - assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) + assert polling_iterations >= 3 + result2 = self._flags_with_polling.get_variant_value( + TEST_FLAG_KEY, "fallback", USER_CONTEXT + ) assert result2 != "fallback" diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index c2e312e..c1784e8 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -9,16 +9,24 @@ ENDPOINT = "https://api.mixpanel.com/flags" -def create_success_response(assigned_variants_per_flag: Dict[str, SelectedVariant]) -> httpx.Response: - serialized_response = RemoteFlagsResponse(code=200, flags=assigned_variants_per_flag).model_dump() + +def create_success_response( + assigned_variants_per_flag: Dict[str, SelectedVariant], +) -> httpx.Response: + serialized_response = RemoteFlagsResponse( + code=200, flags=assigned_variants_per_flag + ).model_dump() return httpx.Response(status_code=200, json=serialized_response) + class TestRemoteFeatureFlagsProviderAsync: @pytest.fixture(autouse=True) async def setup_method(self): config = RemoteFlagsConfig() self.mock_tracker = Mock() - self._flags = RemoteFeatureFlagsProvider("test-token", config, "1.0.0", self.mock_tracker) + self._flags = RemoteFeatureFlagsProvider( + "test-token", config, "1.0.0", self.mock_tracker + ) yield await self._flags.__aexit__(None, None, None) @@ -27,55 +35,91 @@ async def setup_method(self): async def test_get_variant_value_is_fallback_if_call_fails(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) - result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) - assert result == "control" + result = await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) + assert result == "control" @respx.mock async def test_get_variant_value_is_fallback_if_bad_response_format(self): respx.get(ENDPOINT).mock(return_value=httpx.Response(200, text="invalid json")) - result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "control" @respx.mock async def test_get_variant_value_is_fallback_if_success_but_no_flag_found(self): - respx.get(ENDPOINT).mock( - return_value=create_success_response({})) + respx.get(ENDPOINT).mock(return_value=create_success_response({})) - result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "control" @respx.mock async def test_get_variant_value_returns_expected_variant_from_api(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) - - result = await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="treatment", variant_value="treatment" + ) + } + ) + ) + + result = await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "treatment" @respx.mock async def test_get_variant_value_tracks_exposure_event_if_variant_selected(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) - - await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) - - pending = [task for task in asyncio.all_tasks() if not task.done() and task != asyncio.current_task()] + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="treatment", variant_value="treatment" + ) + } + ) + ) + + await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) + + pending = [ + task + for task in asyncio.all_tasks() + if not task.done() and task != asyncio.current_task() + ] if pending: await asyncio.gather(*pending, return_exceptions=True) self.mock_tracker.assert_called_once() @respx.mock - async def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): + async def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) - await self._flags.aget_variant_value("test_flag", "control", {"distinct_id": "user123"}) + await self._flags.aget_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) self.mock_tracker.assert_not_called() @respx.mock async def test_ais_enabled_returns_true_for_true_variant_value(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="enabled", variant_value=True)})) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="enabled", variant_value=True + ) + } + ) + ) result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) assert result == True @@ -83,7 +127,14 @@ async def test_ais_enabled_returns_true_for_true_variant_value(self): @respx.mock async def test_ais_enabled_returns_false_for_false_variant_value(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="disabled", variant_value=False)})) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="disabled", variant_value=False + ) + } + ) + ) result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) assert result == False @@ -92,7 +143,7 @@ async def test_ais_enabled_returns_false_for_false_variant_value(self): async def test_aget_all_variants_returns_all_variants_from_api(self): variants = { "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), - "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2"), } respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) @@ -112,7 +163,7 @@ async def test_aget_all_variants_returns_none_on_network_error(self): async def test_aget_all_variants_does_not_track_exposure_events(self): variants = { "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), - "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2"), } respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) @@ -132,19 +183,28 @@ async def test_aget_all_variants_handles_empty_response(self): async def test_atrack_exposure_event_successfully_tracks(self): variant = SelectedVariant(variant_key="treatment", variant_value="treatment") - await self._flags.atrack_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + await self._flags.atrack_exposure_event( + "test_flag", variant, {"distinct_id": "user123"} + ) - pending = [task for task in asyncio.all_tasks() if not task.done() and task != asyncio.current_task()] + pending = [ + task + for task in asyncio.all_tasks() + if not task.done() and task != asyncio.current_task() + ] if pending: await asyncio.gather(*pending, return_exceptions=True) self.mock_tracker.assert_called_once() + class TestRemoteFeatureFlagsProviderSync: def setup_method(self): config = RemoteFlagsConfig() self.mock_tracker = Mock() - self._flags = RemoteFeatureFlagsProvider("test-token", config, "1.0.0", self.mock_tracker) + self._flags = RemoteFeatureFlagsProvider( + "test-token", config, "1.0.0", self.mock_tracker + ) def teardown_method(self): self._flags.__exit__(None, None, None) @@ -153,50 +213,82 @@ def teardown_method(self): def test_get_variant_value_is_fallback_if_call_fails(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "control" @respx.mock def test_get_variant_value_is_fallback_if_bad_response_format(self): respx.get(ENDPOINT).mock(return_value=httpx.Response(200, text="invalid json")) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "control" @respx.mock def test_get_variant_value_is_fallback_if_success_but_no_flag_found(self): - respx.get(ENDPOINT).mock( - return_value=create_success_response({})) + respx.get(ENDPOINT).mock(return_value=create_success_response({})) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "control" @respx.mock def test_get_variant_value_returns_expected_variant_from_api(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) - - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="treatment", variant_value="treatment" + ) + } + ) + ) + + result = self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) assert result == "treatment" @respx.mock def test_get_variant_value_tracks_exposure_event_if_variant_selected(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="treatment", variant_value="treatment")})) - - self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="treatment", variant_value="treatment" + ) + } + ) + ) + + self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) self.mock_tracker.assert_called_once() @respx.mock def test_get_variant_value_does_not_track_exposure_event_if_fallback(self): respx.get(ENDPOINT).mock(side_effect=httpx.RequestError("Network error")) - self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + self._flags.get_variant_value( + "test_flag", "control", {"distinct_id": "user123"} + ) self.mock_tracker.assert_not_called() @respx.mock def test_is_enabled_returns_true_for_true_variant_value(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="enabled", variant_value=True)})) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="enabled", variant_value=True + ) + } + ) + ) result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) assert result == True @@ -204,7 +296,14 @@ def test_is_enabled_returns_true_for_true_variant_value(self): @respx.mock def test_is_enabled_returns_false_for_false_variant_value(self): respx.get(ENDPOINT).mock( - return_value=create_success_response({"test_flag": SelectedVariant(variant_key="disabled", variant_value=False)})) + return_value=create_success_response( + { + "test_flag": SelectedVariant( + variant_key="disabled", variant_value=False + ) + } + ) + ) result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) assert result == False @@ -213,7 +312,7 @@ def test_is_enabled_returns_false_for_false_variant_value(self): def test_get_all_variants_returns_all_variants_from_api(self): variants = { "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), - "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2"), } respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) @@ -233,7 +332,7 @@ def test_get_all_variants_returns_none_on_network_error(self): def test_get_all_variants_does_not_track_exposure_events(self): variants = { "flag1": SelectedVariant(variant_key="treatment1", variant_value="value1"), - "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2") + "flag2": SelectedVariant(variant_key="treatment2", variant_value="value2"), } respx.get(ENDPOINT).mock(return_value=create_success_response(variants)) @@ -253,7 +352,8 @@ def test_get_all_variants_handles_empty_response(self): def test_track_exposure_event_successfully_tracks(self): variant = SelectedVariant(variant_key="treatment", variant_value="treatment") - self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event( + "test_flag", variant, {"distinct_id": "user123"} + ) self.mock_tracker.assert_called_once() - diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index b60b514..f610f39 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -4,20 +4,28 @@ import string from .utils import generate_traceparent, normalized_hash + class TestUtils: def test_traceparent_format_is_correct(self): traceparent = generate_traceparent() # W3C traceparent format: 00-{32 hex chars}-{16 hex chars}-{2 hex chars} # https://www.w3.org/TR/trace-context/#traceparent-header - pattern = r'^00-[0-9a-f]{32}-[0-9a-f]{16}-01$' + pattern = r"^00-[0-9a-f]{32}-[0-9a-f]{16}-01$" - assert re.match(pattern, traceparent), f"Traceparent '{traceparent}' does not match W3C format" + assert re.match(pattern, traceparent), ( + f"Traceparent '{traceparent}' does not match W3C format" + ) - @pytest.mark.parametrize("key,salt,expected_hash", [ - ("abc", "variant", 0.72), - ("def", "variant", 0.21), - ]) + @pytest.mark.parametrize( + "key,salt,expected_hash", + [ + ("abc", "variant", 0.72), + ("def", "variant", 0.21), + ], + ) def test_normalized_hash_for_known_inputs(self, key, salt, expected_hash): result = normalized_hash(key, salt) - assert result == expected_hash, f"Expected hash of {expected_hash} for '{key}' with salt '{salt}', got {result}" \ No newline at end of file + assert result == expected_hash, ( + f"Expected hash of {expected_hash} for '{key}' with salt '{salt}', got {result}" + ) diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 9a76f4e..3d1e583 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -3,43 +3,52 @@ MIXPANEL_DEFAULT_API_ENDPOINT = "api.mixpanel.com" + class FlagsConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) api_host: str = "api.mixpanel.com" request_timeout_in_seconds: int = 10 + class LocalFlagsConfig(FlagsConfig): enable_polling: bool = True polling_interval_in_seconds: int = 60 + class RemoteFlagsConfig(FlagsConfig): pass + class Variant(BaseModel): key: str value: Any is_control: bool split: Optional[float] = 0.0 + class FlagTestUsers(BaseModel): users: Dict[str, str] + class VariantOverride(BaseModel): key: str + class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None runtime_evaluation_rule: Optional[Dict[Any, Any]] = None variant_override: Optional[VariantOverride] = None - variant_splits: Optional[Dict[str,float]] = None + variant_splits: Optional[Dict[str, float]] = None + class RuleSet(BaseModel): variants: List[Variant] rollout: List[Rollout] test: Optional[FlagTestUsers] = None + class ExperimentationFlag(BaseModel): id: str name: str @@ -63,8 +72,9 @@ class SelectedVariant(BaseModel): class ExperimentationFlags(BaseModel): - flags: List[ExperimentationFlag] + flags: List[ExperimentationFlag] + class RemoteFlagsResponse(BaseModel): code: int - flags: Dict[str, SelectedVariant] \ No newline at end of file + flags: Dict[str, SelectedVariant] diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 863a705..4744ea1 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -5,11 +5,12 @@ EXPOSURE_EVENT = "$experiment_started" REQUEST_HEADERS: Dict[str, str] = { - 'X-Scheme': 'https', - 'X-Forwarded-Proto': 'https', - 'Content-Type': 'application/json' + "X-Scheme": "https", + "X-Forwarded-Proto": "https", + "Content-Type": "application/json", } + def normalized_hash(key: str, salt: str) -> float: """Compute a normalized hash using FNV-1a algorithm. @@ -20,22 +21,24 @@ def normalized_hash(key: str, salt: str) -> float: hash_value = _fnv1a64(key.encode("utf-8") + salt.encode("utf-8")) return (hash_value % 100) / 100.0 + def _fnv1a64(data: bytes) -> int: """FNV-1a 64-bit hash function. :param data: Bytes to hash :return: 64-bit hash value """ - FNV_prime = 0x100000001b3 - hash_value = 0xcbf29ce484222325 + FNV_prime = 0x100000001B3 + hash_value = 0xCBF29CE484222325 for byte in data: hash_value ^= byte hash_value *= FNV_prime - hash_value &= 0xffffffffffffffff # Keep it 64-bit + hash_value &= 0xFFFFFFFFFFFFFFFF # Keep it 64-bit return hash_value + def prepare_common_query_params(token: str, sdk_version: str) -> Dict[str, str]: """Prepare common query string parameters for feature flag evaluation. @@ -43,14 +46,11 @@ def prepare_common_query_params(token: str, sdk_version: str) -> Dict[str, str]: :param sdk_version: The SDK version :return: Dictionary of common query parameters """ - params = { - 'mp_lib': 'python', - 'lib_version': sdk_version, - 'token': token - } + params = {"mp_lib": "python", "lib_version": sdk_version, "token": token} return params + def generate_traceparent() -> str: """Generates a W3C traceparent header for easy interop with distributed tracing systems i.e Open Telemetry https://www.w3.org/TR/trace-context/#traceparent-header @@ -60,7 +60,7 @@ def generate_traceparent() -> str: span_id = uuid.uuid4().hex[:16] # Trace flags: '01' for sampled - trace_flags = '01' + trace_flags = "01" traceparent = f"00-{trace_id}-{span_id}-{trace_flags}" - return traceparent \ No newline at end of file + return traceparent diff --git a/test_mixpanel.py b/test_mixpanel.py index 7018efb..0ea36e8 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -29,7 +29,7 @@ def clear(self): class TestMixpanelBase: - TOKEN = '12345' + TOKEN = "12345" def setup_method(self, method): self.consumer = LogConsumer() @@ -39,400 +39,505 @@ def setup_method(self, method): class TestMixpanelTracking(TestMixpanelBase): - def test_track(self): - self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}) - assert self.consumer.log == [( - 'events', { - 'event': 'button press', - 'properties': { - 'token': self.TOKEN, - 'size': 'big', - 'color': 'blue', - 'distinct_id': 'ID', - 'time': self.mp._now(), - '$insert_id': 'abc123', - 'mp_lib': 'python', - '$lib_version': mixpanel.__version__, - } - } - )] + self.mp.track( + "ID", + "button press", + {"size": "big", "color": "blue", "$insert_id": "abc123"}, + ) + assert self.consumer.log == [ + ( + "events", + { + "event": "button press", + "properties": { + "token": self.TOKEN, + "size": "big", + "color": "blue", + "distinct_id": "ID", + "time": self.mp._now(), + "$insert_id": "abc123", + "mp_lib": "python", + "$lib_version": mixpanel.__version__, + }, + }, + ) + ] def test_track_makes_insert_id(self): - self.mp.track('ID', 'button press', {'size': 'big'}) + self.mp.track("ID", "button press", {"size": "big"}) props = self.consumer.log[0][1]["properties"] assert "$insert_id" in props assert isinstance(props["$insert_id"], str) assert len(props["$insert_id"]) > 0 def test_track_empty(self): - self.mp.track('person_xyz', 'login', {}) - assert self.consumer.log == [( - 'events', { - 'event': 'login', - 'properties': { - 'token': self.TOKEN, - 'distinct_id': 'person_xyz', - 'time': self.mp._now(), - '$insert_id': self.mp._make_insert_id(), - 'mp_lib': 'python', - '$lib_version': mixpanel.__version__, + self.mp.track("person_xyz", "login", {}) + assert self.consumer.log == [ + ( + "events", + { + "event": "login", + "properties": { + "token": self.TOKEN, + "distinct_id": "person_xyz", + "time": self.mp._now(), + "$insert_id": self.mp._make_insert_id(), + "mp_lib": "python", + "$lib_version": mixpanel.__version__, + }, }, - }, - )] + ) + ] def test_import_data(self): timestamp = time.time() - self.mp.import_data('MY_API_KEY', 'ID', 'button press', timestamp, - {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, - api_secret='MY_SECRET') - assert self.consumer.log == [( - 'imports', { - 'event': 'button press', - 'properties': { - 'token': self.TOKEN, - 'size': 'big', - 'color': 'blue', - 'distinct_id': 'ID', - 'time': timestamp, - '$insert_id': 'abc123', - 'mp_lib': 'python', - '$lib_version': mixpanel.__version__, + self.mp.import_data( + "MY_API_KEY", + "ID", + "button press", + timestamp, + {"size": "big", "color": "blue", "$insert_id": "abc123"}, + api_secret="MY_SECRET", + ) + assert self.consumer.log == [ + ( + "imports", + { + "event": "button press", + "properties": { + "token": self.TOKEN, + "size": "big", + "color": "blue", + "distinct_id": "ID", + "time": timestamp, + "$insert_id": "abc123", + "mp_lib": "python", + "$lib_version": mixpanel.__version__, + }, }, - }, - ('MY_API_KEY', 'MY_SECRET'), - )] + ("MY_API_KEY", "MY_SECRET"), + ) + ] def test_track_meta(self): - self.mp.track('ID', 'button press', {'size': 'big', 'color': 'blue', '$insert_id': 'abc123'}, - meta={'ip': 0}) - assert self.consumer.log == [( - 'events', { - 'event': 'button press', - 'properties': { - 'token': self.TOKEN, - 'size': 'big', - 'color': 'blue', - 'distinct_id': 'ID', - 'time': self.mp._now(), - '$insert_id': 'abc123', - 'mp_lib': 'python', - '$lib_version': mixpanel.__version__, + self.mp.track( + "ID", + "button press", + {"size": "big", "color": "blue", "$insert_id": "abc123"}, + meta={"ip": 0}, + ) + assert self.consumer.log == [ + ( + "events", + { + "event": "button press", + "properties": { + "token": self.TOKEN, + "size": "big", + "color": "blue", + "distinct_id": "ID", + "time": self.mp._now(), + "$insert_id": "abc123", + "mp_lib": "python", + "$lib_version": mixpanel.__version__, + }, + "ip": 0, }, - 'ip': 0, - } - )] + ) + ] class TestMixpanelPeople(TestMixpanelBase): - def test_people_set(self): - self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.people_set( + "amq", {"birth month": "october", "favorite color": "purple"} + ) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$set": { + "birth month": "october", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_people_set_once(self): - self.mp.people_set_once('amq', {'birth month': 'october', 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set_once': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.people_set_once( + "amq", {"birth month": "october", "favorite color": "purple"} + ) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$set_once": { + "birth month": "october", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_people_increment(self): - self.mp.people_increment('amq', {'Albums Released': 1}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$add': { - 'Albums Released': 1, + self.mp.people_increment("amq", {"Albums Released": 1}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$add": { + "Albums Released": 1, + }, }, - } - )] + ) + ] def test_people_append(self): - self.mp.people_append('amq', {'birth month': 'october', 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$append': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.people_append( + "amq", {"birth month": "october", "favorite color": "purple"} + ) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$append": { + "birth month": "october", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_people_union(self): - self.mp.people_union('amq', {'Albums': ['Diamond Dogs']}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$union': { - 'Albums': ['Diamond Dogs'], + self.mp.people_union("amq", {"Albums": ["Diamond Dogs"]}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$union": { + "Albums": ["Diamond Dogs"], + }, }, - } - )] + ) + ] def test_people_unset(self): - self.mp.people_unset('amq', ['Albums', 'Singles']) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$unset': ['Albums', 'Singles'], - } - )] + self.mp.people_unset("amq", ["Albums", "Singles"]) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$unset": ["Albums", "Singles"], + }, + ) + ] def test_people_remove(self): - self.mp.people_remove('amq', {'Albums': 'Diamond Dogs'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$remove': {'Albums': 'Diamond Dogs'}, - } - )] + self.mp.people_remove("amq", {"Albums": "Diamond Dogs"}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$remove": {"Albums": "Diamond Dogs"}, + }, + ) + ] def test_people_track_charge(self): - self.mp.people_track_charge('amq', 12.65, {'$time': '2013-04-01T09:02:00'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$append': { - '$transactions': { - '$time': '2013-04-01T09:02:00', - '$amount': 12.65, + self.mp.people_track_charge("amq", 12.65, {"$time": "2013-04-01T09:02:00"}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$append": { + "$transactions": { + "$time": "2013-04-01T09:02:00", + "$amount": 12.65, + }, }, }, - } - )] + ) + ] def test_people_track_charge_without_properties(self): - self.mp.people_track_charge('amq', 12.65) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$append': { - '$transactions': { - '$amount': 12.65, + self.mp.people_track_charge("amq", 12.65) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$append": { + "$transactions": { + "$amount": 12.65, + }, }, }, - } - )] + ) + ] def test_people_clear_charges(self): - self.mp.people_clear_charges('amq') - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$unset': ['$transactions'], - } - )] + self.mp.people_clear_charges("amq") + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$unset": ["$transactions"], + }, + ) + ] def test_people_set_created_date_string(self): - created = '2014-02-14T01:02:03' - self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set': { - '$created': created, - 'favorite color': 'purple', + created = "2014-02-14T01:02:03" + self.mp.people_set("amq", {"$created": created, "favorite color": "purple"}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$set": { + "$created": created, + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_people_set_created_date_datetime(self): created = datetime.datetime(2014, 2, 14, 1, 2, 3) - self.mp.people_set('amq', {'$created': created, 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set': { - '$created': '2014-02-14T01:02:03', - 'favorite color': 'purple', + self.mp.people_set("amq", {"$created": created, "favorite color": "purple"}) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$set": { + "$created": "2014-02-14T01:02:03", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_people_meta(self): - self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}, - meta={'$ip': 0, '$ignore_time': True}) - assert self.consumer.log == [( - 'people', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$distinct_id': 'amq', - '$set': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.people_set( + "amq", + {"birth month": "october", "favorite color": "purple"}, + meta={"$ip": 0, "$ignore_time": True}, + ) + assert self.consumer.log == [ + ( + "people", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$distinct_id": "amq", + "$set": { + "birth month": "october", + "favorite color": "purple", + }, + "$ip": 0, + "$ignore_time": True, }, - '$ip': 0, - '$ignore_time': True, - } - )] + ) + ] class TestMixpanelIdentity(TestMixpanelBase): - def test_alias(self): # More complicated since alias() forces a synchronous call. with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 1, "error": None}, status=200, ) - self.mp.alias('ALIAS', 'ORIGINAL ID') + self.mp.alias("ALIAS", "ORIGINAL ID") assert self.consumer.log == [] call = rsps.calls[0] assert call.request.method == "POST" assert call.request.url == "https://api.mixpanel.com/track" - body = call.request.body if isinstance(call.request.body, str) else call.request.body.decode('utf-8') + body = ( + call.request.body + if isinstance(call.request.body, str) + else call.request.body.decode("utf-8") + ) posted_data = dict(urllib_parse.parse_qsl(body)) - assert json.loads(posted_data["data"]) == {"event":"$create_alias","properties":{"alias":"ALIAS","token":"12345","distinct_id":"ORIGINAL ID"}} + assert json.loads(posted_data["data"]) == { + "event": "$create_alias", + "properties": { + "alias": "ALIAS", + "token": "12345", + "distinct_id": "ORIGINAL ID", + }, + } def test_merge(self): - self.mp.merge('my_good_api_key', 'd1', 'd2') - assert self.consumer.log == [( - 'imports', - { - 'event': '$merge', - 'properties': { - '$distinct_ids': ['d1', 'd2'], - 'token': self.TOKEN, - } - }, - ('my_good_api_key', None), - )] + self.mp.merge("my_good_api_key", "d1", "d2") + assert self.consumer.log == [ + ( + "imports", + { + "event": "$merge", + "properties": { + "$distinct_ids": ["d1", "d2"], + "token": self.TOKEN, + }, + }, + ("my_good_api_key", None), + ) + ] self.consumer.clear() - self.mp.merge('my_good_api_key', 'd1', 'd2', api_secret='my_secret') - assert self.consumer.log == [( - 'imports', - { - 'event': '$merge', - 'properties': { - '$distinct_ids': ['d1', 'd2'], - 'token': self.TOKEN, - } - }, - ('my_good_api_key', 'my_secret'), - )] + self.mp.merge("my_good_api_key", "d1", "d2", api_secret="my_secret") + assert self.consumer.log == [ + ( + "imports", + { + "event": "$merge", + "properties": { + "$distinct_ids": ["d1", "d2"], + "token": self.TOKEN, + }, + }, + ("my_good_api_key", "my_secret"), + ) + ] class TestMixpanelGroups(TestMixpanelBase): - def test_group_set(self): - self.mp.group_set('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'groups', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$group_key': 'company', - '$group_id': 'amq', - '$set': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.group_set( + "company", "amq", {"birth month": "october", "favorite color": "purple"} + ) + assert self.consumer.log == [ + ( + "groups", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$group_key": "company", + "$group_id": "amq", + "$set": { + "birth month": "october", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_group_set_once(self): - self.mp.group_set_once('company', 'amq', {'birth month': 'october', 'favorite color': 'purple'}) - assert self.consumer.log == [( - 'groups', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$group_key': 'company', - '$group_id': 'amq', - '$set_once': { - 'birth month': 'october', - 'favorite color': 'purple', + self.mp.group_set_once( + "company", "amq", {"birth month": "october", "favorite color": "purple"} + ) + assert self.consumer.log == [ + ( + "groups", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$group_key": "company", + "$group_id": "amq", + "$set_once": { + "birth month": "october", + "favorite color": "purple", + }, }, - } - )] + ) + ] def test_group_union(self): - self.mp.group_union('company', 'amq', {'Albums': ['Diamond Dogs']}) - assert self.consumer.log == [( - 'groups', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$group_key': 'company', - '$group_id': 'amq', - '$union': { - 'Albums': ['Diamond Dogs'], + self.mp.group_union("company", "amq", {"Albums": ["Diamond Dogs"]}) + assert self.consumer.log == [ + ( + "groups", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$group_key": "company", + "$group_id": "amq", + "$union": { + "Albums": ["Diamond Dogs"], + }, }, - } - )] + ) + ] def test_group_unset(self): - self.mp.group_unset('company', 'amq', ['Albums', 'Singles']) - assert self.consumer.log == [( - 'groups', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$group_key': 'company', - '$group_id': 'amq', - '$unset': ['Albums', 'Singles'], - } - )] + self.mp.group_unset("company", "amq", ["Albums", "Singles"]) + assert self.consumer.log == [ + ( + "groups", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$group_key": "company", + "$group_id": "amq", + "$unset": ["Albums", "Singles"], + }, + ) + ] def test_group_remove(self): - self.mp.group_remove('company', 'amq', {'Albums': 'Diamond Dogs'}) - assert self.consumer.log == [( - 'groups', { - '$time': self.mp._now(), - '$token': self.TOKEN, - '$group_key': 'company', - '$group_id': 'amq', - '$remove': {'Albums': 'Diamond Dogs'}, - } - )] + self.mp.group_remove("company", "amq", {"Albums": "Diamond Dogs"}) + assert self.consumer.log == [ + ( + "groups", + { + "$time": self.mp._now(), + "$token": self.TOKEN, + "$group_key": "company", + "$group_id": "amq", + "$remove": {"Albums": "Diamond Dogs"}, + }, + ) + ] def test_custom_json_serializer(self): - decimal_string = '12.05' + decimal_string = "12.05" with pytest.raises(TypeError) as excinfo: - self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string)}) + self.mp.track( + "ID", "button press", {"size": decimal.Decimal(decimal_string)} + ) assert "not JSON serializable" in str(excinfo.value) class CustomSerializer(mixpanel.DatetimeSerializer): @@ -441,21 +546,28 @@ def default(self, obj): return obj.to_eng_string() self.mp._serializer = CustomSerializer - self.mp.track('ID', 'button press', {'size': decimal.Decimal(decimal_string), '$insert_id': 'abc123'}) - assert self.consumer.log == [( - 'events', { - 'event': 'button press', - 'properties': { - 'token': self.TOKEN, - 'size': decimal_string, - 'distinct_id': 'ID', - 'time': self.mp._now(), - '$insert_id': 'abc123', - 'mp_lib': 'python', - '$lib_version': mixpanel.__version__, - } - } - )] + self.mp.track( + "ID", + "button press", + {"size": decimal.Decimal(decimal_string), "$insert_id": "abc123"}, + ) + assert self.consumer.log == [ + ( + "events", + { + "event": "button press", + "properties": { + "token": self.TOKEN, + "size": decimal_string, + "distinct_id": "ID", + "time": self.mp._now(), + "$insert_id": "abc123", + "mp_lib": "python", + "$lib_version": mixpanel.__version__, + }, + }, + ) + ] class TestConsumer: @@ -467,87 +579,115 @@ def test_send_events(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 1, "error": None}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) - self.consumer.send('events', '{"foo":"bar"}') + self.consumer.send("events", '{"foo":"bar"}') def test_send_people(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/engage', + "https://api.mixpanel.com/engage", json={"status": 1, "error": None}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) - self.consumer.send('people', '{"foo":"bar"}') + self.consumer.send("people", '{"foo":"bar"}') def test_server_success(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 1, "error": None}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) - self.consumer.send('events', '{"foo":"bar"}') + self.consumer.send("events", '{"foo":"bar"}') def test_server_invalid_data(self): with responses.RequestsMock() as rsps: error_msg = "bad data" rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 0, "error": error_msg}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{INVALID "foo":"bar"}'} + ) + ], ) with pytest.raises(mixpanel.MixpanelException) as exc: - self.consumer.send('events', '{INVALID "foo":"bar"}') + self.consumer.send("events", '{INVALID "foo":"bar"}') assert error_msg in str(exc) def test_server_unauthorized(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 0, "error": "unauthed"}, status=401, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) with pytest.raises(mixpanel.MixpanelException) as exc: - self.consumer.send('events', '{"foo":"bar"}') + self.consumer.send("events", '{"foo":"bar"}') assert "unauthed" in str(exc) def test_server_forbidden(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 0, "error": "forbade"}, status=403, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) with pytest.raises(mixpanel.MixpanelException) as exc: - self.consumer.send('events', '{"foo":"bar"}') + self.consumer.send("events", '{"foo":"bar"}') assert "forbade" in str(exc) def test_server_5xx(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", body="Internal server error", status=500, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) with pytest.raises(mixpanel.MixpanelException) as exc: - self.consumer.send('events', '{"foo":"bar"}') + self.consumer.send("events", '{"foo":"bar"}') def test_consumer_override_api_host(self): consumer = mixpanel.Consumer(api_host="api-zoltan.mixpanel.com") @@ -555,26 +695,34 @@ def test_consumer_override_api_host(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api-zoltan.mixpanel.com/track', + "https://api-zoltan.mixpanel.com/track", json={"status": 1, "error": None}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) - consumer.send('events', '{"foo":"bar"}') + consumer.send("events", '{"foo":"bar"}') with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api-zoltan.mixpanel.com/engage', + "https://api-zoltan.mixpanel.com/engage", json={"status": 1, "error": None}, status=200, - match=[urlencoded_params_matcher({"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'})], + match=[ + urlencoded_params_matcher( + {"ip": "0", "verbose": "1", "data": '{"foo":"bar"}'} + ) + ], ) - consumer.send('people', '{"foo":"bar"}') + consumer.send("people", '{"foo":"bar"}') def test_unknown_endpoint(self): with pytest.raises(mixpanel.MixpanelException): - self.consumer.send('unknown', '1') + self.consumer.send("unknown", "1") class TestBufferedConsumer: @@ -589,65 +737,76 @@ def setup_method(self): del self.log[:] def test_buffer_hold_and_flush(self): - self.consumer.send('events', '"Event"') + self.consumer.send("events", '"Event"') assert len(self.log) == 0 self.consumer.flush() - assert self.log == [('events', ['Event'])] + assert self.log == [("events", ["Event"])] def test_buffer_fills_up(self): for i in range(self.MAX_LENGTH - 1): - self.consumer.send('events', '"Event"') + self.consumer.send("events", '"Event"') assert len(self.log) == 0 - self.consumer.send('events', '"Last Event"') + self.consumer.send("events", '"Last Event"') assert len(self.log) == 1 - assert self.log == [('events', [ - 'Event', 'Event', 'Event', 'Event', 'Event', - 'Event', 'Event', 'Event', 'Event', 'Last Event', - ])] + assert self.log == [ + ( + "events", + [ + "Event", + "Event", + "Event", + "Event", + "Event", + "Event", + "Event", + "Event", + "Event", + "Last Event", + ], + ) + ] def test_unknown_endpoint_raises_on_send(self): # Ensure the exception isn't hidden until a flush. with pytest.raises(mixpanel.MixpanelException): - self.consumer.send('unknown', '1') + self.consumer.send("unknown", "1") def test_useful_reraise_in_flush_endpoint(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 0, "error": "arbitrary error"}, status=200, ) - broken_json = '{broken JSON' + broken_json = "{broken JSON" consumer = mixpanel.BufferedConsumer(2) - consumer.send('events', broken_json) + consumer.send("events", broken_json) with pytest.raises(mixpanel.MixpanelException) as excinfo: consumer.flush() - assert excinfo.value.message == '[%s]' % broken_json - assert excinfo.value.endpoint == 'events' + assert excinfo.value.message == "[%s]" % broken_json + assert excinfo.value.endpoint == "events" def test_send_remembers_api_key(self): - self.consumer.send('imports', '"Event"', api_key='MY_API_KEY') + self.consumer.send("imports", '"Event"', api_key="MY_API_KEY") assert len(self.log) == 0 self.consumer.flush() - assert self.log == [('imports', ['Event'], ('MY_API_KEY', None))] + assert self.log == [("imports", ["Event"], ("MY_API_KEY", None))] def test_send_remembers_api_secret(self): - self.consumer.send('imports', '"Event"', api_secret='ZZZZZZ') + self.consumer.send("imports", '"Event"', api_secret="ZZZZZZ") assert len(self.log) == 0 self.consumer.flush() - assert self.log == [('imports', ['Event'], (None, 'ZZZZZZ'))] - - + assert self.log == [("imports", ["Event"], (None, "ZZZZZZ"))] class TestFunctional: @classmethod def setup_class(cls): - cls.TOKEN = '12345' + cls.TOKEN = "12345" cls.mp = mixpanel.Mixpanel(cls.TOKEN) cls.mp._now = lambda: 1000 @@ -655,12 +814,16 @@ def test_track_functional(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/track', + "https://api.mixpanel.com/track", json={"status": 1, "error": None}, status=200, ) - self.mp.track('player1', 'button_press', {'size': 'big', 'color': 'blue', '$insert_id': 'xyz1200'}) + self.mp.track( + "player1", + "button_press", + {"size": "big", "color": "blue", "$insert_id": "xyz1200"}, + ) body = rsps.calls[0].request.body wrapper = dict(urllib_parse.parse_qsl(body)) @@ -668,24 +831,43 @@ def test_track_functional(self): del wrapper["data"] assert {"ip": "0", "verbose": "1"} == wrapper - 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'}} + 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", + }, + } assert expected_data == data def test_people_set_functional(self): with responses.RequestsMock() as rsps: rsps.add( responses.POST, - 'https://api.mixpanel.com/engage', + "https://api.mixpanel.com/engage", json={"status": 1, "error": None}, status=200, ) - self.mp.people_set('amq', {'birth month': 'october', 'favorite color': 'purple'}) + self.mp.people_set( + "amq", {"birth month": "october", "favorite color": "purple"} + ) body = rsps.calls[0].request.body wrapper = dict(urllib_parse.parse_qsl(body)) data = json.loads(wrapper["data"]) del wrapper["data"] assert {"ip": "0", "verbose": "1"} == wrapper - expected_data = {'$distinct_id': 'amq', '$set': {'birth month': 'october', 'favorite color': 'purple'}, '$time': 1000, '$token': '12345'} + expected_data = { + "$distinct_id": "amq", + "$set": {"birth month": "october", "favorite color": "purple"}, + "$time": 1000, + "$token": "12345", + } assert expected_data == data From ab2ab57f9386f523c6c1834658ff211ef04ac4b9 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Mon, 23 Mar 2026 12:26:42 -0700 Subject: [PATCH 202/208] Add comprehensive ruff linting configuration (#159) --- .github/workflows/test.yml | 2 + .pre-commit-config.yaml | 7 + demo/local_flags.py | 4 +- demo/remote_flags.py | 3 +- demo/subprocess_consumer.py | 4 +- docs/conf.py | 7 +- mixpanel/__init__.py | 71 ++++---- mixpanel/flags/local_feature_flags.py | 170 ++++++++++---------- mixpanel/flags/remote_feature_flags.py | 168 +++++++++---------- mixpanel/flags/test_local_feature_flags.py | 72 +++++---- mixpanel/flags/test_remote_feature_flags.py | 23 +-- mixpanel/flags/test_utils.py | 8 +- mixpanel/flags/types.py | 19 +-- mixpanel/flags/utils.py | 26 ++- pyproject.toml | 82 ++++++++++ test_mixpanel.py | 17 +- 16 files changed, 386 insertions(+), 297 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a72725d..86261f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Check formatting run: uvx ruff format --check . + - name: Check linting + run: uvx ruff check . test: runs-on: ubuntu-24.04 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..74e18a4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff-format + - id: ruff + args: [--fix] diff --git a/demo/local_flags.py b/demo/local_flags.py index f9d8744..8071a15 100644 --- a/demo/local_flags.py +++ b/demo/local_flags.py @@ -1,8 +1,8 @@ -import os import asyncio -import mixpanel import logging +import mixpanel + logging.basicConfig(level=logging.INFO) # Configure your project token, the feature flag to test, and user context to evaluate. diff --git a/demo/remote_flags.py b/demo/remote_flags.py index 66ebbf4..5834c14 100644 --- a/demo/remote_flags.py +++ b/demo/remote_flags.py @@ -1,7 +1,8 @@ import asyncio -import mixpanel import logging +import mixpanel + logging.basicConfig(level=logging.INFO) # Configure your project token, the feature flag to test, and user context to evaluate. diff --git a/demo/subprocess_consumer.py b/demo/subprocess_consumer.py index 69eb560..cf1654b 100644 --- a/demo/subprocess_consumer.py +++ b/demo/subprocess_consumer.py @@ -1,7 +1,7 @@ import multiprocessing import random -from mixpanel import Mixpanel, BufferedConsumer +from mixpanel import BufferedConsumer, Mixpanel """ As your application scales, it's likely you'll want to @@ -23,7 +23,7 @@ """ -class QueueWriteConsumer(object): +class QueueWriteConsumer: def __init__(self, queue): self.queue = queue diff --git a/docs/conf.py b/docs/conf.py index eb0cb82..330eca3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- import sys -import os +from pathlib import Path # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath("..")) +# documentation root, use Path.resolve() to make it absolute, like shown here. +sys.path.insert(0, str(Path("..").resolve())) extensions = [ "sphinx.ext.autodoc", diff --git a/mixpanel/__init__.py b/mixpanel/__init__.py index 3d16723..833c871 100644 --- a/mixpanel/__init__.py +++ b/mixpanel/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """This is the official Mixpanel client library for Python. Mixpanel client libraries allow for tracking events and setting properties on @@ -20,12 +19,11 @@ import logging import time import uuid +from typing import Optional import requests -from requests.auth import HTTPBasicAuth import urllib3 - -from typing import Optional +from requests.auth import HTTPBasicAuth from .flags.local_feature_flags import LocalFeatureFlagsProvider from .flags.remote_feature_flags import RemoteFeatureFlagsProvider @@ -98,7 +96,7 @@ def _make_insert_id(self): @property def local_flags(self) -> LocalFeatureFlagsProvider: - """Get the local flags provider if configured for it""" + """Get the local flags provider if configured for it.""" if self._local_flags_provider is None: raise MixpanelException( "No local flags provider initialized. Pass local_flags_config to constructor." @@ -107,7 +105,7 @@ def local_flags(self) -> LocalFeatureFlagsProvider: @property def remote_flags(self) -> RemoteFeatureFlagsProvider: - """Get the remote flags provider if configured for it""" + """Get the remote flags provider if configured for it.""" if self._remote_flags_provider is None: raise MixpanelException( "No remote_flags_config was passed to the consttructor" @@ -182,7 +180,6 @@ def import_data( for `more details `__. """ - if api_secret is None: logger.warning( "api_key will soon be removed from mixpanel-python; please use api_secret instead." @@ -242,8 +239,7 @@ def alias(self, alias_id, original, meta=None): sync_consumer.send("events", json_dumps(event, cls=self._serializer)) def merge(self, api_key, distinct_id1, distinct_id2, meta=None, api_secret=None): - """ - Merges the two given distinct_ids. + """Merges the two given distinct_ids. :param str api_key: (DEPRECATED) Your Mixpanel project's API key. :param str distinct_id1: The first distinct_id to merge. @@ -587,7 +583,7 @@ def group_delete(self, group_key, group_id, meta=None): ) def group_update(self, message, meta=None): - """Send a generic group profile update + """Send a generic group profile update. :param dict message: the message to send @@ -626,20 +622,18 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self._remote_flags_provider.__aexit__(exc_type, exc_val, exc_tb) -class MixpanelException(Exception): +class MixpanelException(Exception): # noqa: N818 """Raised by consumers when unable to send messages. This could be caused by a network outage or interruption, or by an invalid endpoint passed to :meth:`.Consumer.send`. """ - pass +class Consumer: + """A consumer that sends an HTTP request directly to the Mixpanel service. -class Consumer(object): - """ - A consumer that sends an HTTP request directly to the Mixpanel service, one - per call to :meth:`~.send`. + One per call to :meth:`~.send`. :param str events_url: override the default events API endpoint :param str people_url: override the default people API endpoint @@ -674,10 +668,10 @@ def __init__( ): # TODO: With next major version, make the above args kwarg-only, and reorder them. self._endpoints = { - "events": events_url or "https://{}/track".format(api_host), - "people": people_url or "https://{}/engage".format(api_host), - "groups": groups_url or "https://{}/groups".format(api_host), - "imports": import_url or "https://{}/import".format(api_host), + "events": events_url or f"https://{api_host}/track", + "people": people_url or f"https://{api_host}/engage", + "groups": groups_url or f"https://{api_host}/groups", + "imports": import_url or f"https://{api_host}/import", } self._verify_cert = verify_cert @@ -717,11 +711,8 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): The *api_secret* parameter. """ if endpoint not in self._endpoints: - raise MixpanelException( - 'No such endpoint "{0}". Valid endpoints are one of {1}'.format( - endpoint, self._endpoints.keys() - ) - ) + msg = f'No such endpoint "{endpoint}". Valid endpoints are one of {self._endpoints.keys()}' + raise MixpanelException(msg) self._write_request( self._endpoints[endpoint], json_message, api_key, api_secret @@ -759,22 +750,19 @@ def _write_request(self, request_url, json_message, api_key=None, api_secret=Non try: response_dict = response.json() except ValueError: - raise MixpanelException( - "Cannot interpret Mixpanel server response: {0}".format(response.text) - ) + msg = f"Cannot interpret Mixpanel server response: {response.text}" + raise MixpanelException(msg) from None if response_dict["status"] != 1: - raise MixpanelException( - "Mixpanel error: {0}".format(response_dict["error"]) - ) + raise MixpanelException("Mixpanel error: {}".format(response_dict["error"])) return True # <- TODO: remove return val with major release. -class BufferedConsumer(object): - """ - A consumer that maintains per-endpoint buffers of messages and then sends - them in batches. This can save bandwidth and reduce the total amount of +class BufferedConsumer: + """A consumer that maintains per-endpoint buffers of messages and then sends them in batches. + + This can save bandwidth and reduce the total amount of time required to post your events to Mixpanel. :param int max_size: number of :meth:`~.send` calls for a given endpoint to @@ -857,18 +845,15 @@ def send(self, endpoint, json_message, api_key=None, api_secret=None): The *api_key* parameter. """ if endpoint not in self._buffers: - raise MixpanelException( - 'No such endpoint "{0}". Valid endpoints are one of {1}'.format( - endpoint, self._buffers.keys() - ) - ) + msg = f'No such endpoint "{endpoint}". Valid endpoints are one of {self._buffers.keys()}' + raise MixpanelException(msg) if not isinstance(api_key, tuple): api_key = (api_key, api_secret) buf = self._buffers[endpoint] buf.append(json_message) - # Fixme: Don't stick these in the instance. + # TODO: Don't stick these in the instance. self._api_key = api_key self._api_secret = api_secret if len(buf) >= self._max_size: @@ -880,7 +865,7 @@ def flush(self): :raises MixpanelException: if the server is unreachable or any buffered message cannot be processed """ - for endpoint in self._buffers.keys(): + for endpoint in self._buffers: self._flush_endpoint(endpoint) def _flush_endpoint(self, endpoint): @@ -888,7 +873,7 @@ def _flush_endpoint(self, endpoint): while buf: batch = buf[: self._max_size] - batch_json = "[{0}]".format(",".join(batch)) + batch_json = "[{}]".format(",".join(batch)) try: self._consumer.send(endpoint, batch_json, api_key=self._api_key) except MixpanelException as orig_e: diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index cb98e51..cdc2fce 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -1,24 +1,28 @@ -import httpx -import logging +from __future__ import annotations + import asyncio -import time +import logging import threading -import json_logic +import time from datetime import datetime, timedelta -from typing import Dict, Any, Callable, Optional +from typing import Any, Callable + +import httpx +import json_logic + from .types import ( ExperimentationFlag, ExperimentationFlags, - SelectedVariant, LocalFlagsConfig, Rollout, + SelectedVariant, ) from .utils import ( + EXPOSURE_EVENT, REQUEST_HEADERS, + generate_traceparent, normalized_hash, prepare_common_query_params, - EXPOSURE_EVENT, - generate_traceparent, ) logger = logging.getLogger(__name__) @@ -31,8 +35,8 @@ class LocalFeatureFlagsProvider: def __init__( self, token: str, config: LocalFlagsConfig, version: str, tracker: Callable ) -> None: - """ - Initializes the LocalFeatureFlagsProvider + """Initialize the LocalFeatureFlagsProvider. + :param str token: your project's Mixpanel token :param LocalFlagsConfig config: configuration options for the local feature flags provider :param str version: the version of the Mixpanel library being used, just for tracking @@ -43,7 +47,7 @@ def __init__( self._version = version self._tracker: Callable = tracker - self._flag_definitions: Dict[str, ExperimentationFlag] = dict() + self._flag_definitions: dict[str, ExperimentationFlag] = {} self._are_flags_ready = False httpx_client_parameters = { @@ -60,14 +64,14 @@ def __init__( ) self._sync_client: httpx.Client = httpx.Client(**httpx_client_parameters) - self._async_polling_task: Optional[asyncio.Task] = None - self._sync_polling_task: Optional[threading.Thread] = None + self._async_polling_task: asyncio.Task | None = None + self._sync_polling_task: threading.Thread | None = None self._sync_stop_event = threading.Event() def start_polling_for_definitions(self): - """ - Fetches flag definitions for the current project. + """Fetch flag definitions for the current project. + If configured by the caller, starts a background thread to poll for updates at regular intervals, if one does not already exist. """ self._fetch_flag_definitions() @@ -83,8 +87,8 @@ def start_polling_for_definitions(self): logger.warning("A polling task is already running") def stop_polling_for_definitions(self): - """ - If there exists a reference to a background thread polling for flag definition updates, signal it to stop and clear the reference. + """Signal background polling thread to stop and clear the reference. + Once stopped, the polling thread cannot be restarted. """ if self._sync_polling_task: @@ -94,8 +98,8 @@ def stop_polling_for_definitions(self): logger.info("There is no polling task to cancel.") async def astart_polling_for_definitions(self): - """ - Fetches flag definitions for the current project. + """Fetch flag definitions for the current project. + If configured by the caller, starts an async task on the event loop to poll for updates at regular intervals, if one does not already exist. """ await self._afetch_flag_definitions() @@ -109,9 +113,7 @@ async def astart_polling_for_definitions(self): logger.error("A polling task is already running") async def astop_polling_for_definitions(self): - """ - If there exists an async task to poll for flag definition updates, cancel the task and clear the reference to it. - """ + """If there exists an async task to poll for flag definition updates, cancel the task and clear the reference to it.""" if self._async_polling_task: self._async_polling_task.cancel() self._async_polling_task = None @@ -120,7 +122,8 @@ async def astop_polling_for_definitions(self): async def _astart_continuous_polling(self): logger.info( - f"Initialized async polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" + "Initialized async polling for flag definition updates every '%s' seconds", + self._config.polling_interval_in_seconds, ) try: while True: @@ -131,7 +134,8 @@ async def _astart_continuous_polling(self): def _start_continuous_polling(self): logger.info( - f"Initialized sync polling for flag definition updates every '{self._config.polling_interval_in_seconds}' seconds" + "Initialized sync polling for flag definition updates every '%s' seconds", + self._config.polling_interval_in_seconds, ) while not self._sync_stop_event.is_set(): if self._sync_stop_event.wait( @@ -142,21 +146,19 @@ def _start_continuous_polling(self): self._fetch_flag_definitions() def are_flags_ready(self) -> bool: - """ - Check if the call to fetch flag definitions has been made successfully. - """ + """Check if the call to fetch flag definitions has been made successfully.""" return self._are_flags_ready - def get_all_variants(self, context: Dict[str, Any]) -> Dict[str, SelectedVariant]: - """ - Gets the selected variant for all feature flags that the current user context is in the rollout for. + def get_all_variants(self, context: dict[str, Any]) -> dict[str, SelectedVariant]: + """Get the selected variant for all feature flags that the current user context is in the rollout for. + Exposure events are not automatically tracked when this method is used. :param Dict[str, Any] context: The user context to evaluate against the feature flags """ - variants: Dict[str, SelectedVariant] = {} + variants: dict[str, SelectedVariant] = {} fallback = SelectedVariant(variant_key=None, variant_value=None) - for flag_key in self._flag_definitions.keys(): + for flag_key in self._flag_definitions: variant = self.get_variant( flag_key, fallback, context, report_exposure=False ) @@ -166,10 +168,9 @@ def get_all_variants(self, context: Dict[str, Any]) -> Dict[str, SelectedVariant return variants def get_variant_value( - self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + self, flag_key: str, fallback_value: Any, context: dict[str, Any] ) -> Any: - """ - Get the value of a feature flag variant. + """Get the value of a feature flag variant. :param str flag_key: The key of the feature flag to evaluate :param Any fallback_value: The default value to return if the flag is not found or evaluation fails @@ -180,25 +181,23 @@ def get_variant_value( ) return variant.variant_value - def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: - """ - Check if a feature flag is enabled for the given context. + def is_enabled(self, flag_key: str, context: dict[str, Any]) -> bool: + """Check if a feature flag is enabled for the given context. :param str flag_key: The key of the feature flag to check :param Dict[str, Any] context: Context dictionary containing user's distinct_id and any other attributes needed for rollout evaluation """ variant_value = self.get_variant_value(flag_key, False, context) - return variant_value == True + return variant_value is True def get_variant( self, flag_key: str, fallback_value: SelectedVariant, - context: Dict[str, Any], + context: dict[str, Any], report_exposure: bool = True, ) -> SelectedVariant: - """ - Gets the selected variant for a feature flag + """Get the selected variant for a feature flag. :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails @@ -209,16 +208,18 @@ def get_variant( flag_definition = self._flag_definitions.get(flag_key) if not flag_definition: - logger.warning(f"Cannot find flag definition for key: '{flag_key}'") + logger.warning("Cannot find flag definition for key: '%s'", flag_key) return fallback_value if not (context_value := context.get(flag_definition.context)): logger.warning( - f"The rollout context, '{flag_definition.context}' for flag, '{flag_key}' is not present in the supplied context dictionary" + "The rollout context, '%s' for flag, '%s' is not present in the supplied context dictionary", + flag_definition.context, + flag_key, ) return fallback_value - selected_variant: Optional[SelectedVariant] = None + selected_variant: SelectedVariant | None = None if test_user_variant := self._get_variant_override_for_test_user( flag_definition, context @@ -240,16 +241,19 @@ def get_variant( return selected_variant logger.debug( - f"{flag_definition.context} context {context_value} not eligible for any rollout for flag: {flag_key}" + "%s context %s not eligible for any rollout for flag: %s", + flag_definition.context, + context_value, + flag_key, ) return fallback_value def track_exposure_event( - self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, variant: SelectedVariant, context: dict[str, Any] ): - """ - Manually tracks a feature flagging exposure event to Mixpanel. - This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + """Manually track a feature flagging exposure event to Mixpanel. + + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting. :param str flag_key: The key of the feature flag :param SelectedVariant variant: The selected variant for the feature flag @@ -258,9 +262,9 @@ def track_exposure_event( self._track_exposure(flag_key, variant, context) def _get_variant_override_for_test_user( - self, flag_definition: ExperimentationFlag, context: Dict[str, Any] - ) -> Optional[SelectedVariant]: - """""" + self, flag_definition: ExperimentationFlag, context: dict[str, Any] + ) -> SelectedVariant | None: + """Check if user has a test variant override.""" if not flag_definition.ruleset.test or not flag_definition.ruleset.test.users: return None @@ -279,11 +283,12 @@ def _get_assigned_variant( flag_name: str, rollout: Rollout, ) -> SelectedVariant: - if rollout.variant_override: - if variant := self._get_matching_variant( + if rollout.variant_override and ( + variant := self._get_matching_variant( rollout.variant_override.key, flag_definition - ): - return variant + ) + ): + return variant stored_salt = ( flag_definition.hash_salt if flag_definition.hash_salt is not None else "" @@ -319,10 +324,9 @@ def _get_assigned_rollout( self, flag_definition: ExperimentationFlag, context_value: Any, - context: Dict[str, Any], - ) -> Optional[Rollout]: + context: dict[str, Any], + ) -> Rollout | None: for index, rollout in enumerate(flag_definition.ruleset.rollout): - salt = None if flag_definition.hash_salt is not None: salt = flag_definition.key + flag_definition.hash_salt + str(index) else: @@ -341,33 +345,29 @@ def _get_assigned_rollout( def lowercase_keys_and_values(self, val: Any) -> Any: if isinstance(val, str): return val.casefold() - elif isinstance(val, list): + if isinstance(val, list): return [self.lowercase_keys_and_values(item) for item in val] - elif isinstance(val, dict): + if isinstance(val, dict): return { ( key.casefold() if isinstance(key, str) else key ): self.lowercase_keys_and_values(value) for key, value in val.items() } - else: - return val + return val - def lowercase_only_leaf_nodes(self, val: Any) -> Dict[str, Any]: + def lowercase_only_leaf_nodes(self, val: Any) -> dict[str, Any]: if isinstance(val, str): return val.casefold() - elif isinstance(val, list): + if isinstance(val, list): return [self.lowercase_only_leaf_nodes(item) for item in val] - elif isinstance(val, dict): + if isinstance(val, dict): return { key: self.lowercase_only_leaf_nodes(value) for key, value in val.items() } - else: - return val + return val - def _get_runtime_parameters( - self, context: Dict[str, Any] - ) -> Optional[Dict[str, Any]]: + def _get_runtime_parameters(self, context: dict[str, Any]) -> dict[str, Any] | None: if not (custom_properties := context.get("custom_properties")): return None if not isinstance(custom_properties, dict): @@ -375,7 +375,7 @@ def _get_runtime_parameters( return self.lowercase_keys_and_values(custom_properties) def _is_runtime_rules_engine_satisfied( - self, rollout: Rollout, context: Dict[str, Any] + self, rollout: Rollout, context: dict[str, Any] ) -> bool: if rollout.runtime_evaluation_rule: parameters_for_runtime_rule = self._get_runtime_parameters(context) @@ -399,7 +399,7 @@ def _is_runtime_rules_engine_satisfied( return True def _is_legacy_runtime_evaluation_rule_satisfied( - self, rollout: Rollout, context: Dict[str, Any] + self, rollout: Rollout, context: dict[str, Any] ) -> bool: if not rollout.runtime_evaluation_definition: return True @@ -420,7 +420,7 @@ def _is_legacy_runtime_evaluation_rule_satisfied( def _get_matching_variant( self, variant_key: str, flag: ExperimentationFlag - ) -> Optional[SelectedVariant]: + ) -> SelectedVariant | None: for variant in flag.ruleset.variants: if variant_key.casefold() == variant.key.casefold(): return SelectedVariant( @@ -434,28 +434,28 @@ def _get_matching_variant( async def _afetch_flag_definitions(self) -> None: try: - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = await self._async_client.get( self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers, ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._handle_response(response, start_time, end_time) except Exception: logger.exception("Failed to fetch feature flag definitions") def _fetch_flag_definitions(self) -> None: try: - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = self._sync_client.get( self.FLAGS_DEFINITIONS_URL_PATH, params=self._request_params, headers=headers, ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._handle_response(response, start_time, end_time) except Exception: logger.exception("Failed to fetch feature flag definitions") @@ -465,7 +465,10 @@ def _handle_response( ) -> None: request_duration: timedelta = end_time - start_time logger.debug( - f"Request started at '{start_time.isoformat()}', completed at '{end_time.isoformat()}', duration: '{request_duration.total_seconds():.3f}s'" + "Request started at '%s', completed at '%s', duration: '%.3fs'", + start_time.isoformat(), + end_time.isoformat(), + request_duration.total_seconds(), ) response.raise_for_status() @@ -483,15 +486,16 @@ def _handle_response( self._flag_definitions = flags self._are_flags_ready = True logger.debug( - f"Successfully fetched {len(self._flag_definitions)} flag definitions" + "Successfully fetched %s flag definitions", + len(self._flag_definitions), ) def _track_exposure( self, flag_key: str, variant: SelectedVariant, - context: Dict[str, Any], - latency_in_seconds: Optional[float] = None, + context: dict[str, Any], + latency_in_seconds: float | None = None, ): if distinct_id := context.get("distinct_id"): properties = { diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 886db2b..0931a0d 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -1,18 +1,21 @@ -import httpx -import logging +from __future__ import annotations + +import asyncio import json +import logging import urllib.parse -import asyncio from datetime import datetime -from typing import Dict, Any, Callable, Tuple, Optional +from typing import Any, Callable + +import httpx from asgiref.sync import sync_to_async -from .types import RemoteFlagsConfig, SelectedVariant, RemoteFlagsResponse +from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant from .utils import ( - REQUEST_HEADERS, EXPOSURE_EVENT, - prepare_common_query_params, + REQUEST_HEADERS, generate_traceparent, + prepare_common_query_params, ) logger = logging.getLogger(__name__) @@ -44,34 +47,33 @@ def __init__( self._request_params_base = prepare_common_query_params(self._token, version) async def aget_all_variants( - self, context: Dict[str, Any] - ) -> Optional[Dict[str, SelectedVariant]]: - """ - Asynchronously gets all feature flag variants for the current user context from remote server. + self, context: dict[str, Any] + ) -> dict[str, SelectedVariant] | None: + """Asynchronously get all feature flag variants for the current user context from remote server. + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context :return: A dictionary mapping flag keys to their selected variants, or None if the call fails """ - flags: Optional[Dict[str, SelectedVariant]] = None + flags: dict[str, SelectedVariant] | None = None try: params = self._prepare_query_params(context) - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = await self._async_client.get( self.FLAGS_URL_PATH, params=params, headers=headers ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._instrument_call(start_time, end_time) flags = self._handle_response(response) except Exception: - logger.exception(f"Failed to get remote variants") + logger.exception("Failed to get remote variants") return flags async def aget_variant_value( - self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + self, flag_key: str, fallback_value: Any, context: dict[str, Any] ) -> Any: - """ - Gets the selected variant value of a feature flag variant for the current user context from remote server. + """Get the selected variant value of a feature flag variant for the current user context from remote server. :param str flag_key: The key of the feature flag to evaluate :param Any fallback_value: The default value to return if the flag is not found or evaluation fails @@ -86,11 +88,10 @@ async def aget_variant( self, flag_key: str, fallback_value: SelectedVariant, - context: Dict[str, Any], - reportExposure: bool = True, + context: dict[str, Any], + reportExposure: bool = True, # noqa: N803 - matches public API convention ) -> SelectedVariant: - """ - Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server. + """Asynchronously get the selected variant of a feature flag variant for the current user context from remote server. :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails @@ -99,12 +100,12 @@ async def aget_variant( """ try: params = self._prepare_query_params(context, flag_key) - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = await self._async_client.get( self.FLAGS_URL_PATH, params=params, headers=headers ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._instrument_call(start_time, end_time) flags = self._handle_response(response) selected_variant, is_fallback = self._lookup_flag_in_response( @@ -119,33 +120,32 @@ async def aget_variant( properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) - asyncio.create_task( + asyncio.create_task( # noqa: RUF006 - intentional fire-and-forget for exposure tracking sync_to_async(self._tracker, thread_sensitive=False)( distinct_id, EXPOSURE_EVENT, properties ) ) - - return selected_variant except Exception: - logger.exception(f"Failed to get remote variant for flag '{flag_key}'") + logger.exception("Failed to get remote variant for flag '%s'", flag_key) return fallback_value + else: + return selected_variant - async def ais_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: - """ - Asynchronously checks if a feature flag is enabled for the given context. + async def ais_enabled(self, flag_key: str, context: dict[str, Any]) -> bool: + """Asynchronously check if a feature flag is enabled for the given context. :param str flag_key: The key of the feature flag to check :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context """ variant_value = await self.aget_variant_value(flag_key, False, context) - return variant_value == True + return variant_value is True async def atrack_exposure_event( - self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, variant: SelectedVariant, context: dict[str, Any] ): - """ - Manually tracks a feature flagging exposure event asynchronously to Mixpanel. - This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + """Manually track a feature flagging exposure event asynchronously to Mixpanel. + + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting. :param str flag_key: The key of the feature flag :param SelectedVariant variant: The selected variant for the feature flag @@ -163,34 +163,33 @@ async def atrack_exposure_event( ) def get_all_variants( - self, context: Dict[str, Any] - ) -> Optional[Dict[str, SelectedVariant]]: - """ - Synchronously gets all feature flag variants for the current user context from remote server. + self, context: dict[str, Any] + ) -> dict[str, SelectedVariant] | None: + """Synchronously get all feature flag variants for the current user context from remote server. + :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context :return: A dictionary mapping flag keys to their selected variants, or None if the call fails """ - flags: Optional[Dict[str, SelectedVariant]] = None + flags: dict[str, SelectedVariant] | None = None try: params = self._prepare_query_params(context) - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = self._sync_client.get( self.FLAGS_URL_PATH, params=params, headers=headers ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._instrument_call(start_time, end_time) flags = self._handle_response(response) except Exception: - logger.exception(f"Failed to get remote variants") + logger.exception("Failed to get remote variants") return flags def get_variant_value( - self, flag_key: str, fallback_value: Any, context: Dict[str, Any] + self, flag_key: str, fallback_value: Any, context: dict[str, Any] ) -> Any: - """ - Synchronously gets the value of a feature flag variant from remote server. + """Synchronously get the value of a feature flag variant from remote server. :param str flag_key: The key of the feature flag to evaluate :param Any fallback_value: The default value to return if the flag is not found or evaluation fails @@ -205,11 +204,10 @@ def get_variant( self, flag_key: str, fallback_value: SelectedVariant, - context: Dict[str, Any], - reportExposure: bool = True, + context: dict[str, Any], + reportExposure: bool = True, # noqa: N803 - matches public API convention ) -> SelectedVariant: - """ - Synchronously gets the selected variant for a feature flag from remote server. + """Synchronously get the selected variant for a feature flag from remote server. :param str flag_key: The key of the feature flag to evaluate :param SelectedVariant fallback_value: The default variant to return if evaluation fails @@ -218,12 +216,12 @@ def get_variant( """ try: params = self._prepare_query_params(context, flag_key) - start_time = datetime.now() + start_time = datetime.now() # noqa: DTZ005 headers = {"traceparent": generate_traceparent()} response = self._sync_client.get( self.FLAGS_URL_PATH, params=params, headers=headers ) - end_time = datetime.now() + end_time = datetime.now() # noqa: DTZ005 self._instrument_call(start_time, end_time) flags = self._handle_response(response) @@ -241,27 +239,27 @@ def get_variant( ) self._tracker(distinct_id, EXPOSURE_EVENT, properties) - return selected_variant except Exception: - logging.exception(f"Failed to get remote variant for flag '{flag_key}'") + logger.exception("Failed to get remote variant for flag '%s'", flag_key) return fallback_value + else: + return selected_variant - def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: - """ - Synchronously checks if a feature flag is enabled for the given context. + def is_enabled(self, flag_key: str, context: dict[str, Any]) -> bool: + """Synchronously check if a feature flag is enabled for the given context. :param str flag_key: The key of the feature flag to check :param Dict[str, Any] context: Context dictionary containing user attributes and rollout context """ variant_value = self.get_variant_value(flag_key, False, context) - return variant_value == True + return variant_value is True def track_exposure_event( - self, flag_key: str, variant: SelectedVariant, context: Dict[str, Any] + self, flag_key: str, variant: SelectedVariant, context: dict[str, Any] ): - """ - Manually tracks a feature flagging exposure event synchronously to Mixpanel. - This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting + """Manually track a feature flagging exposure event synchronously to Mixpanel. + + This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting. :param str flag_key: The key of the feature flag :param SelectedVariant variant: The selected variant for the feature flag @@ -271,13 +269,13 @@ def track_exposure_event( properties = self._build_tracking_properties(flag_key, variant) self._tracker(distinct_id, EXPOSURE_EVENT, properties) else: - logging.error( + logger.error( "Cannot track exposure event without a distinct_id in the context" ) def _prepare_query_params( - self, context: Dict[str, Any], flag_key: Optional[str] = None - ) -> Dict[str, str]: + self, context: dict[str, Any], flag_key: str | None = None + ) -> dict[str, str]: params = self._request_params_base.copy() context_json = json.dumps(context).encode("utf-8") url_encoded_context = urllib.parse.quote(context_json) @@ -288,20 +286,21 @@ def _prepare_query_params( def _instrument_call(self, start_time: datetime, end_time: datetime) -> None: request_duration = end_time - start_time - formatted_start_time = start_time.isoformat() - formatted_end_time = end_time.isoformat() - logging.debug( - f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'" + logger.debug( + "Request started at '%s', completed at '%s', duration: '%.3fs'", + start_time.isoformat(), + end_time.isoformat(), + request_duration.total_seconds(), ) def _build_tracking_properties( self, flag_key: str, variant: SelectedVariant, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - ) -> Dict[str, Any]: - tracking_properties: Dict[str, Any] = { + start_time: datetime | None = None, + end_time: datetime | None = None, + ) -> dict[str, Any]: + tracking_properties: dict[str, Any] = { "Experiment name": flag_key, "Variant name": variant.variant_key, "$experiment_type": "feature_flag", @@ -324,7 +323,7 @@ def _build_tracking_properties( return tracking_properties - def _handle_response(self, response: httpx.Response) -> Dict[str, SelectedVariant]: + def _handle_response(self, response: httpx.Response) -> dict[str, SelectedVariant]: response.raise_for_status() flags_response = RemoteFlagsResponse.model_validate(response.json()) return flags_response.flags @@ -332,16 +331,17 @@ def _handle_response(self, response: httpx.Response) -> Dict[str, SelectedVarian def _lookup_flag_in_response( self, flag_key: str, - flags: Dict[str, SelectedVariant], + flags: dict[str, SelectedVariant], fallback_value: SelectedVariant, - ) -> Tuple[SelectedVariant, bool]: + ) -> tuple[SelectedVariant, bool]: if flag_key in flags: return flags[flag_key], False - else: - logging.debug( - f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'" - ) - return fallback_value, True + logger.debug( + "Flag '%s' not found in remote response. Returning fallback, '%s'", + flag_key, + fallback_value, + ) + return fallback_value, True def __enter__(self): return self @@ -350,9 +350,9 @@ async def __aenter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") + logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") self._sync_client.close() async def __aexit__(self, exc_type, exc_val, exc_tb): - logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") + logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources") await self._async_client.aclose() diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 2d8af71..592e3ee 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -1,23 +1,27 @@ +from __future__ import annotations + import asyncio -import pytest -import respx -import httpx import threading -from unittest.mock import Mock, patch -from typing import Any, Dict, Optional, List from itertools import chain, repeat +from typing import Any +from unittest.mock import Mock, patch + +import httpx +import pytest +import respx + +from .local_feature_flags import LocalFeatureFlagsProvider from .types import ( - LocalFlagsConfig, ExperimentationFlag, + ExperimentationFlags, + FlagTestUsers, + LocalFlagsConfig, + Rollout, RuleSet, + SelectedVariant, Variant, - Rollout, - FlagTestUsers, - ExperimentationFlags, VariantOverride, - SelectedVariant, ) -from .local_feature_flags import LocalFeatureFlagsProvider TEST_FLAG_KEY = "test_flag" DISTINCT_ID = "user123" @@ -27,16 +31,16 @@ def create_test_flag( flag_key: str = TEST_FLAG_KEY, context: str = "distinct_id", - variants: Optional[list[Variant]] = None, - variant_override: Optional[VariantOverride] = None, + variants: list[Variant] | None = None, + variant_override: VariantOverride | None = None, rollout_percentage: float = 100.0, - runtime_evaluation_legacy_definition: Optional[Dict] = None, - runtime_evaluation_rule: Optional[Dict] = None, - test_users: Optional[Dict[str, str]] = None, - experiment_id: Optional[str] = None, - is_experiment_active: Optional[bool] = None, - variant_splits: Optional[Dict[str, float]] = None, - hash_salt: Optional[str] = None, + runtime_evaluation_legacy_definition: dict | None = None, + runtime_evaluation_rule: dict | None = None, + test_users: dict[str, str] | None = None, + experiment_id: str | None = None, + is_experiment_active: bool | None = None, + variant_splits: dict[str, float] | None = None, + hash_salt: str | None = None, ) -> ExperimentationFlag: if variants is None: variants = [ @@ -74,7 +78,7 @@ def create_test_flag( ) -def create_flags_response(flags: List[ExperimentationFlag]) -> httpx.Response: +def create_flags_response(flags: list[ExperimentationFlag]) -> httpx.Response: if flags is None: flags = [] response_data = ExperimentationFlags(flags=flags).model_dump() @@ -104,14 +108,14 @@ async def setup_method(self): await self._flags.__aexit__(None, None, None) await self._flags_with_polling.__aexit__(None, None, None) - async def setup_flags(self, flags: List[ExperimentationFlag]): + async def setup_flags(self, flags: list[ExperimentationFlag]): respx.get("https://api.mixpanel.com/flags/definitions").mock( return_value=create_flags_response(flags) ) await self._flags.astart_polling_for_definitions() async def setup_flags_with_polling( - self, flags_in_order: List[List[ExperimentationFlag]] = [[]] + self, flags_in_order: list[list[ExperimentationFlag]] = [[]] ): responses = [create_flags_response(flag) for flag in flags_in_order] @@ -451,10 +455,9 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not assert result == "fallback" def user_context_with_properties( - self, properties: Dict[str, Any] - ) -> Dict[str, Any]: - context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} - return context + self, properties: dict[str, Any] + ) -> dict[str, Any]: + return {"distinct_id": DISTINCT_ID, "custom_properties": properties} @respx.mock async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied( @@ -586,7 +589,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): @respx.mock @pytest.mark.parametrize( - "experiment_id,is_experiment_active,use_qa_user", + ("experiment_id", "is_experiment_active", "use_qa_user"), [ ("exp-123", True, True), # QA tester with active experiment ("exp-456", False, True), # QA tester with inactive experiment @@ -624,7 +627,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties( assert properties["$is_experiment_active"] == is_experiment_active if use_qa_user: - assert properties["$is_qa_tester"] == True + assert properties["$is_qa_tester"] is True else: assert properties.get("$is_qa_tester") is None @@ -697,19 +700,18 @@ async def test_track_exposure_event_successfully_tracks(self): async def test_are_flags_ready_returns_true_when_flags_loaded(self): flag = create_test_flag() await self.setup_flags([flag]) - assert self._flags.are_flags_ready() == True + assert self._flags.are_flags_ready() is True @respx.mock async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): - flag = create_test_flag() await self.setup_flags([]) - assert self._flags.are_flags_ready() == True + assert self._flags.are_flags_ready() is True @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) - assert result == False + assert result is False @respx.mock async def test_is_enabled_returns_true_for_true_variant_value(self): @@ -717,7 +719,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) - assert result == True + assert result is True @respx.mock async def test_get_variant_value_uses_most_recent_polled_flag(self): @@ -765,7 +767,7 @@ def teardown_method(self): self._flags_with_polling.__exit__(None, None, None) def setup_flags_with_polling( - self, flags_in_order: List[List[ExperimentationFlag]] = [[]] + self, flags_in_order: list[list[ExperimentationFlag]] = [[]] ): responses = [create_flags_response(flag) for flag in flags_in_order] diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index c1784e8..5b5dd71 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -1,17 +1,20 @@ -import pytest -import httpx -import respx +from __future__ import annotations + import asyncio -from typing import Dict from unittest.mock import Mock -from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant + +import httpx +import pytest +import respx + from .remote_feature_flags import RemoteFeatureFlagsProvider +from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant ENDPOINT = "https://api.mixpanel.com/flags" def create_success_response( - assigned_variants_per_flag: Dict[str, SelectedVariant], + assigned_variants_per_flag: dict[str, SelectedVariant], ) -> httpx.Response: serialized_response = RemoteFlagsResponse( code=200, flags=assigned_variants_per_flag @@ -122,7 +125,7 @@ async def test_ais_enabled_returns_true_for_true_variant_value(self): ) result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) - assert result == True + assert result is True @respx.mock async def test_ais_enabled_returns_false_for_false_variant_value(self): @@ -137,7 +140,7 @@ async def test_ais_enabled_returns_false_for_false_variant_value(self): ) result = await self._flags.ais_enabled("test_flag", {"distinct_id": "user123"}) - assert result == False + assert result is False @respx.mock async def test_aget_all_variants_returns_all_variants_from_api(self): @@ -291,7 +294,7 @@ def test_is_enabled_returns_true_for_true_variant_value(self): ) result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) - assert result == True + assert result is True @respx.mock def test_is_enabled_returns_false_for_false_variant_value(self): @@ -306,7 +309,7 @@ def test_is_enabled_returns_false_for_false_variant_value(self): ) result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) - assert result == False + assert result is False @respx.mock def test_get_all_variants_returns_all_variants_from_api(self): diff --git a/mixpanel/flags/test_utils.py b/mixpanel/flags/test_utils.py index f610f39..6e02717 100644 --- a/mixpanel/flags/test_utils.py +++ b/mixpanel/flags/test_utils.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import re + import pytest -import random -import string + from .utils import generate_traceparent, normalized_hash @@ -18,7 +20,7 @@ def test_traceparent_format_is_correct(self): ) @pytest.mark.parametrize( - "key,salt,expected_hash", + ("key", "salt", "expected_hash"), [ ("abc", "variant", 0.72), ("def", "variant", 0.21), diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3d1e583..8325439 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -1,4 +1,5 @@ -from typing import Optional, List, Dict, Any +from typing import Any, Optional + from pydantic import BaseModel, ConfigDict MIXPANEL_DEFAULT_API_ENDPOINT = "api.mixpanel.com" @@ -28,7 +29,7 @@ class Variant(BaseModel): class FlagTestUsers(BaseModel): - users: Dict[str, str] + users: dict[str, str] class VariantOverride(BaseModel): @@ -37,15 +38,15 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float - runtime_evaluation_definition: Optional[Dict[str, str]] = None - runtime_evaluation_rule: Optional[Dict[Any, Any]] = None + runtime_evaluation_definition: Optional[dict[str, str]] = None + runtime_evaluation_rule: Optional[dict[Any, Any]] = None variant_override: Optional[VariantOverride] = None - variant_splits: Optional[Dict[str, float]] = None + variant_splits: Optional[dict[str, float]] = None class RuleSet(BaseModel): - variants: List[Variant] - rollout: List[Rollout] + variants: list[Variant] + rollout: list[Rollout] test: Optional[FlagTestUsers] = None @@ -72,9 +73,9 @@ class SelectedVariant(BaseModel): class ExperimentationFlags(BaseModel): - flags: List[ExperimentationFlag] + flags: list[ExperimentationFlag] class RemoteFlagsResponse(BaseModel): code: int - flags: Dict[str, SelectedVariant] + flags: dict[str, SelectedVariant] diff --git a/mixpanel/flags/utils.py b/mixpanel/flags/utils.py index 4744ea1..4b07cc7 100644 --- a/mixpanel/flags/utils.py +++ b/mixpanel/flags/utils.py @@ -1,10 +1,10 @@ +from __future__ import annotations + import uuid -import httpx -from typing import Dict EXPOSURE_EVENT = "$experiment_started" -REQUEST_HEADERS: Dict[str, str] = { +REQUEST_HEADERS: dict[str, str] = { "X-Scheme": "https", "X-Forwarded-Proto": "https", "Content-Type": "application/json", @@ -28,31 +28,30 @@ def _fnv1a64(data: bytes) -> int: :param data: Bytes to hash :return: 64-bit hash value """ - FNV_prime = 0x100000001B3 + fnv_prime = 0x100000001B3 hash_value = 0xCBF29CE484222325 - for byte in data: - hash_value ^= byte - hash_value *= FNV_prime + for _byte in data: + hash_value ^= _byte + hash_value *= fnv_prime hash_value &= 0xFFFFFFFFFFFFFFFF # Keep it 64-bit return hash_value -def prepare_common_query_params(token: str, sdk_version: str) -> Dict[str, str]: +def prepare_common_query_params(token: str, sdk_version: str) -> dict[str, str]: """Prepare common query string parameters for feature flag evaluation. :param token: The project token :param sdk_version: The SDK version :return: Dictionary of common query parameters """ - params = {"mp_lib": "python", "lib_version": sdk_version, "token": token} - - return params + return {"mp_lib": "python", "lib_version": sdk_version, "token": token} def generate_traceparent() -> str: - """Generates a W3C traceparent header for easy interop with distributed tracing systems i.e Open Telemetry + """Generate a W3C traceparent header for distributed tracing interop. + https://www.w3.org/TR/trace-context/#traceparent-header :return: A traceparent string """ @@ -62,5 +61,4 @@ def generate_traceparent() -> str: # Trace flags: '01' for sampled trace_flags = "01" - traceparent = f"00-{trace_id}-{span_id}-{trace_flags}" - return traceparent + return f"00-{trace_id}-{span_id}-{trace_flags}" diff --git a/pyproject.toml b/pyproject.toml index 6399c1c..fe774aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ "twine", "sphinx", "ghp-import", + "pre-commit", ] [tool.setuptools.dynamic] @@ -66,3 +67,84 @@ commands = [ [tool.pytest.ini_options] asyncio_mode = "auto" + +# --- Ruff configuration (strict guide: select ALL, exclude explicitly) --- + +[tool.ruff] +target-version = "py39" +line-length = 88 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # --- Rule conflicts --- + "D203", # conflicts with D211 (no-blank-line-before-class) + "D213", # conflicts with D212 (multi-line-summary-first-line) + "COM812", # conflicts with ruff formatter + "ISC001", # conflicts with ruff formatter + + # --- Type annotations (separate effort for existing codebase) --- + "ANN", # all annotation rules — 150+ violations, separate PR + + # --- Docstrings (separate effort) --- + "D100", # undocumented-public-module + "D101", # undocumented-public-class + "D102", # undocumented-public-method + "D103", # undocumented-public-function + "D104", # undocumented-public-package + "D105", # undocumented-magic-method + "D107", # undocumented-public-init + + # --- Boolean arguments (public API, can't change) --- + "FBT", # boolean-type-hint / boolean-default / boolean-positional + + # --- TODO/FIXME enforcement (not needed) --- + "TD002", # missing-todo-author + "TD003", # missing-todo-link + "FIX001", # line-contains-fixme + "FIX002", # line-contains-todo + + # --- Exception message style (too invasive) --- + "EM101", # raw-string-in-exception + "EM103", # dot-format-in-exception + "TRY003", # raise-vanilla-args + + # --- Other pragmatic exclusions --- + "PLR0913", # too-many-arguments (public API signatures) + "E501", # line-too-long (formatter handles code; remaining are strings/comments) + "FA100", # future-rewritable-type-annotation (interacts with Pydantic runtime, defer) +] + +[tool.ruff.lint.per-file-ignores] +"test_mixpanel.py" = [ + "S101", # assert + "S105", # hardcoded-password-string (test fixtures) + "S106", # hardcoded-password-func-arg + "SLF001", # private-member-access + "PLR2004", # magic-value-comparison + "D", # all docstring rules + "PT018", # pytest-composite-assertion +] +"mixpanel/flags/test_*.py" = [ + "S101", "S105", "S106", "SLF001", "PLR2004", + "D", "PT018", "B006", +] +"demo/*.py" = [ + "INP001", # implicit-namespace-package + "T201", # print + "S105", # hardcoded tokens + "S311", # suspicious-non-cryptographic-random-usage + "D", # docstrings +] +"mixpanel/flags/types.py" = [ + "A005", # shadows stdlib `types` module (renaming would break imports) +] +"docs/conf.py" = [ + "INP001", "A001", "ERA001", "D", +] + +[tool.ruff.lint.isort] +known-first-party = ["mixpanel"] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/test_mixpanel.py b/test_mixpanel.py index 0ea36e8..d09ab10 100644 --- a/test_mixpanel.py +++ b/test_mixpanel.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import decimal import json @@ -31,7 +33,7 @@ def clear(self): class TestMixpanelBase: TOKEN = "12345" - def setup_method(self, method): + def setup_method(self): self.consumer = LogConsumer() self.mp = mixpanel.Mixpanel(self.TOKEN, consumer=self.consumer) self.mp._now = lambda: 1000.1 @@ -336,7 +338,7 @@ def test_people_set_created_date_string(self): ] def test_people_set_created_date_datetime(self): - created = datetime.datetime(2014, 2, 14, 1, 2, 3) + created = datetime.datetime(2014, 2, 14, 1, 2, 3) # noqa: DTZ001 self.mp.people_set("amq", {"$created": created, "favorite color": "purple"}) assert self.consumer.log == [ ( @@ -544,6 +546,7 @@ class CustomSerializer(mixpanel.DatetimeSerializer): def default(self, obj): if isinstance(obj, decimal.Decimal): return obj.to_eng_string() + return super().default(obj) self.mp._serializer = CustomSerializer self.mp.track( @@ -686,7 +689,7 @@ def test_server_5xx(self): ) ], ) - with pytest.raises(mixpanel.MixpanelException) as exc: + with pytest.raises(mixpanel.MixpanelException): self.consumer.send("events", '{"foo":"bar"}') def test_consumer_override_api_host(self): @@ -743,7 +746,7 @@ def test_buffer_hold_and_flush(self): assert self.log == [("events", ["Event"])] def test_buffer_fills_up(self): - for i in range(self.MAX_LENGTH - 1): + for _i in range(self.MAX_LENGTH - 1): self.consumer.send("events", '"Event"') assert len(self.log) == 0 @@ -787,7 +790,7 @@ def test_useful_reraise_in_flush_endpoint(self): with pytest.raises(mixpanel.MixpanelException) as excinfo: consumer.flush() - assert excinfo.value.message == "[%s]" % broken_json + assert excinfo.value.message == f"[{broken_json}]" assert excinfo.value.endpoint == "events" def test_send_remembers_api_key(self): @@ -830,7 +833,7 @@ def test_track_functional(self): data = json.loads(wrapper["data"]) del wrapper["data"] - assert {"ip": "0", "verbose": "1"} == wrapper + assert wrapper == {"ip": "0", "verbose": "1"} expected_data = { "event": "button_press", "properties": { @@ -863,7 +866,7 @@ def test_people_set_functional(self): data = json.loads(wrapper["data"]) del wrapper["data"] - assert {"ip": "0", "verbose": "1"} == wrapper + assert wrapper == {"ip": "0", "verbose": "1"} expected_data = { "$distinct_id": "amq", "$set": {"birth month": "october", "favorite color": "purple"}, From 1393399ab233eba77120881be1d3f6f1e957bfa7 Mon Sep 17 00:00:00 2001 From: Austin Pray <71290498+austinpray-mixpanel@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:21:53 -0500 Subject: [PATCH 203/208] chore: add 30-day dependabot cooldown (#161) --- .github/dependabot.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..482b9df --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 30 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 30 From 67424eb474a1e0562e0b92fda8f5150e95e09a74 Mon Sep 17 00:00:00 2001 From: Austin Pray <71290498+austinpray-mixpanel@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:22:22 -0500 Subject: [PATCH 204/208] chore: pin GitHub Actions to full commit SHAs (#160) --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86261f1..74fcfb3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - name: Check formatting run: uvx ruff format --check . - name: Check linting @@ -24,9 +24,9 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.11'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -37,7 +37,7 @@ jobs: run: | pytest --cov --cov-branch --cov-report=xml - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: token: ${{ secrets.CODECOV_TOKEN }} slug: mixpanel/mixpanel-python \ No newline at end of file From d26f2ba5a167d7042b3bd05d5eba94ac782ea257 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 13:28:58 -0700 Subject: [PATCH 205/208] Python: Add OpenFeature provider (#156) * Add OpenFeature provider package Implement an OpenFeature provider that wraps the Mixpanel Python SDK's local or remote feature flags provider, enabling standardized feature flag evaluation via the OpenFeature API. Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 29 +- mixpanel/flags/local_feature_flags.py | 4 + mixpanel/flags/remote_feature_flags.py | 3 + openfeature-provider/RELEASE.md | 45 ++ openfeature-provider/pyproject.toml | 98 ++++ .../src/mixpanel_openfeature/__init__.py | 3 + .../src/mixpanel_openfeature/provider.py | 216 +++++++++ openfeature-provider/tests/test_provider.py | 452 ++++++++++++++++++ pyproject.toml | 2 + 9 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 openfeature-provider/RELEASE.md create mode 100644 openfeature-provider/pyproject.toml create mode 100644 openfeature-provider/src/mixpanel_openfeature/__init__.py create mode 100644 openfeature-provider/src/mixpanel_openfeature/provider.py create mode 100644 openfeature-provider/tests/test_provider.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74fcfb3..1b0ed66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,4 +40,31 @@ jobs: uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: token: ${{ secrets.CODECOV_TOKEN }} - slug: mixpanel/mixpanel-python \ No newline at end of file + slug: mixpanel/mixpanel-python + + test-openfeature-provider: + runs-on: ubuntu-24.04 + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.11'] + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -e ./openfeature-provider[test] + - name: Run OpenFeature provider tests + run: | + pytest --cov --cov-branch --cov-report=xml openfeature-provider/tests/ + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: mixpanel/mixpanel-python + flags: openfeature-provider \ No newline at end of file diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index cdc2fce..ed13ee0 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -520,6 +520,10 @@ def _track_exposure( async def __aenter__(self): return self + def shutdown(self): + self.stop_polling_for_definitions() + self._sync_client.close() + def __enter__(self): return self diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 0931a0d..b26f4af 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -343,6 +343,9 @@ def _lookup_flag_in_response( ) return fallback_value, True + def shutdown(self): + self._sync_client.close() + def __enter__(self): return self diff --git a/openfeature-provider/RELEASE.md b/openfeature-provider/RELEASE.md new file mode 100644 index 0000000..50e0f9f --- /dev/null +++ b/openfeature-provider/RELEASE.md @@ -0,0 +1,45 @@ +# Releasing the OpenFeature Provider + +The OpenFeature provider (`mixpanel-openfeature`) is published to PyPI independently from the core SDK. + +## Prerequisites + +- Python 3.9+ +- `build` and `twine` packages: `pip install build twine` +- A PyPI API token with permission to upload to the `mixpanel-openfeature` project + - Create one at https://pypi.org/manage/account/token/ + - For the first upload, you'll need an account-scoped token (project-scoped tokens can only be created after the project exists on PyPI) + +## Releasing + +1. Update the version in `pyproject.toml` + +2. Build the package: + ```bash + cd openfeature-provider + python -m build + ``` + +3. Verify the built artifacts look correct: + ```bash + ls dist/ + # Should show: mixpanel_openfeature--py3-none-any.whl + # mixpanel_openfeature-.tar.gz + ``` + +4. Upload to PyPI: + ```bash + python -m twine upload dist/* + ``` + Twine will prompt for credentials. Use `__token__` as the username and your API token as the password. Alternatively, configure `~/.pypirc`: + ```ini + [pypi] + username = __token__ + password = pypi- + ``` + +5. Verify at https://pypi.org/project/mixpanel-openfeature/ + +## Versioning + +The OpenFeature provider is versioned independently from the core SDK. The core SDK dependency version is pinned in `pyproject.toml` (`mixpanel>=5.1.0,<6`) — update it when the provider needs features from a newer core SDK release. diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml new file mode 100644 index 0000000..771c0d9 --- /dev/null +++ b/openfeature-provider/pyproject.toml @@ -0,0 +1,98 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mixpanel-openfeature" +version = "0.1.0" +description = "OpenFeature provider for the Mixpanel Python SDK" +license = "Apache-2.0" +authors = [ + {name = "Mixpanel, Inc.", email = "dev@mixpanel.com"}, +] +requires-python = ">=3.9" +dependencies = [ + "mixpanel>=5.1.0,<6", + "openfeature-sdk>=0.7.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "pytest-asyncio>=0.23.0", + "pytest-cov>=6.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +# --- Ruff configuration (mirrors main project) --- + +[tool.ruff] +target-version = "py39" +line-length = 88 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # --- Rule conflicts --- + "D203", # conflicts with D211 (no-blank-line-before-class) + "D213", # conflicts with D212 (multi-line-summary-first-line) + "COM812", # conflicts with ruff formatter + "ISC001", # conflicts with ruff formatter + + # --- Type annotations (separate effort) --- + "ANN", # all annotation rules + + # --- Docstrings (separate effort) --- + "D100", # undocumented-public-module + "D101", # undocumented-public-class + "D102", # undocumented-public-method + "D103", # undocumented-public-function + "D104", # undocumented-public-package + "D105", # undocumented-magic-method + "D107", # undocumented-public-init + + # --- Boolean arguments (public API) --- + "FBT", # boolean-type-hint / boolean-default / boolean-positional + + # --- TODO/FIXME enforcement --- + "TD002", # missing-todo-author + "TD003", # missing-todo-link + "FIX001", # line-contains-fixme + "FIX002", # line-contains-todo + + # --- Exception message style --- + "EM101", # raw-string-in-exception + "EM103", # dot-format-in-exception + "TRY003", # raise-vanilla-args + + # --- Other pragmatic exclusions --- + "PLR0913", # too-many-arguments + "PLR0911", # too-many-return-statements (_resolve has many type-check branches) + "E501", # line-too-long (formatter handles code) + "FA100", # future-rewritable-type-annotation + "BLE001", # blind-exception (catching Exception in flag resolution is intentional) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ + "S101", # assert + "S105", # hardcoded-password-string (test fixtures) + "S106", # hardcoded-password-func-arg + "SLF001", # private-member-access + "PLR2004", # magic-value-comparison + "D", # all docstring rules + "PT018", # pytest-composite-assertion + "INP001", # implicit-namespace-package (no __init__.py in tests) + "ARG", # unused arguments (lambda stubs in mocks) +] + +[tool.ruff.lint.isort] +known-first-party = ["mixpanel", "mixpanel_openfeature"] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/openfeature-provider/src/mixpanel_openfeature/__init__.py b/openfeature-provider/src/mixpanel_openfeature/__init__.py new file mode 100644 index 0000000..322c6b7 --- /dev/null +++ b/openfeature-provider/src/mixpanel_openfeature/__init__.py @@ -0,0 +1,3 @@ +from .provider import MixpanelProvider + +__all__ = ["MixpanelProvider"] diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py new file mode 100644 index 0000000..ec16d78 --- /dev/null +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import math +import typing +from collections.abc import Mapping, Sequence +from typing import Optional, Union + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider, Metadata + +from mixpanel import Mixpanel +from mixpanel.flags.types import LocalFlagsConfig, RemoteFlagsConfig, SelectedVariant + +FlagValueType = Union[bool, str, int, float, list, dict, None] + + +class MixpanelProvider(AbstractProvider): + """An OpenFeature provider backed by a Mixpanel feature flags provider.""" + + def __init__( + self, + flags_provider: typing.Any, + mixpanel_instance: Optional[Mixpanel] = None, + ) -> None: + super().__init__() + self._flags_provider = flags_provider + self._mixpanel = mixpanel_instance + + @classmethod + def from_local_config( + cls, token: str, config: LocalFlagsConfig + ) -> MixpanelProvider: + """Create a MixpanelProvider backed by a local flags provider. + + :param str token: your project's Mixpanel token + :param LocalFlagsConfig config: configuration for local feature flags + """ + mp = Mixpanel(token, local_flags_config=config) + local_flags = mp.local_flags + local_flags.start_polling_for_definitions() + return cls(local_flags, mixpanel_instance=mp) + + @classmethod + def from_remote_config( + cls, token: str, config: RemoteFlagsConfig + ) -> MixpanelProvider: + """Create a MixpanelProvider backed by a remote flags provider. + + :param str token: your project's Mixpanel token + :param RemoteFlagsConfig config: configuration for remote feature flags + """ + mp = Mixpanel(token, remote_flags_config=config) + remote_flags = mp.remote_flags + return cls(remote_flags, mixpanel_instance=mp) + + @property + def mixpanel(self) -> Optional[Mixpanel]: + """The Mixpanel instance used by this provider, if created via a class method.""" + return self._mixpanel + + def get_metadata(self) -> Metadata: + return Metadata(name="mixpanel-provider") + + def shutdown(self) -> None: + self._flags_provider.shutdown() + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, bool, evaluation_context) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, str, evaluation_context) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, int, evaluation_context) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, float, evaluation_context) + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[ + Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] + ]: + return self._resolve(flag_key, default_value, None, evaluation_context) + + @staticmethod + def _unwrap_value(value: typing.Any) -> typing.Any: + if isinstance(value, dict): + return {k: MixpanelProvider._unwrap_value(v) for k, v in value.items()} + if isinstance(value, list): + return [MixpanelProvider._unwrap_value(item) for item in value] + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + @staticmethod + def _build_user_context( + evaluation_context: typing.Optional[EvaluationContext], + ) -> dict: + user_context: dict = {} + if evaluation_context is not None: + if evaluation_context.attributes: + for k, v in evaluation_context.attributes.items(): + user_context[k] = MixpanelProvider._unwrap_value(v) + if evaluation_context.targeting_key: + user_context["targetingKey"] = evaluation_context.targeting_key + return user_context + + def _resolve( + self, + flag_key: str, + default_value: typing.Any, + expected_type: typing.Optional[type], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + if not self._are_flags_ready(): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.PROVIDER_NOT_READY, + reason=Reason.ERROR, + ) + + fallback = SelectedVariant(variant_value=default_value) + user_context = self._build_user_context(evaluation_context) + try: + result = self._flags_provider.get_variant(flag_key, fallback, user_context) + except Exception: + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.GENERAL, + reason=Reason.ERROR, + ) + + if result is fallback: + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.FLAG_NOT_FOUND, + reason=Reason.DEFAULT, + ) + + value = result.variant_value + variant_key = result.variant_key + + if expected_type is None: + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.TARGETING_MATCH + ) + + # In Python, bool is a subclass of int, so isinstance(True, int) + # returns True. Reject bools early when expecting numeric types. + if expected_type in (int, float) and isinstance(value, bool): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", + reason=Reason.ERROR, + ) + + if expected_type is int and isinstance(value, float): + if math.isfinite(value) and value == math.floor(value): + return FlagResolutionDetails( + value=int(value), variant=variant_key, reason=Reason.TARGETING_MATCH + ) + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + error_message=f"Expected int, got float (value={value} is not a whole number)", + reason=Reason.ERROR, + ) + + if expected_type is float and isinstance(value, (int, float)): + return FlagResolutionDetails( + value=float(value), variant=variant_key, reason=Reason.TARGETING_MATCH + ) + + if not isinstance(value, expected_type): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", + reason=Reason.ERROR, + ) + + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.TARGETING_MATCH + ) + + def _are_flags_ready(self) -> bool: + if hasattr(self._flags_provider, "are_flags_ready"): + return self._flags_provider.are_flags_ready() + return True diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py new file mode 100644 index 0000000..723f9ce --- /dev/null +++ b/openfeature-provider/tests/test_provider.py @@ -0,0 +1,452 @@ +from unittest.mock import MagicMock + +import pytest +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason + +from mixpanel.flags.types import SelectedVariant +from mixpanel_openfeature import MixpanelProvider + + +@pytest.fixture +def mock_flags(): + flags = MagicMock() + flags.are_flags_ready.return_value = True + return flags + + +@pytest.fixture +def provider(mock_flags): + return MixpanelProvider(mock_flags) + + +def setup_flag(mock_flags, flag_key, value, variant_key="variant-key"): + """Configure mock to return a SelectedVariant with the given value.""" + mock_flags.get_variant.side_effect = lambda key, fallback, ctx: ( + SelectedVariant(variant_key=variant_key, variant_value=value) + if key == flag_key + else fallback + ) + + +def setup_flag_not_found(mock_flags, flag_key): + """Configure mock to return the fallback (identity check triggers FLAG_NOT_FOUND).""" + mock_flags.get_variant.side_effect = lambda key, fallback, ctx: fallback + + +# --- Metadata --- + + +def test_metadata_name(provider): + assert provider.get_metadata().name == "mixpanel-provider" + + +# --- Boolean evaluation --- + + +def test_resolves_boolean_true(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", True) + result = provider.resolve_boolean_details("bool-flag", False) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_resolves_boolean_false(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", False) + result = provider.resolve_boolean_details("bool-flag", True) + assert result.value is False + assert result.reason == Reason.TARGETING_MATCH + + +# --- String evaluation --- + + +def test_resolves_string(provider, mock_flags): + setup_flag(mock_flags, "string-flag", "hello") + result = provider.resolve_string_details("string-flag", "default") + assert result.value == "hello" + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +# --- Integer evaluation --- + + +def test_resolves_integer(provider, mock_flags): + setup_flag(mock_flags, "int-flag", 42) + result = provider.resolve_integer_details("int-flag", 0) + assert result.value == 42 + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_resolves_integer_from_float_no_fraction(provider, mock_flags): + setup_flag(mock_flags, "int-flag", 42.0) + result = provider.resolve_integer_details("int-flag", 0) + assert result.value == 42 + assert isinstance(result.value, int) + assert result.reason == Reason.TARGETING_MATCH + + +# --- Float evaluation --- + + +def test_resolves_float(provider, mock_flags): + setup_flag(mock_flags, "float-flag", 3.14) + result = provider.resolve_float_details("float-flag", 0.0) + assert result.value == pytest.approx(3.14) + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_resolves_float_from_integer(provider, mock_flags): + setup_flag(mock_flags, "float-flag", 42) + result = provider.resolve_float_details("float-flag", 0.0) + assert result.value == 42.0 + assert isinstance(result.value, float) + assert result.reason == Reason.TARGETING_MATCH + + +# --- Object evaluation --- + + +def test_resolves_object_with_dict(provider, mock_flags): + setup_flag(mock_flags, "obj-flag", {"key": "value"}) + result = provider.resolve_object_details("obj-flag", {}) + assert result.value == {"key": "value"} + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_resolves_object_with_list(provider, mock_flags): + setup_flag(mock_flags, "obj-flag", [1, 2, 3]) + result = provider.resolve_object_details("obj-flag", []) + assert result.value == [1, 2, 3] + assert result.reason == Reason.TARGETING_MATCH + + +def test_resolves_object_with_string(provider, mock_flags): + setup_flag(mock_flags, "obj-flag", "hello") + result = provider.resolve_object_details("obj-flag", {}) + assert result.value == "hello" + assert result.reason == Reason.TARGETING_MATCH + + +def test_resolves_object_with_bool(provider, mock_flags): + setup_flag(mock_flags, "obj-flag", True) + result = provider.resolve_object_details("obj-flag", {}) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + +# --- Error: FLAG_NOT_FOUND --- + + +def test_flag_not_found_boolean(provider, mock_flags): + setup_flag_not_found(mock_flags, "missing-flag") + result = provider.resolve_boolean_details("missing-flag", True) + assert result.value is True + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + +def test_flag_not_found_string(provider, mock_flags): + setup_flag_not_found(mock_flags, "missing-flag") + result = provider.resolve_string_details("missing-flag", "fallback") + assert result.value == "fallback" + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + +def test_flag_not_found_integer(provider, mock_flags): + setup_flag_not_found(mock_flags, "missing-flag") + result = provider.resolve_integer_details("missing-flag", 99) + assert result.value == 99 + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + +def test_flag_not_found_float(provider, mock_flags): + setup_flag_not_found(mock_flags, "missing-flag") + result = provider.resolve_float_details("missing-flag", 1.5) + assert result.value == 1.5 + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + +def test_flag_not_found_object(provider, mock_flags): + setup_flag_not_found(mock_flags, "missing-flag") + result = provider.resolve_object_details("missing-flag", {"default": True}) + assert result.value == {"default": True} + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + +# --- Error: TYPE_MISMATCH --- + + +def test_type_mismatch_boolean_gets_string(provider, mock_flags): + setup_flag(mock_flags, "string-flag", "not-a-bool") + result = provider.resolve_boolean_details("string-flag", False) + assert result.value is False + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_string_gets_boolean(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", True) + result = provider.resolve_string_details("bool-flag", "default") + assert result.value == "default" + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_integer_gets_string(provider, mock_flags): + setup_flag(mock_flags, "string-flag", "not-a-number") + result = provider.resolve_integer_details("string-flag", 0) + assert result.value == 0 + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_float_gets_string(provider, mock_flags): + setup_flag(mock_flags, "string-flag", "not-a-number") + result = provider.resolve_float_details("string-flag", 0.0) + assert result.value == 0.0 + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_integer_gets_float_with_fraction(provider, mock_flags): + setup_flag(mock_flags, "float-flag", 3.14) + result = provider.resolve_integer_details("float-flag", 0) + assert result.value == 0 + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_integer_gets_boolean(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", True) + result = provider.resolve_integer_details("bool-flag", 0) + assert result.value == 0 + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +def test_type_mismatch_float_gets_boolean(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", True) + result = provider.resolve_float_details("bool-flag", 0.0) + assert result.value == 0.0 + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.reason == Reason.ERROR + + +# --- Error: PROVIDER_NOT_READY --- + + +def test_provider_not_ready_boolean(mock_flags): + mock_flags.are_flags_ready.return_value = False + provider = MixpanelProvider(mock_flags) + result = provider.resolve_boolean_details("any-flag", True) + assert result.value is True + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.reason == Reason.ERROR + + +def test_provider_not_ready_string(mock_flags): + mock_flags.are_flags_ready.return_value = False + provider = MixpanelProvider(mock_flags) + result = provider.resolve_string_details("any-flag", "default") + assert result.value == "default" + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.reason == Reason.ERROR + + +def test_provider_not_ready_integer(mock_flags): + mock_flags.are_flags_ready.return_value = False + provider = MixpanelProvider(mock_flags) + result = provider.resolve_integer_details("any-flag", 5) + assert result.value == 5 + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.reason == Reason.ERROR + + +def test_provider_not_ready_float(mock_flags): + mock_flags.are_flags_ready.return_value = False + provider = MixpanelProvider(mock_flags) + result = provider.resolve_float_details("any-flag", 2.5) + assert result.value == 2.5 + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.reason == Reason.ERROR + + +def test_provider_not_ready_object(mock_flags): + mock_flags.are_flags_ready.return_value = False + provider = MixpanelProvider(mock_flags) + result = provider.resolve_object_details("any-flag", {"default": True}) + assert result.value == {"default": True} + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.reason == Reason.ERROR + + +# --- Remote provider (no are_flags_ready) is always ready --- + + +def test_remote_provider_always_ready(): + remote_flags = MagicMock(spec=[]) # empty spec = no attributes + remote_flags.get_variant = MagicMock( + side_effect=lambda key, fallback, ctx: SelectedVariant( + variant_key="v1", variant_value=True + ) + ) + provider = MixpanelProvider(remote_flags) + result = provider.resolve_boolean_details("flag", False) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + +# --- Lifecycle --- + + +def test_shutdown_is_noop(provider): + provider.shutdown() # Should not raise + + +# --- EvaluationContext forwarding --- + + +def test_forwards_targeting_key(provider, mock_flags): + setup_flag(mock_flags, "flag", "val") + ctx = EvaluationContext(targeting_key="user-123") + provider.resolve_string_details("flag", "default", ctx) + _, _, user_context = mock_flags.get_variant.call_args[0] + assert user_context["targetingKey"] == "user-123" + + +def test_forwards_attributes_flat(provider, mock_flags): + setup_flag(mock_flags, "flag", "val") + ctx = EvaluationContext(attributes={"plan": "pro", "beta": True}) + provider.resolve_string_details("flag", "default", ctx) + _, _, user_context = mock_flags.get_variant.call_args[0] + assert user_context["plan"] == "pro" + assert user_context["beta"] is True + + +def test_forwards_full_context(provider, mock_flags): + setup_flag(mock_flags, "flag", "val") + ctx = EvaluationContext(targeting_key="user-456", attributes={"tier": "enterprise"}) + provider.resolve_string_details("flag", "default", ctx) + _, _, user_context = mock_flags.get_variant.call_args[0] + assert user_context == { + "targetingKey": "user-456", + "tier": "enterprise", + } + + +def test_no_context_passes_empty_dict(provider, mock_flags): + setup_flag(mock_flags, "flag", "val") + provider.resolve_string_details("flag", "default") + _, _, user_context = mock_flags.get_variant.call_args[0] + assert user_context == {} + + +# --- Variant key passthrough --- + + +def test_variant_key_present_in_boolean_resolution(provider, mock_flags): + setup_flag(mock_flags, "bool-flag", True, variant_key="control") + result = provider.resolve_boolean_details("bool-flag", False) + assert result.value is True + assert result.variant == "control" + assert result.reason == Reason.TARGETING_MATCH + + +def test_variant_key_present_in_string_resolution(provider, mock_flags): + setup_flag(mock_flags, "string-flag", "hello", variant_key="treatment-a") + result = provider.resolve_string_details("string-flag", "default") + assert result.value == "hello" + assert result.variant == "treatment-a" + assert result.reason == Reason.TARGETING_MATCH + + +def test_variant_key_present_in_integer_resolution(provider, mock_flags): + setup_flag(mock_flags, "int-flag", 42, variant_key="v2") + result = provider.resolve_integer_details("int-flag", 0) + assert result.value == 42 + assert result.variant == "v2" + assert result.reason == Reason.TARGETING_MATCH + + +def test_variant_key_present_in_float_resolution(provider, mock_flags): + setup_flag(mock_flags, "float-flag", 3.14, variant_key="v3") + result = provider.resolve_float_details("float-flag", 0.0) + assert result.value == pytest.approx(3.14) + assert result.variant == "v3" + assert result.reason == Reason.TARGETING_MATCH + + +def test_variant_key_present_in_object_resolution(provider, mock_flags): + setup_flag(mock_flags, "obj-flag", {"key": "value"}, variant_key="v4") + result = provider.resolve_object_details("obj-flag", {}) + assert result.value == {"key": "value"} + assert result.variant == "v4" + assert result.reason == Reason.TARGETING_MATCH + + +# --- SDK exception handling --- + + +def test_sdk_exception_returns_default_boolean(provider, mock_flags): + mock_flags.get_variant.side_effect = RuntimeError("SDK failure") + result = provider.resolve_boolean_details("flag", True) + assert result.value is True + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + + +def test_sdk_exception_returns_default_string(provider, mock_flags): + mock_flags.get_variant.side_effect = RuntimeError("SDK failure") + result = provider.resolve_string_details("flag", "fallback") + assert result.value == "fallback" + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + + +def test_sdk_exception_returns_default_integer(provider, mock_flags): + mock_flags.get_variant.side_effect = RuntimeError("SDK failure") + result = provider.resolve_integer_details("flag", 99) + assert result.value == 99 + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + + +# --- Null variant key --- + + +def test_null_variant_key_boolean(provider, mock_flags): + setup_flag(mock_flags, "flag", True, variant_key=None) + result = provider.resolve_boolean_details("flag", False) + assert result.value is True + assert result.variant is None + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_null_variant_key_string(provider, mock_flags): + setup_flag(mock_flags, "flag", "hello", variant_key=None) + result = provider.resolve_string_details("flag", "default") + assert result.value == "hello" + assert result.variant is None + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None + + +def test_null_variant_key_object(provider, mock_flags): + setup_flag(mock_flags, "flag", {"key": "value"}, variant_key=None) + result = provider.resolve_object_details("flag", {}) + assert result.value == {"key": "value"} + assert result.variant is None + assert result.reason == Reason.TARGETING_MATCH + assert result.error_code is None diff --git a/pyproject.toml b/pyproject.toml index fe774aa..78b5559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,12 +67,14 @@ commands = [ [tool.pytest.ini_options] asyncio_mode = "auto" +addopts = "--ignore=openfeature-provider" # --- Ruff configuration (strict guide: select ALL, exclude explicitly) --- [tool.ruff] target-version = "py39" line-length = 88 +extend-exclude = ["openfeature-provider"] [tool.ruff.lint] select = ["ALL"] From 2c364ada443eb081b80a7d968d79d7dd43802183 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 10 Apr 2026 09:24:19 -0700 Subject: [PATCH 206/208] Add README for OpenFeature provider package (#166) Documents installation, initialization (local/remote/existing instance), usage examples, context mapping, error handling, and troubleshooting. Mirrors the structure of the Java OpenFeature provider README. Co-authored-by: Claude Opus 4.6 --- openfeature-provider/README.md | 303 +++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 openfeature-provider/README.md diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md new file mode 100644 index 0000000..2e45f54 --- /dev/null +++ b/openfeature-provider/README.md @@ -0,0 +1,303 @@ +# Mixpanel Python OpenFeature Provider + +[![PyPI](https://img.shields.io/pypi/v/mixpanel-openfeature.svg)](https://pypi.org/project/mixpanel-openfeature/) +[![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mixpanel/mixpanel-python/blob/master/LICENSE) + +An [OpenFeature](https://openfeature.dev/) provider that integrates Mixpanel's feature flags with the OpenFeature Python SDK. This allows you to use Mixpanel's feature flagging capabilities through OpenFeature's standardized, vendor-agnostic API. + +## Overview + +This package provides a bridge between Mixpanel's native feature flags implementation and the OpenFeature specification. By using this provider, you can: + +- Leverage Mixpanel's powerful feature flag and experimentation platform +- Use OpenFeature's standardized API for flag evaluation +- Easily switch between feature flag providers without changing your application code +- Integrate with OpenFeature's ecosystem of tools and frameworks + +## Installation + +```bash +pip install mixpanel-openfeature +``` + +You will also need the OpenFeature Python SDK: + +```bash +pip install openfeature-sdk +``` + +## Quick Start + +```python +from mixpanel_openfeature import MixpanelProvider +from mixpanel.flags.types import LocalFlagsConfig +from openfeature import api + +# 1. Create and register the provider with local evaluation +provider = MixpanelProvider.from_local_config( + "YOUR_PROJECT_TOKEN", + LocalFlagsConfig(token="YOUR_PROJECT_TOKEN"), +) +api.set_provider(provider) + +# 2. Get a client and evaluate flags +client = api.get_client() +show_new_feature = client.get_boolean_value("new-feature-flag", False) + +if show_new_feature: + print("New feature is enabled!") +``` + +## Initialization + +The provider supports three initialization methods depending on your evaluation strategy: + +### Local Evaluation + +Evaluates flags locally using cached flag definitions that are polled from Mixpanel. This is the recommended approach for most server-side applications as it minimizes latency. + +```python +from mixpanel_openfeature import MixpanelProvider +from mixpanel.flags.types import LocalFlagsConfig + +provider = MixpanelProvider.from_local_config( + "YOUR_PROJECT_TOKEN", + LocalFlagsConfig(token="YOUR_PROJECT_TOKEN"), +) +``` + +This automatically starts polling for flag definitions in the background. + +### Remote Evaluation + +Evaluates flags by making a request to Mixpanel's servers for each evaluation. Use this when you need real-time flag values and can tolerate the additional network latency. + +```python +from mixpanel_openfeature import MixpanelProvider +from mixpanel.flags.types import RemoteFlagsConfig + +provider = MixpanelProvider.from_remote_config( + "YOUR_PROJECT_TOKEN", + RemoteFlagsConfig(token="YOUR_PROJECT_TOKEN"), +) +``` + +### Using an Existing Mixpanel Instance + +If your application already has a `Mixpanel` instance configured, you can create the provider from its flags provider directly rather than having the provider create a new one: + +```python +from mixpanel import Mixpanel +from mixpanel.flags.types import LocalFlagsConfig +from mixpanel_openfeature import MixpanelProvider + +# Your existing Mixpanel instance +mp = Mixpanel("YOUR_PROJECT_TOKEN", local_flags_config=LocalFlagsConfig(token="YOUR_PROJECT_TOKEN")) +local_flags = mp.local_flags +local_flags.start_polling_for_definitions() + +# Wrap the existing flags provider with OpenFeature +provider = MixpanelProvider(local_flags) +``` + +> **Note:** When using this constructor, `provider.mixpanel` will return `None` since the provider does not own the `Mixpanel` instance. + +## Usage Examples + +### Basic Boolean Flag + +```python +client = api.get_client() + +# Get a boolean flag with a default value +is_feature_enabled = client.get_boolean_value("my-feature", False) + +if is_feature_enabled: + # Show the new feature + pass +``` + +### Mixpanel Flag Types and OpenFeature Evaluation Methods + +Mixpanel feature flags support three flag types. Use the corresponding OpenFeature evaluation method based on your flag's variant values: + +| Mixpanel Flag Type | Variant Values | OpenFeature Method | +|---|---|---| +| Feature Gate | `True` / `False` | `get_boolean_value()` | +| Experiment | boolean, string, number, or JSON object | `get_boolean_value()`, `get_string_value()`, `get_integer_value()`, `get_float_value()`, or `get_object_value()` | +| Dynamic Config | JSON object | `get_object_value()` | + +```python +client = api.get_client() + +# Feature Gate - boolean variants +is_feature_on = client.get_boolean_value("new-checkout", False) + +# Experiment with string variants +button_color = client.get_string_value("button-color-test", "blue") + +# Experiment with integer variants +max_items = client.get_integer_value("max-items", 10) + +# Experiment with float variants +threshold = client.get_float_value("score-threshold", 0.5) + +# Dynamic Config - JSON object variants +feature_config = client.get_object_value("homepage-layout", {"layout": "default"}) +``` + +### Getting Full Resolution Details + +If you need additional metadata about the flag evaluation: + +```python +client = api.get_client() + +details = client.get_boolean_details("my-feature", False) + +print(details.value) # The resolved value +print(details.variant) # The variant key from Mixpanel +print(details.reason) # Why this value was returned +print(details.error_code) # Error code if evaluation failed +``` + +### Setting Context + +You can pass evaluation context that will be sent to Mixpanel for flag evaluation: + +```python +from openfeature.evaluation_context import EvaluationContext + +context = EvaluationContext( + targeting_key="user-123", + attributes={ + "email": "user@example.com", + "plan": "premium", + "beta_tester": True, + }, +) + +value = client.get_boolean_value("premium-feature", False, context) +``` + +### Accessing the Underlying Mixpanel Instance + +If you initialized the provider with a token and config, you can access the underlying `Mixpanel` instance for sending events or profile updates: + +```python +mp = provider.mixpanel +``` + +> **Note:** This returns `None` if the provider was constructed with a flags provider directly. + +### Shutdown + +When your application is shutting down, call `shutdown()` to clean up resources: + +```python +provider.shutdown() +``` + +## Context Mapping + +### All Properties Passed Directly + +All properties in the OpenFeature `EvaluationContext` are passed directly to Mixpanel's feature flag evaluation. There is no transformation or filtering of properties. + +```python +# This OpenFeature context... +context = EvaluationContext( + targeting_key="user-123", + attributes={ + "email": "user@example.com", + "plan": "premium", + }, +) + +# ...is passed to Mixpanel as-is for flag evaluation +``` + +### targetingKey is Not Special + +Unlike some feature flag providers, `targetingKey` is **not** used as a special bucketing key in Mixpanel. It is simply passed as another context property. Mixpanel's server-side configuration determines which properties are used for targeting rules and bucketing. + +## Error Handling + +The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation: + +### PROVIDER_NOT_READY + +Returned when flags are evaluated before the local flags provider has finished loading flag definitions. This only applies when using local evaluation. + +```python +details = client.get_boolean_details("my-feature", False) + +if details.error_code == ErrorCode.PROVIDER_NOT_READY: + print("Provider still loading, using default value") +``` + +### FLAG_NOT_FOUND + +Returned when the requested flag does not exist in Mixpanel. + +```python +details = client.get_boolean_details("nonexistent-flag", False) + +if details.error_code == ErrorCode.FLAG_NOT_FOUND: + print("Flag does not exist, using default value") +``` + +### TYPE_MISMATCH + +Returned when the flag value type does not match the requested type. The provider supports some numeric coercions (e.g., a whole-number `float` flag value can be retrieved via `get_integer_value()`, and any numeric type can be retrieved via `get_float_value()`), but incompatible types will return this error. + +```python +# If 'my-flag' is configured as a string in Mixpanel... +details = client.get_boolean_details("my-flag", False) + +if details.error_code == ErrorCode.TYPE_MISMATCH: + print("Flag is not a boolean, using default value") +``` + +## Troubleshooting + +### Flags Always Return Default Values + +**Possible causes:** + +1. **Provider not ready (local evaluation):** The local flags provider may still be loading flag definitions. Flag definitions are polled asynchronously after the provider is created. Allow time for the initial fetch to complete, or check the `PROVIDER_NOT_READY` error code. + +2. **Invalid project token:** Verify the token passed to the config matches your Mixpanel project. + +3. **Flag not configured:** Verify the flag exists in your Mixpanel project and is enabled. + +4. **Network issues:** Check that your application can reach Mixpanel's API servers. + +### Type Mismatch Errors + +If you are getting `TYPE_MISMATCH` errors: + +1. **Check flag configuration:** Verify the flag's value type in Mixpanel matches how you are evaluating it. For example, if the flag value is the string `"true"`, use `get_string_value()`, not `get_boolean_value()`. + +2. **Use `get_object_value()` for complex types:** For JSON objects or arrays, use `get_object_value()`. + +3. **Numeric coercion:** Integer evaluation accepts whole-number `float` values. Float evaluation accepts any numeric type (`int` or `float`). + +### Exposure Events Not Tracking + +If `$experiment_started` events are not appearing in Mixpanel: + +1. **Verify Mixpanel tracking is working:** Test that other Mixpanel events are being tracked successfully. + +2. **Check for duplicate evaluations:** Mixpanel only tracks the first exposure per flag per session to avoid duplicate events. + +## Requirements + +- Python 3.9 or higher +- `mixpanel` 5.1.0+ +- `openfeature-sdk` 0.7.0+ + +## License + +Apache-2.0 From 8fa2f53939b2a14eef71e341de077eb1917188c0 Mon Sep 17 00:00:00 2001 From: Scot Matson Date: Tue, 19 May 2026 13:08:31 -0700 Subject: [PATCH 207/208] chore: add SECURITY.md vulnerability disclosure policy (#174) Co-authored-by: Scot Matson <4695187+scotmatson@users.noreply.github.com> --- SECURITY.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eb5cdfc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting a Vulnerability + +We take the security of our services and the privacy of our users' data very seriously. If you have discovered a security vulnerability, we appreciate your help in disclosing it to us responsibly. + +**Please do not report security vulnerabilities through public GitHub issues or public forums.** + +### How to Report +Please choose the path that best fits your intent: + +* **Responsible Disclosure:** If you have identified a security vulnerability, please email **[security@mixpanel.com](mailto:security@mixpanel.com)**. + * *Note:* Your report will be routed to our internal ticketing system. We will acknowledge receipt of your findings. Please be advised that we do not maintain ongoing communication regarding the status of reports unless we have specific follow-up questions. + +* **Bug Bounty Program:** If you are a security researcher interested in participating in our private bug bounty program, please email **[bugbounty@mixpanel.com](mailto:bugbounty@mixpanel.com)** to request onboarding instructions. + * *Note:* Participation in our private program is subject to eligibility requirements, including a verification process to ensure researchers are in good standing on the [HackerOne](https://www.hackerone.com/) platform. + +### What to Include in Your Report +To help us triage the issue effectively, please include: +* **Summary:** A clear description of the vulnerability. +* **Environment:** The affected service, SDK, or repository. +* **Reproduction Steps:** Step-by-step instructions to reproduce the issue. +* **Impact:** A description of the potential risk. +* **Remediation Suggestions:** Any specific recommendations you have for mitigating or fixing the vulnerability. + +### Supported Versions +We are committed to securing our latest stable releases. We recommend all users keep their implementations updated to the most current version to ensure they have the latest security patches. From 58d9c388b4d61e196d483b4a85758836280568a0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 20 May 2026 10:18:18 -0400 Subject: [PATCH 208/208] chore: standardize release process (#171) --- .github/modules.json | 18 ++ .github/scripts/generate-changelog.sh | 87 +++++++++ .github/workflows/pr-title-check.yml | 51 ++++++ .github/workflows/prepare-release.yml | 246 ++++++++++++++++++++++++++ .github/workflows/release-pypi.yml | 238 +++++++++++++++++++++++++ CHANGELOG.md | 6 + README.md | 2 + openfeature-provider/CHANGELOG.md | 4 + openfeature-provider/README.md | 2 + 9 files changed, 654 insertions(+) create mode 100644 .github/modules.json create mode 100755 .github/scripts/generate-changelog.sh create mode 100644 .github/workflows/pr-title-check.yml create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release-pypi.yml create mode 100644 CHANGELOG.md create mode 100644 openfeature-provider/CHANGELOG.md diff --git a/.github/modules.json b/.github/modules.json new file mode 100644 index 0000000..ba1a184 --- /dev/null +++ b/.github/modules.json @@ -0,0 +1,18 @@ +{ + "analytics": { + "tag_prefix": "v", + "pyproject_toml": "pyproject.toml", + "version_files": ["mixpanel/__init__.py"], + "changelog": "CHANGELOG.md", + "readme": "README.md", + "package_name": "mixpanel" + }, + "openfeature": { + "tag_prefix": "openfeature/v", + "pyproject_toml": "openfeature-provider/pyproject.toml", + "version_files": [], + "changelog": "openfeature-provider/CHANGELOG.md", + "readme": "openfeature-provider/README.md", + "package_name": "mixpanel-openfeature" + } +} diff --git a/.github/scripts/generate-changelog.sh b/.github/scripts/generate-changelog.sh new file mode 100755 index 0000000..7ebd946 --- /dev/null +++ b/.github/scripts/generate-changelog.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +MODULE="$1" +VERSION_LABEL="$2" +REPO_URL="$3" +END_REF="${4:-HEAD}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MODULES_JSON="$SCRIPT_DIR/../modules.json" + +TAG_PREFIX=$(jq -e -r --arg m "$MODULE" '.[$m].tag_prefix' "$MODULES_JSON") || { + echo "Unknown module: $MODULE. Valid modules: $(jq -r 'keys | join(", ")' "$MODULES_JSON")" >&2 + exit 1 +} +TAG_GLOB="${TAG_PREFIX}*" + +PREVIOUS_TAG=$(git tag --sort=-creatordate --list "$TAG_GLOB" | head -1 || true) + +if [ -z "$PREVIOUS_TAG" ]; then + RANGE="$END_REF" +else + RANGE="${PREVIOUS_TAG}..${END_REF}" +fi + +DATE=$(date +%Y-%m-%d) +SAFE_URL=$(printf '%s' "$REPO_URL" | sed 's|[&/\]|\\&|g') + +declare -a FEATURES=() +declare -a FIXES=() +declare -a CHORES=() + +while IFS= read -r line; do + [ -z "$line" ] && continue + MSG=$(echo "$line" | cut -d' ' -f2-) + + # feat / fix: include whether bare, scoped to our module, or scoped to + # `all` (cross-cutting changes that appear in every module's changelog). + # chore: include only when explicitly scoped to our module or `all` — + # bare `chore:` is the convention for changes intentionally hidden from + # the changelog (release prep PRs, CI tweaks, lockfile bumps, internal + # docs). + if [[ "$MSG" =~ ^(feat|fix)(\((${MODULE}|all)\))?:\ (.+) ]]; then + TYPE="${BASH_REMATCH[1]}" + DESC="${BASH_REMATCH[4]}" + DESC=$(echo "$DESC" | sed -E "s|\(#([0-9]+)\)|([#\1](${SAFE_URL}/pull/\1))|g") + case "$TYPE" in + feat) FEATURES+=("$DESC") ;; + fix) FIXES+=("$DESC") ;; + esac + elif [[ "$MSG" =~ ^chore\((${MODULE}|all)\):\ (.+) ]]; then + DESC="${BASH_REMATCH[2]}" + DESC=$(echo "$DESC" | sed -E "s|\(#([0-9]+)\)|([#\1](${SAFE_URL}/pull/\1))|g") + CHORES+=("$DESC") + fi +done < <(git log --oneline "$RANGE") + +echo "## [${VERSION_LABEL}](${REPO_URL}/tree/${VERSION_LABEL}) (${DATE})" +echo "" + +if [ ${#FEATURES[@]} -gt 0 ]; then + echo "### Features" + for entry in "${FEATURES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ ${#FIXES[@]} -gt 0 ]; then + echo "### Fixes" + for entry in "${FIXES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ ${#CHORES[@]} -gt 0 ]; then + echo "### Chores" + for entry in "${CHORES[@]}"; do + echo "- ${entry}" + done + echo "" +fi + +if [ -n "$PREVIOUS_TAG" ]; then + echo "[Full Changelog](${REPO_URL}/compare/${PREVIOUS_TAG}...${VERSION_LABEL})" +fi diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 0000000..59867ed --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,51 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + check-title: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: .github/modules.json + sparse-checkout-cone-mode: false + + - name: Check PR title format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + MODULE_LIST=$(jq -r 'keys | join("|")' .github/modules.json) + # Scope is optional. Bare, scoped to a known module, or scoped to + # `all` (cross-cutting changes that appear in every module's + # changelog) all pass. + MAIN_PATTERN="^(feat|fix|chore)(\((${MODULE_LIST}|all)\))?: .+" + RELEASE_PATTERN="^release: .+" + + if [[ "$PR_TITLE" =~ $MAIN_PATTERN ]] || [[ "$PR_TITLE" =~ $RELEASE_PATTERN ]]; then + echo "PR title is valid: $PR_TITLE" + exit 0 + fi + + echo "PR title does not match the required format." + echo "" + echo " Got: $PR_TITLE" + echo "" + echo "Expected one of:" + echo " feat: description" + echo " fix: description" + echo " chore: description" + echo " feat(|all): description" + echo " fix(|all): description" + echo " chore(|all): description" + echo " release: description" + echo "" + echo "Valid scopes: ${MODULE_LIST//|/, }, all" + exit 1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..cbd397c --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,246 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + module: + description: 'Module to release (must match a key in .github/modules.json)' + required: true + type: string + version: + description: 'Release version (e.g., 1.3.0 or 1.3.0-beta.1)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-release-${{ inputs.module }} + cancel-in-progress: false + +jobs: + prepare: + name: "Prepare ${{ inputs.module }} ${{ inputs.version }}" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate inputs + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Invalid version format: $VERSION" + exit 1 + fi + jq -e --arg m "$MODULE" '.[$m]' .github/modules.json > /dev/null || { + echo "::error::Unknown module '$MODULE'. Valid modules: $(jq -r 'keys | join(", ")' .github/modules.json)" + exit 1 + } + + - name: Resolve module config + id: config + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + run: | + MODULE_CONFIG=$(jq -e --arg m "$MODULE" '.[$m]' .github/modules.json) + + TAG_PREFIX=$(echo "$MODULE_CONFIG" | jq -r '.tag_prefix') + { + echo "tag=${TAG_PREFIX}${VERSION}" + echo "tag_prefix=${TAG_PREFIX}" + echo "pyproject_toml=$(echo "$MODULE_CONFIG" | jq -r '.pyproject_toml')" + # Newline-separated list of additional Python source files holding the version literal. + echo "version_files<> "$GITHUB_OUTPUT" + + - name: Validate version not already released + env: + TAG: ${{ steps.config.outputs.tag }} + run: | + if git tag -l "$TAG" | grep -q .; then + echo "::error::Tag $TAG already exists" + exit 1 + fi + + - name: Clean up existing release branch and PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.config.outputs.branch }} + run: | + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) + if [[ -n "$EXISTING_PR" ]]; then + echo "Closing existing PR #$EXISTING_PR and deleting branch" + gh pr close "$EXISTING_PR" --delete-branch + elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "Deleting orphaned branch $BRANCH" + git push origin --delete "$BRANCH" + fi + + - name: Create release branch + env: + BRANCH: ${{ steps.config.outputs.branch }} + run: git checkout -b "$BRANCH" + + - name: Bump version + env: + VERSION: ${{ inputs.version }} + PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }} + VERSION_FILES: ${{ steps.config.outputs.version_files }} + run: | + set -euo pipefail + + # If pyproject.toml uses dynamic version, the canonical source lives + # in the listed Python file(s) (e.g. `__version__ = "X.Y.Z"`). Otherwise + # the literal `version = "X.Y.Z"` line in pyproject.toml is the source. + IS_DYNAMIC=$(python3 - </dev/null; then + echo "::error::No __version__ literal found in $vf" + exit 1 + fi + OLD=$(grep -E "^__version__\s*=\s*['\"][^'\"]+['\"]" "$vf" | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/") + echo "Bumping ${vf}: ${OLD} -> ${VERSION}" + sed -i.bak -E "s|^__version__[[:space:]]*=[[:space:]]*['\"][^'\"]+['\"]|__version__ = \"${VERSION}\"|" "$vf" + rm -f "${vf}.bak" + done <<< "$VERSION_FILES" + else + if ! grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" >/dev/null; then + echo "::error::No literal version line found in $PYPROJECT_TOML" + exit 1 + fi + OLD=$(grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" | head -1 | sed -E "s/.*\"([^\"]+)\".*/\1/") + echo "Bumping ${PYPROJECT_TOML}: ${OLD} -> ${VERSION}" + sed -i.bak -E "s|^version[[:space:]]*=[[:space:]]*\"[^\"]+\"|version = \"${VERSION}\"|" "$PYPROJECT_TOML" + rm -f "${PYPROJECT_TOML}.bak" + fi + + - name: Update README version header + env: + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + TAG: ${{ steps.config.outputs.tag }} + README: ${{ steps.config.outputs.readme }} + run: | + DATE=$(date +"%B %d, %Y") + # The `1,/pat/` address range bounds substitution to the first match + # so older changelog entries inside the README aren't trampled. + sed -i -E \ + "1,/^##### _.*_ - \[.*\]\(.*\)\$/ s|^##### _.*_ - \[.*\]\(.*\)\$|##### _${DATE}_ - [${TAG}](${REPO_URL}/releases/tag/${TAG})|" \ + "$README" + + - name: Generate changelog + env: + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + MODULE: ${{ inputs.module }} + TAG: ${{ steps.config.outputs.tag }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + run: | + CHANGELOG=$(.github/scripts/generate-changelog.sh \ + "$MODULE" "$TAG" "$REPO_URL" HEAD) + + if [ -f "$CHANGELOG_FILE" ]; then + { + printf '# Changelog\n\n%s\n' "$CHANGELOG" + sed '1{/^# Changelog$/d;}' "$CHANGELOG_FILE" + } > CHANGELOG.new.md + mv CHANGELOG.new.md "$CHANGELOG_FILE" + else + mkdir -p "$(dirname "$CHANGELOG_FILE")" + printf '# Changelog\n\n%s\n' "$CHANGELOG" > "$CHANGELOG_FILE" + fi + + - name: Commit and push + env: + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + BRANCH: ${{ steps.config.outputs.branch }} + PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }} + VERSION_FILES: ${{ steps.config.outputs.version_files }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + README: ${{ steps.config.outputs.readme }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$PYPROJECT_TOML" "$CHANGELOG_FILE" "$README" + while IFS= read -r FILE; do + [ -z "$FILE" ] && continue + git add "$FILE" + done <<< "$VERSION_FILES" + git commit -m "release: prepare ${MODULE} ${VERSION}" + git push origin "$BRANCH" + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODULE: ${{ inputs.module }} + VERSION: ${{ inputs.version }} + TAG: ${{ steps.config.outputs.tag }} + TAG_PREFIX: ${{ steps.config.outputs.tag_prefix }} + PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }} + CHANGELOG_FILE: ${{ steps.config.outputs.changelog }} + README: ${{ steps.config.outputs.readme }} + BRANCH: ${{ steps.config.outputs.branch }} + run: | + gh pr create \ + --title "release: prepare ${MODULE} ${VERSION}" \ + --body "$(cat <> "$GITHUB_OUTPUT" + + echo "Resolved tag '$TAG' -> module '$MODULE', version '$VERSION', project dir '$PROJECT_DIR'" + + - name: Verify tag commit is on master + env: + TAG: ${{ github.ref_name }} + run: | + git fetch origin master + TAG_SHA=$(git rev-parse HEAD) + if ! git merge-base --is-ancestor "$TAG_SHA" origin/master; then + echo "::error::Tag '$TAG' ($TAG_SHA) is not an ancestor of origin/master." + echo "Tags must be pushed from a commit on the master branch." + exit 1 + fi + + - name: Validate package version matches tag + env: + VERSION: ${{ steps.module.outputs.version }} + PYPROJECT_TOML: ${{ steps.module.outputs.pyproject_toml }} + VERSION_FILES: ${{ steps.module.outputs.version_files }} + run: | + set -euo pipefail + PKG_VERSION=$(python3 - </dev/null > release_notes.md || true + if [ ! -s release_notes.md ]; then + echo "Release $TAG" > release_notes.md + fi + echo "--- release_notes.md ---" + cat release_notes.md + + - name: Create draft GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + # Idempotent: if a draft release for this tag already exists, leave it alone. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release for $TAG already exists; skipping creation" + else + gh release create "$TAG" \ + --draft \ + --title "$TAG" \ + --notes-file release_notes.md + fi + + # Publish last - PyPI uploads are irreversible (releases can be yanked + # but not re-uploaded under the same version). The `release` GitHub + # Environment's required reviewer acts as the human gate. + # + # Trusted Publishing: twine >= 5 picks up the GitHub OIDC token + # automatically when `id-token: write` is set and no + # username/password/token is provided. + - name: Publish to PyPI + run: python -m twine upload --skip-existing --non-interactive dist/* + + - name: Summary + env: + MODULE: ${{ steps.module.outputs.module }} + VERSION: ${{ steps.module.outputs.version }} + PACKAGE_NAME: ${{ steps.module.outputs.package_name }} + TAG: ${{ github.ref_name }} + run: | + { + echo "## ${MODULE} ${VERSION} published" + echo "" + echo "- [PyPI](https://pypi.org/project/${PACKAGE_NAME}/${VERSION}/)" + echo "- [Draft GitHub Release](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG})" + echo "" + echo "### Next step" + echo "Review the draft GitHub release and click **Publish release** to make it live." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92efc3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +Release notes for the `mixpanel` Python package will be added here starting +with the first release made under the standardized release process. + +For prior history, see [`CHANGES.txt`](./CHANGES.txt). diff --git a/README.md b/README.md index 35586b5..97b902d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # mixpanel-python +##### _May 13, 2026_ - [v5.1.0](https://github.com/mixpanel/mixpanel-python/releases/tag/v5.1.0) + [![PyPI](https://img.shields.io/pypi/v/mixpanel)](https://pypi.org/project/mixpanel) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mixpanel)](https://pypi.org/project/mixpanel) [![PyPI - Downloads](https://img.shields.io/pypi/dm/mixpanel)](https://pypi.org/project/mixpanel) diff --git a/openfeature-provider/CHANGELOG.md b/openfeature-provider/CHANGELOG.md new file mode 100644 index 0000000..673454c --- /dev/null +++ b/openfeature-provider/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +Release notes for the `mixpanel-openfeature` package will be added here +starting with the first release made under the standardized release process. diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md index 2e45f54..e472f90 100644 --- a/openfeature-provider/README.md +++ b/openfeature-provider/README.md @@ -1,5 +1,7 @@ # Mixpanel Python OpenFeature Provider +##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-python/releases/tag/openfeature/v0.1.0) + [![PyPI](https://img.shields.io/pypi/v/mixpanel-openfeature.svg)](https://pypi.org/project/mixpanel-openfeature/) [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mixpanel/mixpanel-python/blob/master/LICENSE)