Skip to content

Commit 0f8c84e

Browse files
efahkKwame Efah
andauthored
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 <[email protected]>
1 parent 87ec4ad commit 0f8c84e

File tree

14 files changed

+1172
-8
lines changed

14 files changed

+1172
-8
lines changed

.github/workflows/test.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ jobs:
2222
run: |
2323
python -m pip install --upgrade pip
2424
pip install -e .[test]
25-
- name: Test with pytest
25+
- name: Run tests
2626
run: |
27-
pytest test_mixpanel.py
27+
pytest --cov --cov-branch --cov-report=xml
28+
- name: Upload coverage reports to Codecov
29+
uses: codecov/codecov-action@v5
30+
with:
31+
token: ${{ secrets.CODECOV_TOKEN }}
32+
slug: mixpanel/mixpanel-python

BUILD.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Run tests::
1515

1616
python -m tox - runs all tests against all configured environments in the pyproject.toml
1717

18+
Run tests under code coverage::
19+
python -m coverage run -m pytest
20+
python -m coverage report -m
21+
python -m coverage html
22+
1823
Publish to PyPI::
1924

2025
python -m build

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
v5.0.0b1
2+
* Added initial feature flagging support
3+
14
v4.11.1
25
* Loosen requirements for `requests` lib to >=2.4.2 to keep compatible with 2.10
36

demo/local_flags.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
import asyncio
3+
import mixpanel
4+
import logging
5+
6+
logging.basicConfig(level=logging.INFO)
7+
8+
# Configure your project token, the feature flag to test, and user context to evaluate.
9+
PROJECT_TOKEN = ""
10+
FLAG_KEY = "sample-flag"
11+
FLAG_FALLBACK_VARIANT = "control"
12+
USER_CONTEXT = { "distinct_id": "sample-distinct-id" }
13+
14+
# If False, the flag definitions are fetched just once on SDK initialization. Otherwise, will poll
15+
SHOULD_POLL_CONTINOUSLY = False
16+
POLLING_INTERVAL_IN_SECONDS = 90
17+
18+
# Use the correct data residency endpoint for your project.
19+
API_HOST = "api-eu.mixpanel.com"
20+
21+
async def main():
22+
local_config = mixpanel.LocalFlagsConfig(api_host=API_HOST, enable_polling=SHOULD_POLL_CONTINOUSLY, polling_interval_in_seconds=POLLING_INTERVAL_IN_SECONDS)
23+
24+
# Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging
25+
async with mixpanel.Mixpanel(PROJECT_TOKEN, local_flags_config=local_config) as mp:
26+
await mp.local_flags.astart_polling_for_definitions()
27+
variant_value = mp.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
28+
print(f"Variant value: {variant_value}")
29+
30+
if __name__ == '__main__':
31+
asyncio.run(main())

demo/remote_flags.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import asyncio
2+
import mixpanel
3+
import logging
4+
5+
logging.basicConfig(level=logging.INFO)
6+
7+
# Configure your project token, the feature flag to test, and user context to evaluate.
8+
PROJECT_TOKEN = ""
9+
FLAG_KEY = "sample-flag"
10+
FLAG_FALLBACK_VARIANT = "control"
11+
USER_CONTEXT = { "distinct_id": "sample-distinct-id" }
12+
13+
# Use the correct data residency endpoint for your project.
14+
API_HOST = "api-eu.mixpanel.com"
15+
16+
DEMO_ASYNC = True
17+
18+
async def async_demo():
19+
remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST)
20+
# Optionally use mixpanel client as a context manager, that will ensure shutdown of resources used by feature flagging
21+
async with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp:
22+
variant_value = await mp.remote_flags.aget_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
23+
print(f"Variant value: {variant_value}")
24+
25+
def sync_demo():
26+
remote_config = mixpanel.RemoteFlagsConfig(api_host=API_HOST)
27+
with mixpanel.Mixpanel(PROJECT_TOKEN, remote_flags_config=remote_config) as mp:
28+
variant_value = mp.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
29+
print(f"Variant value: {variant_value}")
30+
31+
if __name__ == '__main__':
32+
if DEMO_ASYNC:
33+
asyncio.run(async_demo())
34+
else:
35+
sync_demo()

