diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5bc441e..5730a9c 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -3,6 +3,7 @@ import asyncio import time import threading +import json_logic from datetime import datetime, timedelta from typing import Dict, Any, Callable, Optional from .types import ( @@ -23,7 +24,6 @@ logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) - class LocalFeatureFlagsProvider: FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" @@ -312,29 +312,82 @@ def _get_assigned_rollout( rollout_hash = normalized_hash(str(context_value), salt) if (rollout_hash < rollout.rollout_percentage - and self._is_runtime_evaluation_satisfied(rollout, context) + and self._is_runtime_rules_engine_satisfied(rollout, context) ): return rollout return None + + def lowercase_keys_and_values(self, val: Any) -> Any: + if isinstance(val, str): + return val.casefold() + elif isinstance(val, list): + return [self.lowercase_keys_and_values(item) 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) + for key, value in val.items() + } + else: + return val + + def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not (custom_properties := context.get("custom_properties")): + return None + if not isinstance(custom_properties, dict): + return None + return self.lowercase_keys_and_values(custom_properties) + + def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + if rollout.runtime_evaluation_rule: + parameters_for_runtime_rule = self._get_runtime_parameters(context) + if parameters_for_runtime_rule is None: + return False - def _is_runtime_evaluation_satisfied( + try: + rule = self.lowercase_only_leaf_nodes(rollout.runtime_evaluation_rule) + result = json_logic.jsonLogic(rule, parameters_for_runtime_rule) + return bool(result) + except Exception: + logger.exception("Error evaluating runtime evaluation rule") + return False + + elif rollout.runtime_evaluation_definition: # legacy field supporting only exact match conditions + return self._is_legacy_runtime_evaluation_rule_satisfied(rollout, context) + + else: + return True + + def _is_legacy_runtime_evaluation_rule_satisfied( self, rollout: Rollout, context: Dict[str, Any] ) -> bool: if not rollout.runtime_evaluation_definition: return True - if not (custom_properties := context.get("custom_properties")): - return False - - if not isinstance(custom_properties, dict): + parameters_for_runtime_rule = self._get_runtime_parameters(context) + if parameters_for_runtime_rule is None: return False for key, expected_value in rollout.runtime_evaluation_definition.items(): - if key not in custom_properties: + if key not in parameters_for_runtime_rule: return False - actual_value = custom_properties[key] + actual_value = parameters_for_runtime_rule[key] if actual_value.casefold() != expected_value.casefold(): return False diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index fed3f57..e4481c8 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -4,19 +4,23 @@ import httpx import threading from unittest.mock import Mock, patch -from typing import Dict, Optional, List +from typing import Any, Dict, Optional, List from itertools import chain, repeat from .types import LocalFlagsConfig, ExperimentationFlag, RuleSet, Variant, Rollout, FlagTestUsers, ExperimentationFlags, VariantOverride, SelectedVariant from .local_feature_flags import LocalFeatureFlagsProvider +TEST_FLAG_KEY = "test_flag" +DISTINCT_ID = "user123" +USER_CONTEXT = {"distinct_id": DISTINCT_ID} def create_test_flag( - flag_key: str = "test_flag", + flag_key: str = TEST_FLAG_KEY, context: str = "distinct_id", variants: Optional[list[Variant]] = None, variant_override: Optional[VariantOverride] = None, rollout_percentage: float = 100.0, - runtime_evaluation: Optional[Dict] = None, + runtime_evaluation_legacy_definition: Optional[Dict] = None, + runtime_evaluation_rule: Optional[Dict] = None, test_users: Optional[Dict[str, str]] = None, experiment_id: Optional[str] = None, is_experiment_active: Optional[bool] = None, @@ -30,7 +34,8 @@ def create_test_flag( rollouts = [Rollout( rollout_percentage=rollout_percentage, - runtime_evaluation_definition=runtime_evaluation, + runtime_evaluation_definition=runtime_evaluation_legacy_definition, + runtime_evaluation_rule=runtime_evaluation_rule, variant_override=variant_override, variant_splits=variant_splits )] @@ -103,7 +108,7 @@ async def setup_flags_with_polling(self, flags_in_order: List[List[Experimentati @respx.mock async def test_get_variant_value_returns_fallback_when_no_flag_definitions(self): await self.setup_flags([]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -113,28 +118,28 @@ async def test_get_variant_value_returns_fallback_if_flag_definition_call_fails( ) await self._flags.astart_polling_for_definitions() - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_flag_does_not_exist(self): other_flag = create_test_flag("other_flag") await self.setup_flags([other_flag]) - result = self._flags.get_variant_value("nonexistent_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock async def test_get_variant_value_returns_fallback_when_no_context(self): flag = create_test_flag(context="distinct_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {}) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_fallback_when_wrong_context_key(self): flag = create_test_flag(context="user_id") await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock @@ -149,7 +154,7 @@ async def test_get_variant_value_returns_test_user_variant_when_configured(self) ) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": "test_user"}) assert result == "true" @respx.mock @@ -165,51 +170,290 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "test_user"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": "test_user"}) assert result == "false" @respx.mock async def test_get_variant_value_returns_fallback_when_rollout_percentage_zero(self): flag = create_test_flag(rollout_percentage=0.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred(self): flag = create_test_flag(rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): - runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "premium", - "region": "US" - } + async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] } - result = self._flags.get_variant_value("test_flag", "fallback", context) + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): - runtime_eval = {"plan": "premium", "region": "US"} - flag = create_test_flag(runtime_evaluation=runtime_eval) - await self.setup_flags([flag]) - context = { - "distinct_id": "user123", - "custom_properties": { - "plan": "basic", - "region": "US" - } + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_invalid_runtime_rule_resorts_to_fallback(self): + runtime_eval = { + "=oops=": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_when_no_custom_properties_provided(self): + runtime_eval = { + "=": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_param_value__satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "PremIum", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_varnames__satisfied(self): + runtime_eval = { + "==": [{"var": "Plan"}, "premium"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_rule_value__satisfied(self): + runtime_eval = { + "==": [{"var": "plan"}, "pREMIUm"] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Springfield/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_contains_not_satisfied(self): + runtime_eval = { + "in": ["Springfield", {"var": "url"}] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "url": "https://helloworld.com/Boston/all-about-it", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "b", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_multi_value_not_satisfied(self): + runtime_eval = { + "in": [ + {"var": "name"}, + ["a", "b", "c", "all-from-the-ui"] + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "d", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "Deutschland", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_and_not_satisfied(self): + runtime_eval = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "name": "Johannes", + "country": "France", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] + } + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 30, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_respects_runtime_evaluation_rule_comparison_not_satisfied(self): + runtime_eval = { + ">": [ + {"var": "queries_ran"}, + 25 + ] } - result = self._flags.get_variant_value("test_flag", "fallback", context) + flag = create_test_flag(runtime_evaluation_rule=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "queries_ran": 20, + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]: + context = {"distinct_id": DISTINCT_ID, "custom_properties": properties} + return context + + @respx.mock + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "premium"] + } + legacy_runtime_definition = {"plan": "basic"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_ignores_legacy_runtime_evaluation_definition_when_runtime_evaluation_rule_is_present__not_satisfied(self): + runtime_rule = { + "==": [{"var": "plan"}, "basic"] + } + legacy_runtime_definition = {"plan": "premium"} + flag = create_test_flag(runtime_evaluation_rule=runtime_rule, runtime_evaluation_legacy_definition=legacy_runtime_definition) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result == "fallback" + + @respx.mock + async def test_get_variant_value_respects_legacy_runtime_evaluation_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "premium", + "region": "US" + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) + assert result != "fallback" + + @respx.mock + async def test_get_variant_value_returns_fallback_when_legacy_runtime_evaluation_not_satisfied(self): + runtime_eval = {"plan": "premium", "region": "US"} + flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) + await self.setup_flags([flag]) + context = self.user_context_with_properties({ + "plan": "basic", + "region": "US" + }) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @respx.mock @@ -221,7 +465,7 @@ async def test_get_variant_value_picks_correct_variant_with_hundred_percent_spli ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_a" @respx.mock @@ -234,7 +478,7 @@ async def test_get_variant_value_picks_correct_variant_with_half_migrated_group_ variant_splits = {"A": 0.0, "B": 100.0, "C": 0.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -247,7 +491,7 @@ async def test_get_variant_value_picks_correct_variant_with_full_migrated_group_ variant_splits = {"A": 0.0, "B": 0.0, "C": 100.0} flag = create_test_flag(variants=variants, rollout_percentage=100.0, variant_splits=variant_splits) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_c" @respx.mock @@ -258,7 +502,7 @@ async def test_get_variant_value_picks_overriden_variant(self): ] flag = create_test_flag(variants=variants, variant_override=VariantOverride(key="B")) await self.setup_flags([flag]) - result = self._flags.get_variant_value("test_flag", "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -267,7 +511,7 @@ async def test_get_variant_value_tracks_exposure_when_variant_selected(self): await self.setup_flags([flag]) with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) self._mock_tracker.assert_called_once() @respx.mock @@ -292,7 +536,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e with patch('mixpanel.flags.utils.normalized_hash') as mock_hash: mock_hash.return_value = 0.5 - _ = self._flags.get_variant_value("test_flag", "fallback", {"distinct_id": distinct_id}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": distinct_id}) self._mock_tracker.assert_called_once() @@ -310,7 +554,7 @@ async def test_get_variant_value_tracks_exposure_with_correct_properties(self, e @respx.mock async def test_get_variant_value_does_not_track_exposure_on_fallback(self): await self.setup_flags([]) - _ = self._flags.get_variant_value("nonexistent_flag", "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", USER_CONTEXT) self._mock_tracker.assert_not_called() @respx.mock @@ -326,7 +570,7 @@ async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -336,7 +580,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo flag2 = create_test_flag(flag_key="flag2", rollout_percentage=0.0) await self.setup_flags([flag1, flag2]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -344,7 +588,7 @@ async def test_get_all_variants_returns_partial_variants_when_user_in_some_rollo async def test_get_all_variants_returns_empty_dict_when_no_flags_configured(self): await self.setup_flags([]) - result = self._flags.get_all_variants({"distinct_id": "user123"}) + result = self._flags.get_all_variants(USER_CONTEXT) assert result == {} @@ -354,7 +598,7 @@ async def test_get_all_variants_does_not_track_exposure_events(self): flag2 = create_test_flag(flag_key="flag2", rollout_percentage=100.0) await self.setup_flags([flag1, flag2]) - _ = self._flags.get_all_variants({"distinct_id": "user123"}) + _ = self._flags.get_all_variants(USER_CONTEXT) self._mock_tracker.assert_not_called() @@ -364,7 +608,7 @@ async def test_track_exposure_event_successfully_tracks(self): await self.setup_flags([flag]) variant = SelectedVariant(key="treatment", variant_value="treatment") - self._flags.track_exposure_event("test_flag", variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, USER_CONTEXT) self._mock_tracker.assert_called_once() @@ -384,7 +628,7 @@ async def test_are_flags_ready_returns_true_when_empty_flags_loaded(self): @respx.mock async def test_is_enabled_returns_false_for_nonexistent_flag(self): await self.setup_flags([]) - result = self._flags.is_enabled("nonexistent_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) assert result == False @respx.mock @@ -394,7 +638,7 @@ async def test_is_enabled_returns_true_for_true_variant_value(self): ] flag = create_test_flag(variants=variants, rollout_percentage=100.0) await self.setup_flags([flag]) - result = self._flags.is_enabled("test_flag", {"distinct_id": "user123"}) + result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) assert result == True @respx.mock @@ -419,7 +663,7 @@ async def track_fetch_calls(self): async with polling_limit_check: await polling_limit_check.wait_for(lambda: polling_iterations >= len(flags_in_order)) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -465,5 +709,5 @@ def track_fetch_calls(self): self.setup_flags_with_polling(flags_in_order) polling_event.wait(timeout=5.0) assert (polling_iterations >= 3 ) - result2 = self._flags_with_polling.get_variant_value("test_flag", "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 3f2d6b7..9a76f4e 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,6 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None + runtime_evaluation_rule: Optional[Dict[Any, Any]] = None variant_override: Optional[VariantOverride] = None variant_splits: Optional[Dict[str,float]] = None diff --git a/pyproject.toml b/pyproject.toml index 4bee8d1..d1164b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0.0", "asgiref>=3.0.0", + "json-logic>=0.7.0a0" ] keywords = ["mixpanel", "analytics"] classifiers = [