Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,31 @@ jobs:
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: mixpanel/mixpanel-python
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
4 changes: 4 additions & 0 deletions mixpanel/flags/local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions mixpanel/flags/remote_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 98 additions & 0 deletions openfeature-provider/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions openfeature-provider/src/mixpanel_openfeature/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .provider import MixpanelProvider

__all__ = ["MixpanelProvider"]
176 changes: 176 additions & 0 deletions openfeature-provider/src/mixpanel_openfeature/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import math
import typing
from collections.abc import Mapping, Sequence
from typing import 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.flags.types import 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) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the primary purpose of the openfeatureprovider is to not have to worry about handling the internals of the providers behind the scene, I think the init should just take in properties we need to initialize the mixpanel library and the requested flags provider

provider = MixpanelProvider(token: str, evaluation_mode: Local|Remote, ..., ..., ... ) 

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really good point, and for a lot of other OpenFeature providers, it looks like this is how things are handled. I have a couple concerns though (kind of more in a general sense, and not specifically with Python):

  1. For the client side SDKs, we need to be able to call mixpanel.identify to get all the distinctId stuff in
  2. If we create a Mixpanel client within the OpenFeature provider, mixpanel.track calls on the instance outside the provider are no longer passing through the instance that the OpenFeature provider is using, which breaks runtime events' ability to detect those triggers. This will break client SDKs, and will preclude us from implementing the feature on server side SDKs, and incur the wrath of Neha.

I personally don't think it's too painful to pass in the mixpanel client, since it's just one more line, but I do understand that this deviates from the usual pattern. What do you think @efahk ?

super().__init__()
self._flags_provider = flags_provider

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.ERROR,
)

value = result.variant_value
variant_key = result.variant_key

if expected_type is None:
return FlagResolutionDetails(
value=value, variant=variant_key, reason=Reason.STATIC
)

# 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.STATIC
)
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.STATIC
)

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.STATIC
)

def _are_flags_ready(self) -> bool:
if hasattr(self._flags_provider, "are_flags_ready"):
return self._flags_provider.are_flags_ready()
return True
Loading
Loading