From 871d63c72663e9fe1e2584fe88455cf660e5dd91 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 8 Jun 2026 15:42:19 +0530 Subject: [PATCH] feat: include the selected variant in flag results Every flag result now carries a `variant` field, mirroring OpenFeature's `ResolutionDetails.variant` (always present, nullable): - the variant's key when a named multivariate variant is selected, - `"control"` when an identity falls in the control bucket (the leftover allocation that resolves to the feature's default value); the control is not a multivariate option, so the engine synthesises this key, - `null` otherwise (standard features, unkeyed variants, or evaluation without an identity, where nothing is bucketed). Selection logic, hashing and ordering are unchanged. The behaviour is covered by the shared engine-test-data corpus, bumped to v3.9.0. --- .gitmodules | 2 +- flag_engine/context/types.py | 1 + flag_engine/result/types.py | 3 +- flag_engine/segments/evaluator.py | 33 ++++++++++--------- tests/engine_tests/engine-test-data | 2 +- .../unit/segments/test_segments_evaluator.py | 7 +++- tests/unit/test_engine.py | 10 ++++++ 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/.gitmodules b/.gitmodules index c62bff8..606c611 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tests/engine_tests/engine-test-data"] path = tests/engine_tests/engine-test-data url = https://github.com/flagsmith/engine-test-data.git - branch = v3.7.0 + branch = v3.9.0 diff --git a/flag_engine/context/types.py b/flag_engine/context/types.py index 7e26142..328face 100644 --- a/flag_engine/context/types.py +++ b/flag_engine/context/types.py @@ -23,6 +23,7 @@ class EnvironmentContext(TypedDict): class FeatureValue(TypedDict): + key: NotRequired[str] value: Any weight: float priority: int diff --git a/flag_engine/result/types.py b/flag_engine/result/types.py index 4c9d663..057349a 100644 --- a/flag_engine/result/types.py +++ b/flag_engine/result/types.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Dict, Generic, List +from typing import Any, Dict, Generic, List, Optional from typing_extensions import NotRequired, TypedDict @@ -16,6 +16,7 @@ class FlagResult(TypedDict, Generic[FeatureMetadataT]): enabled: bool value: Any reason: str + variant: Optional[str] metadata: NotRequired[FeatureMetadataT] diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index 65a380b..149e02e 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -178,38 +178,39 @@ def get_flag_result_from_context( """ key = _get_identity_key(context) - flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None + value = feature_context["value"] + variant: typing.Optional[str] = None if key is not None and (variants := feature_context.get("variants")): + # Default to the control bucket; a matched named variant overrides this. + variant = "control" + percentage_value = get_hashed_percentage_for_object_ids( [feature_context["key"], key] ) start_percentage = 0.0 - for variant in sorted( + for feature_value in sorted( variants, key=operator.itemgetter("priority"), ): - limit = (weight := variant["weight"]) + start_percentage + limit = (weight := feature_value["weight"]) + start_percentage if start_percentage <= percentage_value < limit: - flag_result = { - "enabled": feature_context["enabled"], - "name": feature_context["name"], - "reason": f"SPLIT; weight={weight}", - "value": variant["value"], - } + reason = f"SPLIT; weight={weight}" + value = feature_value["value"] + variant = feature_value.get("key") break start_percentage = limit - if flag_result is None: - flag_result = { - "enabled": feature_context["enabled"], - "name": feature_context["name"], - "reason": reason, - "value": feature_context["value"], - } + flag_result: FlagResult[FeatureMetadataT] = { + "enabled": feature_context["enabled"], + "name": feature_context["name"], + "reason": reason, + "value": value, + "variant": variant, + } if metadata := feature_context.get("metadata"): flag_result["metadata"] = metadata diff --git a/tests/engine_tests/engine-test-data b/tests/engine_tests/engine-test-data index 4b29dc7..5031065 160000 --- a/tests/engine_tests/engine-test-data +++ b/tests/engine_tests/engine-test-data @@ -1 +1 @@ -Subproject commit 4b29dc772a764364af2dd504ecefbdf74cf5473f +Subproject commit 5031065965d5ddbb499e1f5430657b80a609337c diff --git a/tests/unit/segments/test_segments_evaluator.py b/tests/unit/segments/test_segments_evaluator.py index 844d4fa..3029eec 100644 --- a/tests/unit/segments/test_segments_evaluator.py +++ b/tests/unit/segments/test_segments_evaluator.py @@ -806,6 +806,7 @@ def test_segment_condition_matches_context_value_for_modulo( "name": "my_feature", "reason": "SPLIT; weight=30", "value": "foo", + "variant": None, }, ), ( @@ -815,6 +816,7 @@ def test_segment_condition_matches_context_value_for_modulo( "name": "my_feature", "reason": "SPLIT; weight=30", "value": "bar", + "variant": None, }, ), ( @@ -824,6 +826,7 @@ def test_segment_condition_matches_context_value_for_modulo( "name": "my_feature", "reason": "DEFAULT", "value": "control", + "variant": "control", }, ), ), @@ -910,12 +913,14 @@ def test_get_flag_result_from_feature_context__null_key__calls_returns_expected( ) # Then - # the value of the feature state is the default one + # the value of the feature state is the default one and no variant is + # attributed because the identity was never bucketed assert result == { "enabled": False, "name": "my_feature", "reason": "DEFAULT", "value": "control", + "variant": None, } # the function is not called as there is no key to hash against diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index be0aae4..54da757 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -26,12 +26,14 @@ def test_get_evaluation_result__no_overrides__returns_expected( "name": "feature_1", "reason": "DEFAULT", "value": None, + "variant": None, }, "feature_2": { "enabled": False, "name": "feature_2", "reason": "DEFAULT", "value": None, + "variant": None, }, }, segments=[], @@ -52,12 +54,14 @@ def test_get_evaluation_result__segment_override__returns_expected( "name": "feature_1", "reason": "TARGETING_MATCH; segment=my_segment", "value": "segment_override", + "variant": None, }, "feature_2": { "enabled": False, "name": "feature_2", "reason": "DEFAULT", "value": None, + "variant": None, }, }, "segments": [{"name": "my_segment"}], @@ -106,12 +110,14 @@ def test_get_evaluation_result__identity_override__returns_expected( "name": "feature_1", "reason": "TARGETING_MATCH; segment=identity_overrides", "value": "overridden_for_identity", + "variant": None, }, "feature_2": { "enabled": False, "name": "feature_2", "reason": "DEFAULT", "value": None, + "variant": None, }, }, "segments": [{"name": "identity_overrides"}], @@ -202,12 +208,14 @@ def test_get_evaluation_result__two_segments_override_same_feature__returns_expe "name": "feature_1", "reason": "TARGETING_MATCH; segment=higher_priority_segment", "value": "segment_override_other", + "variant": None, }, "feature_2": { "enabled": False, "name": "feature_2", "reason": "DEFAULT", "value": None, + "variant": None, }, }, "segments": [ @@ -325,12 +333,14 @@ def test_get_evaluation_result__segment_override__no_priority__returns_expected( "name": "feature_1", "reason": "TARGETING_MATCH; segment=segment_with_override_priority", "value": "overridden_with_priority", + "variant": None, }, "feature_2": { "enabled": False, "name": "feature_2", "reason": "TARGETING_MATCH; segment=another_segment", "value": "moose", + "variant": None, }, }, "segments": [