From 379c630195204171078507873be4266890aa9094 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 6 Aug 2026 17:01:17 +0100 Subject: [PATCH] feat: Surface engine evaluation reason on flags beep boop --- flagsmith/models.py | 3 +++ tests/data/flags.json | 1 + tests/data/identities.json | 1 + tests/test_flagsmith.py | 5 +++++ tests/test_models.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/flagsmith/models.py b/flagsmith/models.py index f753ccf..51d474e 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -52,6 +52,7 @@ class Flag(BaseFlag): feature_id: int feature_name: str variant: typing.Optional[str] = None + reason: typing.Optional[str] = None is_default: bool = field(default=False) @classmethod @@ -66,6 +67,7 @@ def from_evaluation_result( feature_name=flag_result["name"], feature_id=metadata["id"], variant=flag_result.get("variant"), + reason=flag_result["reason"], ) raise ValueError( "FlagResult metadata is missing. Cannot create Flag instance. " @@ -80,6 +82,7 @@ def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: feature_name=flag_data["feature"]["name"], feature_id=flag_data["feature"]["id"], variant=flag_data.get("variant"), + reason=flag_data.get("reason"), ) diff --git a/tests/data/flags.json b/tests/data/flags.json index a2f226b..bba2bda 100644 --- a/tests/data/flags.json +++ b/tests/data/flags.json @@ -12,6 +12,7 @@ "project": 1 }, "feature_state_value": "some-value", + "reason": "DEFAULT", "enabled": true, "environment": 1, "identity": null, diff --git a/tests/data/identities.json b/tests/data/identities.json index bec5461..07951b3 100644 --- a/tests/data/identities.json +++ b/tests/data/identities.json @@ -20,6 +20,7 @@ "project": 1 }, "feature_state_value": "some-value", + "reason": "SPLIT; weight=50.0", "variant": "treatment", "enabled": true, "environment": 1, diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index d07fcc1..c57362d 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -75,6 +75,7 @@ def test_get_environment_flags_calls_api_when_no_local_environment( assert all_flags[0].enabled is True assert all_flags[0].value == "some-value" assert all_flags[0].feature_name == "some_feature" + assert all_flags[0].reason == "DEFAULT" @responses.activate() @@ -114,6 +115,7 @@ def test_get_environment_flags_omits_segments_from_evaluation_context( "name": "some_feature", "enabled": True, "value": "some-feature-state-value", + "reason": "DEFAULT", "metadata": {"id": 1}, } }, @@ -154,6 +156,7 @@ def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( assert identity_flags[0].enabled is True assert identity_flags[0].value == "some-value" assert identity_flags[0].feature_name == "some_feature" + assert identity_flags[0].reason == "SPLIT; weight=50.0" @responses.activate() @@ -208,6 +211,7 @@ def test_get_identity_flags_uses_local_environment_when_available( "name": "some_feature", "enabled": True, "value": "some-feature-state-value", + "reason": "DEFAULT", "metadata": {"id": 1}, } }, @@ -250,6 +254,7 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( "name": "some_feature", "enabled": True, "value": "some-feature-state-value", + "reason": "DEFAULT", "metadata": {"id": 1}, } }, diff --git a/tests/test_models.py b/tests/test_models.py index 3f0d816..88db019 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -36,6 +36,7 @@ def test_flag_from_evaluation_result() -> None: assert flag.feature_id == 123 assert flag.is_default is False assert flag.variant == "control" + assert flag.reason == "DEFAULT" def test_flag_from_evaluation_result__no_variant__is_none() -> None: @@ -218,6 +219,37 @@ def test_flag_from_api_flag__no_variant__is_none() -> None: assert flag.variant is None +def test_flag_from_api_flag__sets_reason() -> None: + # Given + flag_data = { + "enabled": True, + "feature_state_value": "test-value", + "feature": {"name": "test_feature", "id": 123}, + "reason": "TARGETING_MATCH; segment=premium", + } + + # When + flag = Flag.from_api_flag(flag_data) + + # Then + assert flag.reason == "TARGETING_MATCH; segment=premium" + + +def test_flag_from_api_flag__no_reason__is_none() -> None: + # Given + flag_data = { + "enabled": True, + "feature_state_value": "test-value", + "feature": {"name": "test_feature", "id": 123}, + } + + # When + flag = Flag.from_api_flag(flag_data) + + # Then + assert flag.reason is None + + def test_get_flag_without_pipeline_processor() -> None: flags = Flags( flags={ @@ -335,8 +367,10 @@ def test_lazy_flags__get_flag__applies_matching_segment_override( # When: we read the targeted feature. target = lazy_flags.get_flag("target") # Then: the override wins over the base feature value. + assert isinstance(target, Flag) assert target.enabled is True assert target.value == "premium-value" + assert target.reason == "TARGETING_MATCH; segment=premium_segment" def test_lazy_flags__get_flag__skips_non_matching_segment_override( @@ -355,8 +389,10 @@ def test_lazy_flags__get_flag__skips_non_matching_segment_override( target = flags.get_flag("target") # Then: the override doesn't win and base-value comes through. + assert isinstance(target, Flag) assert target.enabled is False assert target.value == "base-value" + assert target.reason == "DEFAULT" def test_lazy_flags__get_flag__caches_per_feature(