Skip to content
9 changes: 7 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions BUILD.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
31 changes: 31 additions & 0 deletions demo/local_flags.py
Original file line number Diff line number Diff line change
@@ -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())
35 changes: 35 additions & 0 deletions demo/remote_flags.py
Original file line number Diff line number Diff line change
@@ -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()
56 changes: 51 additions & 5 deletions mixpanel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -733,3 +778,4 @@ def _flush_endpoint(self, endpoint):
raise mp_e from orig_e
buf = buf[self._max_size:]
self._buffers[endpoint] = buf

Empty file added mixpanel/flags/__init__.py
Empty file.
Loading