From 7e267c869b91b936b720cb3dafbba81cb0a4f606 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 19 Mar 2026 11:44:00 -0700 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 ) )