From e690d082606f59f3e9403428b51de4ddd061e759 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:13:14 -0600 Subject: [PATCH 01/18] bump to alpha version for python3 support This is the "official" repo, but has been virtually abandoned. the 2017 alpha release is what we need though --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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 = [ From 48c8525a9c382fa10a188caec0318b9e395798b5 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:14:43 -0600 Subject: [PATCH 02/18] =?UTF-8?q?runtime=20rule=20NO=20MATCH=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mixpanel/flags/local_feature_flags.py | 17 ++++++++++++++++- mixpanel/flags/test_local_feature_flags.py | 8 ++++++-- mixpanel/flags/types.py | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 5bc441e..ec9c9ff 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -312,11 +312,26 @@ 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 _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: + if not rollout.runtime_evaluation_rule: + return self._is_runtime_evaluation_satisfied(rollout, context) + if not (custom_properties := context.get("custom_properties")): + return False + if not isinstance(custom_properties, dict): + return False + import json_logic + try: + result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) + return bool(result) + except Exception as e: + logger.exception("Error evaluating runtime evaluation rule", e) + return False def _is_runtime_evaluation_satisfied( self, rollout: Rollout, context: Dict[str, Any] diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 1567af2..f8e09bd 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -189,7 +189,9 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred # TODO problem test doesn't fail @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): - runtime_eval = {"oops": "sorry"} + runtime_eval = { + "==": [{"var": "plan"}, "premium"] + } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { @@ -204,7 +206,9 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied(self): - runtime_eval = {"oops": "sorry"} + runtime_eval = { + "==": [{"var": "plan"}, "basic"] + } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 2c50b24..9a76f4e 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -31,7 +31,7 @@ class VariantOverride(BaseModel): class Rollout(BaseModel): rollout_percentage: float runtime_evaluation_definition: Optional[Dict[str, str]] = None - runtime_evaluation_rule: 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 From 5afd742db513ae912d531a418f55fa3c7666731e Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:15:45 -0600 Subject: [PATCH 03/18] dry distinct id --- mixpanel/flags/test_local_feature_flags.py | 51 +++++++++++----------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index f8e09bd..e56d257 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -10,6 +10,7 @@ from .local_feature_flags import LocalFeatureFlagsProvider TEST_FLAG_KEY = "test_flag" +DISTINCT_ID = "user123" def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -106,7 +107,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", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -116,14 +117,14 @@ 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", {"distinct_id": DISTINCT_ID}) 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", {"distinct_id": DISTINCT_ID}) assert result == "control" @respx.mock @@ -137,7 +138,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): 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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "fallback" @respx.mock @@ -175,14 +176,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con 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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) 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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result != "fallback" # TODO Joshua start here @@ -195,7 +196,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -212,7 +213,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -227,7 +228,7 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "premium", "region": "US" @@ -242,7 +243,7 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) context = { - "distinct_id": "user123", + "distinct_id": DISTINCT_ID, "custom_properties": { "plan": "basic", "region": "US" @@ -260,7 +261,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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_a" @respx.mock @@ -273,7 +274,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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -286,7 +287,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_KEY, "fallback", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result == "variant_c" @respx.mock @@ -297,7 +298,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_KEY, "control", {"distinct_id": "user123"}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", {"distinct_id": DISTINCT_ID}) assert result == "variant_b" @respx.mock @@ -306,7 +307,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_KEY, "fallback", {"distinct_id": "user123"}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @respx.mock @@ -349,7 +350,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", {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @respx.mock @@ -365,7 +366,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({"distinct_id": DISTINCT_ID}) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -375,7 +376,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({"distinct_id": DISTINCT_ID}) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -383,7 +384,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({"distinct_id": DISTINCT_ID}) assert result == {} @@ -393,7 +394,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({"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_not_called() @@ -403,7 +404,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_KEY, variant, {"distinct_id": "user123"}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, {"distinct_id": DISTINCT_ID}) self._mock_tracker.assert_called_once() @@ -423,7 +424,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", {"distinct_id": DISTINCT_ID}) assert result == False @respx.mock @@ -433,7 +434,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_KEY, {"distinct_id": "user123"}) + result = self._flags.is_enabled(TEST_FLAG_KEY, {"distinct_id": DISTINCT_ID}) assert result == True @respx.mock @@ -458,7 +459,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_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -504,5 +505,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_KEY, "fallback", {"distinct_id": "user123"}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", {"distinct_id": DISTINCT_ID}) assert result2 != "fallback" From 8733f8ee6322ed83f759ae9db8c0bb742e6fd36a Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:16:35 -0600 Subject: [PATCH 04/18] DRY user context --- mixpanel/flags/test_local_feature_flags.py | 43 +++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e56d257..c2c0892 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -11,6 +11,7 @@ TEST_FLAG_KEY = "test_flag" DISTINCT_ID = "user123" +USER_CONTEXT = {"distinct_id": DISTINCT_ID} def create_test_flag( flag_key: str = TEST_FLAG_KEY, @@ -107,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": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -117,14 +118,14 @@ 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": DISTINCT_ID}) + 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": DISTINCT_ID}) + result = self._flags.get_variant_value("nonexistent_flag", "control", USER_CONTEXT) assert result == "control" @respx.mock @@ -138,7 +139,7 @@ async def test_get_variant_value_returns_fallback_when_no_context(self): 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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "fallback" @respx.mock @@ -176,14 +177,14 @@ async def test_get_variant_value_returns_fallback_when_test_user_variant_not_con 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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + 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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" # TODO Joshua start here @@ -261,7 +262,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_a" @respx.mock @@ -274,7 +275,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -287,7 +288,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result == "variant_c" @respx.mock @@ -298,7 +299,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_KEY, "control", {"distinct_id": DISTINCT_ID}) + result = self._flags.get_variant_value(TEST_FLAG_KEY, "control", USER_CONTEXT) assert result == "variant_b" @respx.mock @@ -307,7 +308,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + _ = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) self._mock_tracker.assert_called_once() @respx.mock @@ -350,7 +351,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": DISTINCT_ID}) + _ = self._flags.get_variant_value("nonexistent_flag", "fallback", USER_CONTEXT) self._mock_tracker.assert_not_called() @respx.mock @@ -366,7 +367,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": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 2 and "flag1" in result and "flag2" in result @@ -376,7 +377,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": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert len(result) == 1 and "flag1" in result and "flag2" not in result @@ -384,7 +385,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": DISTINCT_ID}) + result = self._flags.get_all_variants(USER_CONTEXT) assert result == {} @@ -394,7 +395,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": DISTINCT_ID}) + _ = self._flags.get_all_variants(USER_CONTEXT) self._mock_tracker.assert_not_called() @@ -404,7 +405,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_KEY, variant, {"distinct_id": DISTINCT_ID}) + self._flags.track_exposure_event(TEST_FLAG_KEY, variant, USER_CONTEXT) self._mock_tracker.assert_called_once() @@ -424,7 +425,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": DISTINCT_ID}) + result = self._flags.is_enabled("nonexistent_flag", USER_CONTEXT) assert result == False @respx.mock @@ -434,7 +435,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_KEY, {"distinct_id": DISTINCT_ID}) + result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT) assert result == True @respx.mock @@ -459,7 +460,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" class TestLocalFeatureFlagsProviderSync: @@ -505,5 +506,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_KEY, "fallback", {"distinct_id": DISTINCT_ID}) + result2 = self._flags_with_polling.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result2 != "fallback" From ba8bd860a2fa1f741df7e5176dec00afcb12d5d8 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:18:11 -0600 Subject: [PATCH 05/18] helper to build context with runtime data --- mixpanel/flags/test_local_feature_flags.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index c2c0892..a0f0670 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -4,7 +4,7 @@ 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 @@ -222,19 +222,20 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( } 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_respects_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 = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "premium", + "region": "US" + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result != "fallback" From a1779c5b7d6dd96244009326d65bbb36fce3b3c8 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:20:22 -0600 Subject: [PATCH 06/18] use helper everywhere for runtime data --- mixpanel/flags/test_local_feature_flags.py | 33 ++++++++-------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index a0f0670..13870a4 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -196,30 +196,22 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + 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_not_satisfied(self): runtime_eval = { - "==": [{"var": "plan"}, "basic"] + "==": [{"var": "plan"}, "premium"] } flag = create_test_flag(runtime_evaluation_rule=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "premium", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "basic", + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" @@ -244,13 +236,10 @@ async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_sa runtime_eval = {"plan": "premium", "region": "US"} flag = create_test_flag(runtime_evaluation_legacy_definition=runtime_eval) await self.setup_flags([flag]) - context = { - "distinct_id": DISTINCT_ID, - "custom_properties": { - "plan": "basic", - "region": "US" - } - } + context = self.user_context_with_properties({ + "plan": "basic", + "region": "US" + }) result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", context) assert result == "fallback" From f79594cf72a76f9825c3499e8b6e51532eab3803 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:27:02 -0600 Subject: [PATCH 07/18] ensure priority is given to new rule --- mixpanel/flags/test_local_feature_flags.py | 35 +++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 13870a4..8435e75 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -187,8 +187,7 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred result = self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT) assert result != "fallback" - # TODO Joshua start here - # TODO problem test doesn't fail + @respx.mock async def test_get_variant_value_respects_runtime_evaluation_rule_satisfied(self): runtime_eval = { @@ -220,7 +219,35 @@ def user_context_with_properties(self, properties: Dict[str, Any]) -> Dict[str, return context @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): + 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]) @@ -232,7 +259,7 @@ async def test_get_variant_value_respects_runtime_evaluation_satisfied(self): assert result != "fallback" @respx.mock - async def test_get_variant_value_returns_fallback_when_runtime_evaluation_not_satisfied(self): + 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]) From 3f5eecad2cef0b21b8e53213bda8ec4fb91e0fc8 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 11:32:49 -0600 Subject: [PATCH 08/18] test all use-cases --- mixpanel/flags/test_local_feature_flags.py | 126 ++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 8435e75..e6f9708 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -213,7 +213,131 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( }) 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 + ] + } + 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 From 88128b251c6c639464008d101711d20ae00d9890 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 12:05:37 -0600 Subject: [PATCH 09/18] global import --- mixpanel/flags/local_feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index ec9c9ff..af54d94 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 ( @@ -325,7 +326,6 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str return False if not isinstance(custom_properties, dict): return False - import json_logic try: result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) return bool(result) From efe0624829f95b3810b0eba6e4bf88c0114bd0d9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 12:06:03 -0600 Subject: [PATCH 10/18] Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mixpanel/flags/local_feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index af54d94..a7b212f 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -330,7 +330,7 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) return bool(result) except Exception as e: - logger.exception("Error evaluating runtime evaluation rule", e) + logger.exception("Error evaluating runtime evaluation rule") return False def _is_runtime_evaluation_satisfied( From f6604b3d710fff72976ca72917ac7b1253bc3d98 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 12:52:44 -0600 Subject: [PATCH 11/18] Update mixpanel/flags/test_local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mixpanel/flags/test_local_feature_flags.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index e6f9708..08d17e8 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -187,7 +187,6 @@ async def test_get_variant_value_returns_variant_when_rollout_percentage_hundred 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_rule_satisfied(self): runtime_eval = { From e1d47a71033d9991021f5baa6d375af88bb0cd99 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 12:52:50 -0600 Subject: [PATCH 12/18] Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mixpanel/flags/local_feature_flags.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index a7b212f..51f38ab 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -318,7 +318,6 @@ def _get_assigned_rollout( return rollout return None - def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: if not rollout.runtime_evaluation_rule: return self._is_runtime_evaluation_satisfied(rollout, context) From bf7871f4ef3a0cd845c3657bb60233ab112dbcde Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 12:53:30 -0600 Subject: [PATCH 13/18] Update mixpanel/flags/local_feature_flags.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mixpanel/flags/local_feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 51f38ab..524f10b 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -328,7 +328,7 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str try: result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) return bool(result) - except Exception as e: + except Exception: logger.exception("Error evaluating runtime evaluation rule") return False From 6fb5c01420f002966433bfc4e67d1356128eaf64 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 16:08:51 -0600 Subject: [PATCH 14/18] unnest prod vs legacy comparisons --- mixpanel/flags/local_feature_flags.py | 47 +++++++++++++++++---------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index 524f10b..ea73c36 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -318,37 +318,48 @@ def _get_assigned_rollout( return rollout return None - def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool: - if not rollout.runtime_evaluation_rule: - return self._is_runtime_evaluation_satisfied(rollout, context) + + def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not (custom_properties := context.get("custom_properties")): - return False + return None if not isinstance(custom_properties, dict): - return False - try: - result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, custom_properties) - return bool(result) - except Exception: - logger.exception("Error evaluating runtime evaluation rule") - return False + return None + return 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: + result = json_logic.jsonLogic(rollout.runtime_evaluation_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 From 57e2f73ae21b18de075f90fe37c5f10db6c99e26 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 16:46:58 -0600 Subject: [PATCH 15/18] =?UTF-8?q?case-insensitivity=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mixpanel/flags/test_local_feature_flags.py | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 08d17e8..54b7d3a 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -213,6 +213,32 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( 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_values__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_contains_satisfied(self): runtime_eval = { From 7f90596c969c1bf0c714319df4d92705a9ee855b Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 16:54:40 -0600 Subject: [PATCH 16/18] =?UTF-8?q?=20case-insensitivity=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mixpanel/flags/local_feature_flags.py | 34 ++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index ea73c36..c74581e 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -24,7 +24,6 @@ logger = logging.getLogger(__name__) logging.getLogger("httpx").setLevel(logging.ERROR) - class LocalFeatureFlagsProvider: FLAGS_DEFINITIONS_URL_PATH = "/flags/definitions" @@ -319,12 +318,40 @@ def _get_assigned_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 + elif isinstance(val, list): + return [self.lowercase_keys_and_values(item) for item in val] + elif isinstance(val, dict): + return { + key: + self.lowercase_keys_and_values(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 custom_properties + 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: @@ -333,7 +360,8 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str return False try: - result = json_logic.jsonLogic(rollout.runtime_evaluation_rule, parameters_for_runtime_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: logger.exception("Error evaluating runtime evaluation rule") From dcbc967d9e44b6458c83deea1012318aa03f7fc9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Thu, 13 Nov 2025 17:00:59 -0600 Subject: [PATCH 17/18] add tests for error cases --- mixpanel/flags/test_local_feature_flags.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 54b7d3a..96cabc3 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -213,6 +213,30 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied( 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_values__satisfied(self): runtime_eval = { From f315638768a314291252bc87b28183a003f2074f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 08:40:34 -0600 Subject: [PATCH 18/18] only lowercase leaf nodes of rule --- mixpanel/flags/local_feature_flags.py | 6 +++--- mixpanel/flags/test_local_feature_flags.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index c74581e..5730a9c 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -334,13 +334,13 @@ def lowercase_keys_and_values(self, val: Any) -> Any: def lowercase_only_leaf_nodes(self, val: Any) -> Dict[str, Any]: if isinstance(val, str): - return val + return val.casefold() elif isinstance(val, list): - return [self.lowercase_keys_and_values(item) for item in val] + return [self.lowercase_only_leaf_nodes(item) for item in val] elif isinstance(val, dict): return { key: - self.lowercase_keys_and_values(value) + self.lowercase_only_leaf_nodes(value) for key, value in val.items() } else: diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index 96cabc3..e4481c8 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -238,7 +238,7 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_not_satisfied_ assert result == "fallback" @respx.mock - async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_values__satisfied(self): + async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitive_param_value__satisfied(self): runtime_eval = { "==": [{"var": "plan"}, "premium"] } @@ -263,6 +263,19 @@ async def test_get_variant_value_respects_runtime_evaluation_rule_caseinsensitiv 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 = {