From f15af268073006581f30d0fab48c5761da4096df Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 9 Jun 2026 14:50:06 +0530 Subject: [PATCH] feat: surface the selected variant on flags In local evaluation, expose which multivariate variant an identity was bucketed into via a new `Flag.variant`: - the variant's key when a named variant is selected, - "control" when the identity falls in the control bucket, - None otherwise (standard feature, unkeyed variant, or no identity). Threads the variant key from the environment document through the evaluation context so the engine can return it, and surfaces the engine's `variant` on the Flag. Requires flag-engine >=10.2.0. `Flag.from_api_flag` reads `variant` too, so remote evaluation will populate it once the API returns it. --- flagsmith/api/types.py | 1 + flagsmith/mappers.py | 19 ++++++++----- flagsmith/models.py | 3 +++ poetry.lock | 10 +++---- pyproject.toml | 2 +- tests/test_mappers.py | 57 +++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 tests/test_mappers.py diff --git a/flagsmith/api/types.py b/flagsmith/api/types.py index 3656487..effe979 100644 --- a/flagsmith/api/types.py +++ b/flagsmith/api/types.py @@ -39,6 +39,7 @@ class FeatureSegmentModel(typing.TypedDict): class MultivariateFeatureOptionModel(typing.TypedDict): value: str + key: NotRequired[typing.Optional[str]] class MultivariateFeatureStateValueModel(typing.TypedDict): diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 3ed3e3d..6cd56e8 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -7,6 +7,7 @@ import sseclient from flag_engine.context.types import ( FeatureContext, + FeatureValue, SegmentContext, SegmentRule, StrValueSegmentCondition, @@ -245,11 +246,13 @@ def _map_environment_document_feature_states_to_feature_contexts( if multivariate_feature_state_values := feature_state.get( "multivariate_feature_state_values" ): - feature_context["variants"] = [ - { - "value": multivariate_feature_state_value[ - "multivariate_feature_option" - ]["value"], + variants: list[FeatureValue] = [] + for multivariate_feature_state_value in multivariate_feature_state_values: + multivariate_feature_option = multivariate_feature_state_value[ + "multivariate_feature_option" + ] + variant: FeatureValue = { + "value": multivariate_feature_option["value"], "weight": multivariate_feature_state_value["percentage_allocation"], "priority": ( multivariate_feature_state_value.get("id") @@ -258,8 +261,10 @@ def _map_environment_document_feature_states_to_feature_contexts( ).int ), } - for multivariate_feature_state_value in multivariate_feature_state_values - ] + if (key := multivariate_feature_option.get("key")) is not None: + variant["key"] = key + variants.append(variant) + feature_context["variants"] = variants if feature_segment := feature_state.get("feature_segment"): feature_context["priority"] = feature_segment["priority"] diff --git a/flagsmith/models.py b/flagsmith/models.py index cfb5ff4..f753ccf 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -51,6 +51,7 @@ class DefaultFlag(BaseFlag): class Flag(BaseFlag): feature_id: int feature_name: str + variant: typing.Optional[str] = None is_default: bool = field(default=False) @classmethod @@ -64,6 +65,7 @@ def from_evaluation_result( value=flag_result["value"], feature_name=flag_result["name"], feature_id=metadata["id"], + variant=flag_result.get("variant"), ) raise ValueError( "FlagResult metadata is missing. Cannot create Flag instance. " @@ -77,6 +79,7 @@ def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: value=flag_data["feature_state_value"], feature_name=flag_data["feature"]["name"], feature_id=flag_data["feature"]["id"], + variant=flag_data.get("variant"), ) diff --git a/poetry.lock b/poetry.lock index 2eb1e39..848f928 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "certifi" @@ -273,14 +273,14 @@ files = [ [[package]] name = "flagsmith-flag-engine" -version = "10.1.0" +version = "10.2.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] files = [ - {file = "flagsmith_flag_engine-10.1.0-py3-none-any.whl", hash = "sha256:767dcf2f32586948eaa7816b5cbdae272d76d89e30c4642cbd74894c89a2d469"}, - {file = "flagsmith_flag_engine-10.1.0.tar.gz", hash = "sha256:fcb7e6833a874001c4ad3b91a66a4c31f050d53d94b116f88ad5c7ecd9650e8a"}, + {file = "flagsmith_flag_engine-10.2.0-py3-none-any.whl", hash = "sha256:c9bed3ee15487057dc61144d34d101d98db255f17d2c739f02794841a5c98502"}, + {file = "flagsmith_flag_engine-10.2.0.tar.gz", hash = "sha256:d935c9fb639e8acc5b9ff4599ec570e1b2f3f7b7874fc789a6eca3db5665a31b"}, ] [package.dependencies] @@ -977,4 +977,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "e91aea422e521889c402d406d22ac7541dea465a76097c131135c7ec046f1c9d" +content-hash = "1dfbe1180c5b24b0d1d185d7508e28df4030563d1bb9685d6f398b54a8967076" diff --git a/pyproject.toml b/pyproject.toml index 1d3b257..7b8eced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com" packages = [{ include = "flagsmith" }] [tool.poetry.dependencies] -flagsmith-flag-engine = "^10.0.4" +flagsmith-flag-engine = "^10.2.0" iso8601 = { version = "^2.1.0", python = "<3.11" } python = ">=3.9,<4" requests = "^2.32.3" diff --git a/tests/test_mappers.py b/tests/test_mappers.py new file mode 100644 index 0000000..fd73dca --- /dev/null +++ b/tests/test_mappers.py @@ -0,0 +1,57 @@ +from flagsmith.api.types import EnvironmentModel +from flagsmith.mappers import map_environment_document_to_context + + +def _environment_with_keyed_variant() -> EnvironmentModel: + return { + "api_key": "test-key", + "name": "Test", + "project": {"segments": []}, + "identity_overrides": [], + "feature_states": [ + { + "enabled": True, + "feature": {"id": 1, "name": "mv_feature"}, + "feature_state_value": "control_value", + "featurestate_uuid": "00000000-0000-0000-0000-000000000001", + "multivariate_feature_state_values": [ + { + "id": 10, + "mv_fs_value_uuid": "00000000-0000-0000-0000-000000000002", + "percentage_allocation": 100, + "multivariate_feature_option": { + "value": "variant_value", + "key": "variant_a", + }, + } + ], + } + ], + } + + +def test_map_environment_document_to_context__keyed_variant__carries_key() -> None: + # Given + environment = _environment_with_keyed_variant() + + # When + context = map_environment_document_to_context(environment) + + # Then + variants = context["features"]["mv_feature"]["variants"] + assert variants[0]["key"] == "variant_a" + + +def test_map_environment_document_to_context__null_variant_key__drops_key() -> None: + # Given - an unkeyed variant is serialised with a null key + environment = _environment_with_keyed_variant() + environment["feature_states"][0]["multivariate_feature_state_values"][0][ + "multivariate_feature_option" + ]["key"] = None + + # When + context = map_environment_document_to_context(environment) + + # Then - the null key is dropped, treated as no key + variants = context["features"]["mv_feature"]["variants"] + assert "key" not in variants[0] diff --git a/tests/test_models.py b/tests/test_models.py index b8aaca1..3f0d816 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -22,6 +22,7 @@ def test_flag_from_evaluation_result() -> None: "name": "test_feature", "reason": "DEFAULT", "value": "test-value", + "variant": "control", "metadata": {"id": 123}, } @@ -34,6 +35,25 @@ def test_flag_from_evaluation_result() -> None: assert flag.feature_name == "test_feature" assert flag.feature_id == 123 assert flag.is_default is False + assert flag.variant == "control" + + +def test_flag_from_evaluation_result__no_variant__is_none() -> None: + # Given + flag_result: SDKFlagResult = { + "enabled": True, + "name": "test_feature", + "reason": "DEFAULT", + "value": "test-value", + "variant": None, + "metadata": {"id": 123}, + } + + # When + flag = Flag.from_evaluation_result(flag_result) + + # Then + assert flag.variant is None @pytest.mark.parametrize( @@ -47,6 +67,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "variant": None, "metadata": {"id": 1}, } }, @@ -59,6 +80,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "variant": None, "metadata": {"id": 1}, } }, @@ -71,6 +93,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "variant": None, "metadata": {"id": 1}, }, "feature2": { @@ -78,6 +101,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature2", "reason": "DEFAULT", "value": "value2", + "variant": None, "metadata": {"id": 2}, }, "feature3": { @@ -85,6 +109,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature3", "reason": "DEFAULT", "value": 42, + "variant": None, "metadata": {"id": 3}, }, }, @@ -136,6 +161,7 @@ def test_flag_from_evaluation_result_value_types( "name": "test_feature", "reason": "DEFAULT", "value": value, + "variant": None, "metadata": {"id": 123}, } @@ -153,6 +179,7 @@ def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None "name": "test_feature", "reason": "DEFAULT", "value": "test-value", + "variant": None, } # When & Then @@ -160,6 +187,37 @@ def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None Flag.from_evaluation_result(flag_result) +def test_flag_from_api_flag__sets_variant() -> None: + # Given + flag_data = { + "enabled": True, + "feature_state_value": "test-value", + "feature": {"name": "test_feature", "id": 123}, + "variant": "control", + } + + # When + flag = Flag.from_api_flag(flag_data) + + # Then + assert flag.variant == "control" + + +def test_flag_from_api_flag__no_variant__is_none() -> None: + # Given - the REST API may not include `variant` + 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.variant is None + + def test_get_flag_without_pipeline_processor() -> None: flags = Flags( flags={ @@ -197,6 +255,7 @@ def make( "name": "target", "enabled": False, "value": "base-value", + "variant": None, "metadata": {"id": 1}, }, } @@ -206,6 +265,7 @@ def make( "name": f"noise_{i}", "enabled": True, "value": f"noise-value-{i}", + "variant": None, "metadata": {"id": 100 + i}, } return {