From 7e267c869b91b936b720cb3dafbba81cb0a4f606 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 19 Mar 2026 11:44:00 -0700 Subject: [PATCH 01/15] 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 --- openfeature-provider/pyproject.toml | 29 ++ .../src/mixpanel_openfeature/__init__.py | 3 + .../src/mixpanel_openfeature/provider.py | 126 ++++++++ openfeature-provider/tests/test_provider.py | 296 ++++++++++++++++++ 4 files changed, 454 insertions(+) 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/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml new file mode 100644 index 0000000..d67a444 --- /dev/null +++ b/openfeature-provider/pyproject.toml @@ -0,0 +1,29 @@ +[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", + "openfeature-sdk>=0.7.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "pytest-asyncio>=0.23.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" 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..85a8c69 --- /dev/null +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -0,0 +1,126 @@ +import math +import typing +from typing import Mapping, Sequence, 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: + super().__init__() + self._flags_provider = flags_provider + + def get_metadata(self) -> Metadata: + return Metadata(name="mixpanel-provider") + + def shutdown(self) -> None: + pass + + 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) + + 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) + + 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) + + 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) + + 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) + + def _resolve( + self, + flag_key: str, + default_value: typing.Any, + expected_type: typing.Optional[type], + ) -> 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) + result = self._flags_provider.get_variant(flag_key, fallback, {}) + + if result is fallback: + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.FLAG_NOT_FOUND, + reason=Reason.ERROR, + ) + + value = result.variant_value + + if expected_type is None: + return FlagResolutionDetails(value=value, reason=Reason.STATIC) + + if expected_type is int and isinstance(value, float): + if math.isfinite(value) and value == math.floor(value): + return FlagResolutionDetails( + value=int(value), reason=Reason.STATIC + ) + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + reason=Reason.ERROR, + ) + + if expected_type is float and isinstance(value, (int, float)): + return FlagResolutionDetails( + value=float(value), reason=Reason.STATIC + ) + + if not isinstance(value, expected_type): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + reason=Reason.ERROR, + ) + + return FlagResolutionDetails(value=value, 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 diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py new file mode 100644 index 0000000..3256b8e --- /dev/null +++ b/openfeature-provider/tests/test_provider.py @@ -0,0 +1,296 @@ +from unittest.mock import MagicMock +import pytest +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.STATIC + 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.STATIC + + +# --- 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.STATIC + 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.STATIC + 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.STATIC + + +# --- 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.STATIC + 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.STATIC + + +# --- 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.STATIC + 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.STATIC + + +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.STATIC + + +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.STATIC + + +# --- 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.ERROR + + +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.ERROR + + +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.ERROR + + +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.ERROR + + +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.ERROR + + +# --- 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 + + +# --- 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.STATIC + + +# --- Lifecycle --- + + +def test_shutdown_is_noop(provider): + provider.shutdown() # Should not raise From 5135505e155cfab6debd3cf5f10c1c19565ddec7 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 20 Mar 2026 11:05:04 -0700 Subject: [PATCH 02/15] Add missing OpenFeature provider tests and fix provider gaps Adds variant key passthrough, SDK exception handling (try/except), null variant key tests, and context forwarding to evaluation calls. Co-Authored-By: Claude Opus 4.6 --- .../src/mixpanel_openfeature/provider.py | 48 ++++-- openfeature-provider/tests/test_provider.py | 140 ++++++++++++++++++ 2 files changed, 178 insertions(+), 10 deletions(-) diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index 85a8c69..d26db25 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -31,7 +31,7 @@ def resolve_boolean_details( default_value: bool, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: - return self._resolve(flag_key, default_value, bool) + return self._resolve(flag_key, default_value, bool, evaluation_context) def resolve_string_details( self, @@ -39,7 +39,7 @@ def resolve_string_details( default_value: str, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: - return self._resolve(flag_key, default_value, str) + return self._resolve(flag_key, default_value, str, evaluation_context) def resolve_integer_details( self, @@ -47,7 +47,7 @@ def resolve_integer_details( default_value: int, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: - return self._resolve(flag_key, default_value, int) + return self._resolve(flag_key, default_value, int, evaluation_context) def resolve_float_details( self, @@ -55,7 +55,7 @@ def resolve_float_details( default_value: float, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: - return self._resolve(flag_key, default_value, float) + return self._resolve(flag_key, default_value, float, evaluation_context) def resolve_object_details( self, @@ -65,13 +65,28 @@ def resolve_object_details( ) -> FlagResolutionDetails[ Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] ]: - return self._resolve(flag_key, default_value, None) + return self._resolve(flag_key, default_value, None, evaluation_context) + + @staticmethod + def _build_user_context( + evaluation_context: typing.Optional[EvaluationContext], + ) -> dict: + user_context: dict = {} + if evaluation_context is not None: + if evaluation_context.targeting_key: + user_context["distinct_id"] = evaluation_context.targeting_key + if evaluation_context.attributes: + user_context["custom_properties"] = dict( + evaluation_context.attributes + ) + 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( @@ -81,7 +96,15 @@ def _resolve( ) fallback = SelectedVariant(variant_value=default_value) - result = self._flags_provider.get_variant(flag_key, fallback, {}) + 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( @@ -91,14 +114,17 @@ def _resolve( ) value = result.variant_value + variant_key = result.variant_key if expected_type is None: - return FlagResolutionDetails(value=value, reason=Reason.STATIC) + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.STATIC + ) if expected_type is int and isinstance(value, float): if math.isfinite(value) and value == math.floor(value): return FlagResolutionDetails( - value=int(value), reason=Reason.STATIC + value=int(value), variant=variant_key, reason=Reason.STATIC ) return FlagResolutionDetails( value=default_value, @@ -108,7 +134,7 @@ def _resolve( if expected_type is float and isinstance(value, (int, float)): return FlagResolutionDetails( - value=float(value), reason=Reason.STATIC + value=float(value), variant=variant_key, reason=Reason.STATIC ) if not isinstance(value, expected_type): @@ -118,7 +144,9 @@ def _resolve( reason=Reason.ERROR, ) - return FlagResolutionDetails(value=value, reason=Reason.STATIC) + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.STATIC + ) def _are_flags_ready(self) -> bool: if hasattr(self._flags_provider, "are_flags_ready"): diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 3256b8e..9c706ec 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock import pytest +from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode from openfeature.flag_evaluation import Reason @@ -294,3 +295,142 @@ def test_remote_provider_always_ready(): def test_shutdown_is_noop(provider): provider.shutdown() # Should not raise + + +# --- EvaluationContext forwarding --- + + +def test_forwards_targeting_key_as_distinct_id(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["distinct_id"] == "user-123" + + +def test_forwards_attributes_as_custom_properties(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["custom_properties"] == {"plan": "pro", "beta": 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 == { + "distinct_id": "user-456", + "custom_properties": {"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.STATIC + + +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.STATIC + + +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.STATIC + + +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.STATIC + + +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.STATIC + + +# --- 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.STATIC + 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.STATIC + 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.STATIC + assert result.error_code is None From ece5e7587ec44ec6bc4bc0bfa5f0a29c5fe9db0c Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 23 Mar 2026 09:48:14 -0700 Subject: [PATCH 03/15] Fix OpenFeature context passthrough to pass attributes as-is Stop mapping targeting_key to distinct_id and nesting attributes under custom_properties. All context attributes are now passed through flat, matching the Java and Go SDKs. Co-Authored-By: Claude Opus 4.6 --- .../src/mixpanel_openfeature/provider.py | 8 +++----- openfeature-provider/tests/test_provider.py | 13 +++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index d26db25..b467253 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -73,12 +73,10 @@ def _build_user_context( ) -> dict: user_context: dict = {} if evaluation_context is not None: - if evaluation_context.targeting_key: - user_context["distinct_id"] = evaluation_context.targeting_key if evaluation_context.attributes: - user_context["custom_properties"] = dict( - evaluation_context.attributes - ) + user_context.update(evaluation_context.attributes) + if evaluation_context.targeting_key: + user_context["targetingKey"] = evaluation_context.targeting_key return user_context def _resolve( diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 9c706ec..39771e9 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -300,20 +300,21 @@ def test_shutdown_is_noop(provider): # --- EvaluationContext forwarding --- -def test_forwards_targeting_key_as_distinct_id(provider, mock_flags): +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["distinct_id"] == "user-123" + assert user_context["targetingKey"] == "user-123" -def test_forwards_attributes_as_custom_properties(provider, mock_flags): +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["custom_properties"] == {"plan": "pro", "beta": True} + assert user_context["plan"] == "pro" + assert user_context["beta"] is True def test_forwards_full_context(provider, mock_flags): @@ -324,8 +325,8 @@ def test_forwards_full_context(provider, mock_flags): provider.resolve_string_details("flag", "default", ctx) _, _, user_context = mock_flags.get_variant.call_args[0] assert user_context == { - "distinct_id": "user-456", - "custom_properties": {"tier": "enterprise"}, + "targetingKey": "user-456", + "tier": "enterprise", } From 124f6b194e632699a0db7b66b55a2a5768291252 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 23 Mar 2026 16:00:28 -0700 Subject: [PATCH 04/15] Align OpenFeature provider with server provider spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shutdown() now delegates to underlying flags provider - Add shutdown() to LocalFeatureFlagsProvider and RemoteFeatureFlagsProvider - Explicitly pass report_exposure=True to get_variant() - Rename reportExposure to report_exposure in RemoteFeatureFlagsProvider - Add context value unwrapping with whole-number float→int conversion - Update test mock signatures for report_exposure parameter Co-Authored-By: Claude Opus 4.6 --- mixpanel/flags/local_feature_flags.py | 4 ++++ mixpanel/flags/remote_feature_flags.py | 15 +++++++++------ .../src/mixpanel_openfeature/provider.py | 17 ++++++++++++++--- openfeature-provider/tests/test_provider.py | 6 +++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5730a9c..190c88c 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -487,6 +487,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 8d265ae..c0a27fa 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -74,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], reportExposure: bool = True + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True ) -> SelectedVariant: """ Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server. @@ -82,7 +82,7 @@ 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 + :param bool report_exposure: Whether to report an exposure event if a variant is successfully retrieved """ try: params = self._prepare_query_params(context, flag_key) @@ -94,7 +94,7 @@ async def aget_variant( 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")): + if not is_fallback and report_exposure and (distinct_id := context.get("distinct_id")): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -180,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], reportExposure: bool = True + self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True ) -> SelectedVariant: """ Synchronously gets the selected variant for a feature flag from remote server. @@ -188,7 +188,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 attributes and rollout context - :param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved + :param bool report_exposure: Whether to report an exposure event if a variant is successfully retrieved """ try: params = self._prepare_query_params(context, flag_key) @@ -201,7 +201,7 @@ def get_variant( 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")): + if not is_fallback and report_exposure and (distinct_id := context.get("distinct_id")): properties = self._build_tracking_properties( flag_key, selected_variant, start_time, end_time ) @@ -304,6 +304,9 @@ def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVaria return fallback_value, True + def shutdown(self): + self._sync_client.close() + def __enter__(self): return self diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index b467253..ac306e6 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -23,7 +23,7 @@ def get_metadata(self) -> Metadata: return Metadata(name="mixpanel-provider") def shutdown(self) -> None: - pass + self._flags_provider.shutdown() def resolve_boolean_details( self, @@ -67,6 +67,16 @@ def resolve_object_details( ]: 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], @@ -74,7 +84,8 @@ def _build_user_context( user_context: dict = {} if evaluation_context is not None: if evaluation_context.attributes: - user_context.update(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 @@ -96,7 +107,7 @@ def _resolve( 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) + result = self._flags_provider.get_variant(flag_key, fallback, user_context, report_exposure=True) except Exception: return FlagResolutionDetails( value=default_value, diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 39771e9..07cb20b 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -22,7 +22,7 @@ def provider(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: ( + mock_flags.get_variant.side_effect = lambda key, fallback, ctx, report_exposure=True: ( SelectedVariant(variant_key=variant_key, variant_value=value) if key == flag_key else fallback @@ -31,7 +31,7 @@ def setup_flag(mock_flags, flag_key, value, variant_key="variant-key"): 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 + mock_flags.get_variant.side_effect = lambda key, fallback, ctx, report_exposure=True: fallback # --- Metadata --- @@ -280,7 +280,7 @@ def test_provider_not_ready_object(mock_flags): 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( + side_effect=lambda key, fallback, ctx, report_exposure=True: SelectedVariant( variant_key="v1", variant_value=True ) ) From a34bf6e9fd212aaafe3d041639261512897ebab4 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 12:49:11 -0700 Subject: [PATCH 05/15] Fix root logger misuse and consolidate duplicate recursive methods Six call sites in remote_feature_flags.py used logging.X() (root logger) instead of logger.X() (module logger), breaking independent log configuration. Consolidate lowercase_keys_and_values and lowercase_only_leaf_nodes into single _casefold_recursive method. Fix incorrect return type annotation and remove unnecessary f-string prefixes. Co-Authored-By: Claude Opus 4.6 --- mixpanel/flags/local_feature_flags.py | 28 +++++++------------------- mixpanel/flags/remote_feature_flags.py | 20 +++++++++--------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 190c88c..bf7ca70 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -318,29 +318,15 @@ def _get_assigned_rollout( return None - def lowercase_keys_and_values(self, val: Any) -> Any: - if isinstance(val, str): + def _casefold_recursive(self, val: Any, include_keys: bool = False) -> Any: + if isinstance(val, str): return val.casefold() elif isinstance(val, list): - return [self.lowercase_keys_and_values(item) for item in val] + return [self._casefold_recursive(item, include_keys) 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) + (key.casefold() if include_keys and isinstance(key, str) else key): + self._casefold_recursive(value, include_keys) for key, value in val.items() } else: @@ -351,7 +337,7 @@ def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str, return None if not isinstance(custom_properties, dict): return None - return self.lowercase_keys_and_values(custom_properties) + return self._casefold_recursive(custom_properties, include_keys=True) def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: if rollout.runtime_evaluation_rule: @@ -360,7 +346,7 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str return False try: - rule = self.lowercase_only_leaf_nodes(rollout.runtime_evaluation_rule) + rule = self._casefold_recursive(rollout.runtime_evaluation_rule) result = json_logic.jsonLogic(rule, parameters_for_runtime_rule) return bool(result) except Exception: diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index c0a27fa..6a2e84f 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -54,7 +54,7 @@ async def aget_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, 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 @@ -132,7 +132,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)( @@ -160,7 +160,7 @@ def get_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, Select 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 @@ -209,7 +209,7 @@ def get_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 def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool: @@ -235,11 +235,11 @@ 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: - logging.error( + logger.error( "Cannot track exposure event without a distinct_id in the context" ) @@ -258,7 +258,7 @@ 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( + logger.debug( f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'" ) @@ -298,7 +298,7 @@ def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVaria if flag_key in flags: return flags[flag_key], False else: - logging.debug( + logger.debug( f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'" ) return fallback_value, True @@ -314,9 +314,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() From 001dfab29d760919cf97e9b4c5f0d46ed77ce6f6 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 14:12:48 -0700 Subject: [PATCH 06/15] Fix bool subclass bug in numeric type resolution Python's bool is a subclass of int, so isinstance(True, int) returns True. Add early guard to reject bool values when resolving integer or float types, returning TYPE_MISMATCH instead of silently coercing. Co-Authored-By: Claude Opus 4.6 --- .../src/mixpanel_openfeature/provider.py | 9 +++++++++ openfeature-provider/tests/test_provider.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index ac306e6..e92d409 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -130,6 +130,15 @@ def _resolve( 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, + reason=Reason.ERROR, + ) + if expected_type is int and isinstance(value, float): if math.isfinite(value) and value == math.floor(value): return FlagResolutionDetails( diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 07cb20b..ff44c3d 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -226,6 +226,22 @@ def test_type_mismatch_integer_gets_float_with_fraction(provider, mock_flags): 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 --- From 6a32f8439555852cfb0eb62c100204df90826f20 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 11:49:23 -0700 Subject: [PATCH 07/15] Revert out-of-scope changes from merge conflict resolution Restore master's lowercase_keys_and_values/lowercase_only_leaf_nodes methods instead of consolidated _casefold_recursive, and restore master's reportExposure camelCase naming on remote provider. Co-Authored-By: Claude Opus 4.6 --- mixpanel/flags/local_feature_flags.py | 24 ++++++++++++++++++------ mixpanel/flags/remote_feature_flags.py | 8 ++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index ce3bbd3..ed13ee0 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -342,25 +342,37 @@ def _get_assigned_rollout( return None - def _casefold_recursive(self, val: Any, include_keys: bool = False) -> Any: + def lowercase_keys_and_values(self, val: Any) -> Any: if isinstance(val, str): return val.casefold() if isinstance(val, list): - return [self._casefold_recursive(item, include_keys) for item in val] + return [self.lowercase_keys_and_values(item) for item in val] if isinstance(val, dict): return { - (key.casefold() if include_keys and isinstance(key, str) else key): - self._casefold_recursive(value, include_keys) + ( + key.casefold() if isinstance(key, str) else key + ): self.lowercase_keys_and_values(value) for key, value in val.items() } return val + def lowercase_only_leaf_nodes(self, val: Any) -> dict[str, Any]: + if isinstance(val, str): + return val.casefold() + if isinstance(val, list): + return [self.lowercase_only_leaf_nodes(item) for item in val] + if isinstance(val, dict): + return { + key: self.lowercase_only_leaf_nodes(value) for key, value in val.items() + } + return val + 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): return None - return self._casefold_recursive(custom_properties, include_keys=True) + return self.lowercase_keys_and_values(custom_properties) def _is_runtime_rules_engine_satisfied( self, rollout: Rollout, context: dict[str, Any] @@ -371,7 +383,7 @@ def _is_runtime_rules_engine_satisfied( return False try: - rule = self._casefold_recursive(rollout.runtime_evaluation_rule) + rule = self.lowercase_only_leaf_nodes(rollout.runtime_evaluation_rule) result = json_logic.jsonLogic(rule, parameters_for_runtime_rule) return bool(result) except Exception: diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 0e59610..6ea9029 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -89,7 +89,7 @@ async def aget_variant( flag_key: str, fallback_value: SelectedVariant, context: dict[str, Any], - report_exposure: bool = True, + reportExposure: bool = True, # noqa: N803 - matches public API convention ) -> SelectedVariant: """Asynchronously get the selected variant of a feature flag variant for the current user context from remote server. @@ -114,7 +114,7 @@ async def aget_variant( if ( not is_fallback - and report_exposure + and reportExposure and (distinct_id := context.get("distinct_id")) ): properties = self._build_tracking_properties( @@ -205,7 +205,7 @@ def get_variant( flag_key: str, fallback_value: SelectedVariant, context: dict[str, Any], - report_exposure: bool = True, + reportExposure: bool = True, # noqa: N803 - matches public API convention ) -> SelectedVariant: """Synchronously get the selected variant for a feature flag from remote server. @@ -231,7 +231,7 @@ def get_variant( if ( not is_fallback - and report_exposure + and reportExposure and (distinct_id := context.get("distinct_id")) ): properties = self._build_tracking_properties( From 571af477d50db5f30ff9b15331cc40427de0763e Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 11:51:36 -0700 Subject: [PATCH 08/15] Fix docstrings to match master's reportExposure naming Co-Authored-By: Claude Opus 4.6 --- mixpanel/flags/remote_feature_flags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index 6ea9029..b26f4af 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -96,7 +96,7 @@ 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 report_exposure: Whether to report an exposure event if a variant is successfully retrieved + :param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved """ try: params = self._prepare_query_params(context, flag_key) @@ -212,7 +212,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 attributes and rollout context - :param bool report_exposure: Whether to report an exposure event if a variant is successfully retrieved + :param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved """ try: params = self._prepare_query_params(context, flag_key) From e601641c9fa6fe1df019427b9f9013fb2b1ba2e1 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 12:06:07 -0700 Subject: [PATCH 09/15] Fix CI: exclude openfeature-provider from root lint and test collection The openfeature-provider sub-project has its own pyproject.toml and dependencies. Root pytest was collecting its tests (failing on missing openfeature SDK), and root ruff was linting it without proper config. - Add --ignore=openfeature-provider to pytest addopts - Add extend-exclude for openfeature-provider in ruff config - Auto-format provider.py and test_provider.py with ruff Co-Authored-By: Claude Opus 4.6 --- .../src/mixpanel_openfeature/provider.py | 4 +++- openfeature-provider/tests/test_provider.py | 18 ++++++++++-------- pyproject.toml | 2 ++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index e92d409..3708a15 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -107,7 +107,9 @@ def _resolve( 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, report_exposure=True) + result = self._flags_provider.get_variant( + flag_key, fallback, user_context, report_exposure=True + ) except Exception: return FlagResolutionDetails( value=default_value, diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index ff44c3d..ba14c02 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -22,16 +22,20 @@ def provider(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, report_exposure=True: ( - SelectedVariant(variant_key=variant_key, variant_value=value) - if key == flag_key - else fallback + mock_flags.get_variant.side_effect = ( + lambda key, fallback, ctx, report_exposure=True: ( + 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, report_exposure=True: fallback + mock_flags.get_variant.side_effect = ( + lambda key, fallback, ctx, report_exposure=True: fallback + ) # --- Metadata --- @@ -335,9 +339,7 @@ def test_forwards_attributes_flat(provider, mock_flags): def test_forwards_full_context(provider, mock_flags): setup_flag(mock_flags, "flag", "val") - ctx = EvaluationContext( - targeting_key="user-456", attributes={"tier": "enterprise"} - ) + 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 == { 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 1c40800109c86b4223365ee2b88fcd405e52fcca Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 14:57:11 -0700 Subject: [PATCH 10/15] Remove report_exposure parameter from OpenFeature provider's get_variant call Let the underlying flags provider use its default behavior rather than explicitly passing report_exposure=True. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/src/mixpanel_openfeature/provider.py | 2 +- openfeature-provider/tests/test_provider.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index 3708a15..4d013de 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -108,7 +108,7 @@ def _resolve( user_context = self._build_user_context(evaluation_context) try: result = self._flags_provider.get_variant( - flag_key, fallback, user_context, report_exposure=True + flag_key, fallback, user_context ) except Exception: return FlagResolutionDetails( diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index ba14c02..972c9ae 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -23,7 +23,7 @@ def provider(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, report_exposure=True: ( + lambda key, fallback, ctx: ( SelectedVariant(variant_key=variant_key, variant_value=value) if key == flag_key else fallback @@ -34,7 +34,7 @@ def setup_flag(mock_flags, flag_key, value, variant_key="variant-key"): 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, report_exposure=True: fallback + lambda key, fallback, ctx: fallback ) @@ -300,7 +300,7 @@ def test_provider_not_ready_object(mock_flags): 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, report_exposure=True: SelectedVariant( + side_effect=lambda key, fallback, ctx: SelectedVariant( variant_key="v1", variant_value=True ) ) From baa48dbedb00ad97f9c78d9028646eebbdc3ff0c Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 15:06:58 -0700 Subject: [PATCH 11/15] Add CI job for OpenFeature provider tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74fcfb3..a30e0a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,4 +40,25 @@ 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'] + + 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 openfeature-provider/tests/ \ No newline at end of file From 247b5398b1600c304f5e23b2a91debf583209294 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 19:55:17 -0700 Subject: [PATCH 12/15] Add coverage upload for OpenFeature provider CI job Codecov patch/project checks were failing because the openfeature-provider tests weren't generating or uploading coverage data. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a30e0a4..27a32d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,4 +61,10 @@ jobs: pip install -e ./openfeature-provider[test] - name: Run OpenFeature provider tests run: | - pytest openfeature-provider/tests/ \ No newline at end of file + 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 From 3c721d598d06f3352ba17a162e367ded1d81e579 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 20:07:34 -0700 Subject: [PATCH 13/15] Add pytest-cov to OpenFeature provider test dependencies The CI coverage upload needs pytest-cov installed to use --cov flags. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml index d67a444..7d6dcbe 100644 --- a/openfeature-provider/pyproject.toml +++ b/openfeature-provider/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ test = [ "pytest>=8.4.1", "pytest-asyncio>=0.23.0", + "pytest-cov>=6.0", ] [tool.setuptools.packages.find] From 60581622b7a3e4ac3589cb90d3f5567c9f6854e2 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 3 Apr 2026 19:09:58 -0700 Subject: [PATCH 14/15] Address PR review feedback for OpenFeature provider - Add PyPy 3.9 and 3.11 to OpenFeature provider CI test matrix - Add error_message to TYPE_MISMATCH flag resolution details - Constrain mixpanel dependency to >=2,<3 - Add ruff linting and formatting config mirroring main project - Fix lint violations (imports, formatting) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 2 +- openfeature-provider/pyproject.toml | 70 ++++++++++++++++++- .../src/mixpanel_openfeature/provider.py | 10 +-- openfeature-provider/tests/test_provider.py | 15 ++-- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27a32d4..1b0ed66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.11'] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml index 7d6dcbe..da62add 100644 --- a/openfeature-provider/pyproject.toml +++ b/openfeature-provider/pyproject.toml @@ -12,7 +12,7 @@ authors = [ ] requires-python = ">=3.9" dependencies = [ - "mixpanel", + "mixpanel>=2,<3", "openfeature-sdk>=0.7.0", ] @@ -28,3 +28,71 @@ 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/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index 4d013de..0c2e894 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -1,6 +1,7 @@ import math import typing -from typing import Mapping, Sequence, Union +from collections.abc import Mapping, Sequence +from typing import Union from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode @@ -107,9 +108,7 @@ def _resolve( 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 - ) + result = self._flags_provider.get_variant(flag_key, fallback, user_context) except Exception: return FlagResolutionDetails( value=default_value, @@ -138,6 +137,7 @@ def _resolve( return FlagResolutionDetails( value=default_value, error_code=ErrorCode.TYPE_MISMATCH, + error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", reason=Reason.ERROR, ) @@ -149,6 +149,7 @@ def _resolve( 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, ) @@ -161,6 +162,7 @@ def _resolve( return FlagResolutionDetails( value=default_value, error_code=ErrorCode.TYPE_MISMATCH, + error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", reason=Reason.ERROR, ) diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 972c9ae..372d4c3 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock + import pytest from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode @@ -22,20 +23,16 @@ def provider(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 - ) + 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 - ) + mock_flags.get_variant.side_effect = lambda key, fallback, ctx: fallback # --- Metadata --- From b6719dfd4d999c802db4934762c753329af11f8f Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 3 Apr 2026 19:19:01 -0700 Subject: [PATCH 15/15] Fix mixpanel version constraint in OpenFeature provider The dependency was pinned to mixpanel>=2,<3 but the current SDK version is 5.1.0, causing CI to fail when pip couldn't resolve the constraint. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml index da62add..771c0d9 100644 --- a/openfeature-provider/pyproject.toml +++ b/openfeature-provider/pyproject.toml @@ -12,7 +12,7 @@ authors = [ ] requires-python = ">=3.9" dependencies = [ - "mixpanel>=2,<3", + "mixpanel>=5.1.0,<6", "openfeature-sdk>=0.7.0", ]