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/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..ac306e6 --- /dev/null +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -0,0 +1,163 @@ +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: + self._flags_provider.shutdown() + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, bool, evaluation_context) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, str, evaluation_context) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, int, evaluation_context) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, float, evaluation_context) + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[ + Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] + ]: + return self._resolve(flag_key, default_value, None, evaluation_context) + + @staticmethod + def _unwrap_value(value: typing.Any) -> typing.Any: + if isinstance(value, dict): + return {k: MixpanelProvider._unwrap_value(v) for k, v in value.items()} + if isinstance(value, list): + return [MixpanelProvider._unwrap_value(item) for item in value] + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + @staticmethod + def _build_user_context( + evaluation_context: typing.Optional[EvaluationContext], + ) -> dict: + user_context: dict = {} + if evaluation_context is not None: + if evaluation_context.attributes: + for k, v in evaluation_context.attributes.items(): + user_context[k] = MixpanelProvider._unwrap_value(v) + if evaluation_context.targeting_key: + user_context["targetingKey"] = evaluation_context.targeting_key + return user_context + + def _resolve( + self, + flag_key: str, + default_value: typing.Any, + expected_type: typing.Optional[type], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + if not self._are_flags_ready(): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.PROVIDER_NOT_READY, + reason=Reason.ERROR, + ) + + fallback = SelectedVariant(variant_value=default_value) + user_context = self._build_user_context(evaluation_context) + try: + result = self._flags_provider.get_variant(flag_key, fallback, user_context, report_exposure=True) + except Exception: + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.GENERAL, + reason=Reason.ERROR, + ) + + if result is fallback: + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.FLAG_NOT_FOUND, + reason=Reason.ERROR, + ) + + value = result.variant_value + variant_key = result.variant_key + + if expected_type is None: + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.STATIC + ) + + if expected_type is int and isinstance(value, float): + if math.isfinite(value) and value == math.floor(value): + return FlagResolutionDetails( + value=int(value), variant=variant_key, reason=Reason.STATIC + ) + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + reason=Reason.ERROR, + ) + + if expected_type is float and isinstance(value, (int, float)): + return FlagResolutionDetails( + value=float(value), variant=variant_key, reason=Reason.STATIC + ) + + if not isinstance(value, expected_type): + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.TYPE_MISMATCH, + reason=Reason.ERROR, + ) + + return FlagResolutionDetails( + value=value, variant=variant_key, reason=Reason.STATIC + ) + + def _are_flags_ready(self) -> bool: + if hasattr(self._flags_provider, "are_flags_ready"): + return self._flags_provider.are_flags_ready() + return True diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py new file mode 100644 index 0000000..07cb20b --- /dev/null +++ b/openfeature-provider/tests/test_provider.py @@ -0,0 +1,437 @@ +from unittest.mock import MagicMock +import pytest +from openfeature.evaluation_context import EvaluationContext +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, 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 + + +# --- 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, report_exposure=True: 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 + + +# --- EvaluationContext forwarding --- + + +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["targetingKey"] == "user-123" + + +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["plan"] == "pro" + assert user_context["beta"] is 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 == { + "targetingKey": "user-456", + "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