mixpanel/__init__.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@
2424
from requests.auth import HTTPBasicAuth
2525
import urllib3
2626

27-
__version__ = '4.11.1'
28-
VERSION = __version__ # TODO: remove when bumping major version.
27+
from typing import Optional
2928

30-
logger = logging.getLogger(__name__)
29+
from .flags.local_feature_flags import LocalFeatureFlagsProvider
30+
from .flags.remote_feature_flags import RemoteFeatureFlagsProvider
31+
from .flags.types import LocalFlagsConfig, RemoteFlagsConfig
32+
33+
__version__ = '5.0.0b1'
3134

35+
logger = logging.getLogger(__name__)
3236

3337
class DatetimeSerializer(json.JSONEncoder):
3438
def default(self, obj):
@@ -44,7 +48,7 @@ def json_dumps(data, cls=None):
4448
return json.dumps(data, separators=(',', ':'), cls=cls)
4549

4650

47-
class Mixpanel(object):
51+
class Mixpanel():
4852
"""Instances of Mixpanel are used for all events and profile updates.
4953
5054
:param str token: your project's Mixpanel token
@@ -59,17 +63,40 @@ class Mixpanel(object):
5963
The *serializer* parameter.
6064
"""
6165

62-
def __init__(self, token, consumer=None, serializer=DatetimeSerializer):
66+
def __init__(self, token, consumer=None, serializer=DatetimeSerializer, local_flags_config: Optional[LocalFlagsConfig] = None, remote_flags_config: Optional[RemoteFlagsConfig] = None):
6367
self._token = token
6468
self._consumer = consumer or Consumer()
6569
self._serializer = serializer
6670

71+
self._local_flags_provider = None
72+
self._remote_flags_provider = None
73+
74+
if local_flags_config:
75+
self._local_flags_provider = LocalFeatureFlagsProvider(self._token, local_flags_config, __version__, self.track)
76+
77+
if remote_flags_config:
78+
self._remote_flags_provider = RemoteFeatureFlagsProvider(self._token, remote_flags_config, __version__, self.track)
79+
6780
def _now(self):
6881
return time.time()
6982

7083
def _make_insert_id(self):
7184
return uuid.uuid4().hex
7285

86+
@property
87+
def local_flags(self) -> LocalFeatureFlagsProvider:
88+
"""Get the local flags provider if configured for it"""
89+
if self._local_flags_provider is None:
90+
raise MixpanelException("No local flags provider initialized. Pass local_flags_config to constructor.")
91+
return self._local_flags_provider
92+
93+
@property
94+
def remote_flags(self) -> RemoteFeatureFlagsProvider:
95+
"""Get the remote flags provider if configured for it"""
96+
if self._remote_flags_provider is None:
97+
raise MixpanelException("No remote_flags_config was passed to the consttructor")
98+
return self._remote_flags_provider
99+
73100
def track(self, distinct_id, event_name, properties=None, meta=None):
74101
"""Record an event.
75102
@@ -504,6 +531,24 @@ def group_update(self, message, meta=None):
504531
record.update(meta)
505532
self._consumer.send('groups', json_dumps(record, cls=self._serializer))
506533

534+
def __enter__(self):
535+
return self
536+
537+
def __exit__(self, exc_type, exc_val, exc_tb):
538+
if self._local_flags_provider is not None:
539+
self._local_flags_provider.__exit__(exc_type, exc_val, exc_tb)
540+
if self._remote_flags_provider is not None:
541+
self._remote_flags_provider.__exit__(exc_type, exc_val, exc_tb)
542+
543+
async def __aenter__(self):
544+
return self
545+
546+
async def __aexit__(self, exc_type, exc_val, exc_tb):
547+
if self._local_flags_provider is not None:
548+
await self._local_flags_provider.__aexit__(exc_type, exc_val, exc_tb)
549+
if self._remote_flags_provider is not None:
550+
await self._remote_flags_provider.__aexit__(exc_type, exc_val, exc_tb)
551+
507552

508553
class MixpanelException(Exception):
509554
"""Raised by consumers when unable to send messages.
@@ -733,3 +778,4 @@ def _flush_endpoint(self, endpoint):
733778
raise mp_e from orig_e
734779
buf = buf[self._max_size:]
735780
self._buffers[endpoint] = buf
781+

mixpanel/flags/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)