Skip to content

Commit a7fdb40

Browse files
committed
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.
1 parent 7698afc commit a7fdb40

7 files changed

Lines changed: 125 additions & 14 deletions

File tree

flagsmith/api/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class FeatureSegmentModel(typing.TypedDict):
3939

4040
class MultivariateFeatureOptionModel(typing.TypedDict):
4141
value: str
42+
key: NotRequired[typing.Optional[str]]
4243

4344

4445
class MultivariateFeatureStateValueModel(typing.TypedDict):

flagsmith/mappers.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sseclient
88
from flag_engine.context.types import (
99
FeatureContext,
10+
FeatureValue,
1011
SegmentContext,
1112
SegmentRule,
1213
StrValueSegmentCondition,
@@ -245,11 +246,13 @@ def _map_environment_document_feature_states_to_feature_contexts(
245246
if multivariate_feature_state_values := feature_state.get(
246247
"multivariate_feature_state_values"
247248
):
248-
feature_context["variants"] = [
249-
{
250-
"value": multivariate_feature_state_value[
251-
"multivariate_feature_option"
252-
]["value"],
249+
variants: list[FeatureValue] = []
250+
for multivariate_feature_state_value in multivariate_feature_state_values:
251+
multivariate_feature_option = multivariate_feature_state_value[
252+
"multivariate_feature_option"
253+
]
254+
variant: FeatureValue = {
255+
"value": multivariate_feature_option["value"],
253256
"weight": multivariate_feature_state_value["percentage_allocation"],
254257
"priority": (
255258
multivariate_feature_state_value.get("id")
@@ -258,8 +261,10 @@ def _map_environment_document_feature_states_to_feature_contexts(
258261
).int
259262
),
260263
}
261-
for multivariate_feature_state_value in multivariate_feature_state_values
262-
]
264+
if (key := multivariate_feature_option.get("key")) is not None:
265+
variant["key"] = key
266+
variants.append(variant)
267+
feature_context["variants"] = variants
263268

264269
if feature_segment := feature_state.get("feature_segment"):
265270
feature_context["priority"] = feature_segment["priority"]

flagsmith/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class DefaultFlag(BaseFlag):
5151
class Flag(BaseFlag):
5252
feature_id: int
5353
feature_name: str
54+
variant: typing.Optional[str] = None
5455
is_default: bool = field(default=False)
5556

5657
@classmethod
@@ -64,6 +65,7 @@ def from_evaluation_result(
6465
value=flag_result["value"],
6566
feature_name=flag_result["name"],
6667
feature_id=metadata["id"],
68+
variant=flag_result.get("variant"),
6769
)
6870
raise ValueError(
6971
"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:
7779
value=flag_data["feature_state_value"],
7880
feature_name=flag_data["feature"]["name"],
7981
feature_id=flag_data["feature"]["id"],
82+
variant=flag_data.get("variant"),
8083
)
8184

8285

poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com"
1010
packages = [{ include = "flagsmith" }]
1111

1212
[tool.poetry.dependencies]
13-
flagsmith-flag-engine = "^10.0.4"
13+
flagsmith-flag-engine = "^10.2.0"
1414
iso8601 = { version = "^2.1.0", python = "<3.11" }
1515
python = ">=3.9,<4"
1616
requests = "^2.32.3"

tests/test_mappers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from flagsmith.api.types import EnvironmentModel
2+
from flagsmith.mappers import map_environment_document_to_context
3+
4+
5+
def _environment_with_keyed_variant() -> EnvironmentModel:
6+
return {
7+
"api_key": "test-key",
8+
"name": "Test",
9+
"project": {"segments": []},
10+
"identity_overrides": [],
11+
"feature_states": [
12+
{
13+
"enabled": True,
14+
"feature": {"id": 1, "name": "mv_feature"},
15+
"feature_state_value": "control_value",
16+
"featurestate_uuid": "00000000-0000-0000-0000-000000000001",
17+
"multivariate_feature_state_values": [
18+
{
19+
"id": 10,
20+
"mv_fs_value_uuid": "00000000-0000-0000-0000-000000000002",
21+
"percentage_allocation": 100,
22+
"multivariate_feature_option": {
23+
"value": "variant_value",
24+
"key": "variant_a",
25+
},
26+
}
27+
],
28+
}
29+
],
30+
}
31+
32+
33+
def test_map_environment_document_to_context__keyed_variant__carries_key() -> None:
34+
# Given
35+
environment = _environment_with_keyed_variant()
36+
37+
# When
38+
context = map_environment_document_to_context(environment)
39+
40+
# Then
41+
variants = context["features"]["mv_feature"]["variants"]
42+
assert variants[0]["key"] == "variant_a"

tests/test_models.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ def test_flag_from_evaluation_result() -> None:
2020
flag_result: SDKFlagResult = {
2121
"enabled": True,
2222
"name": "test_feature",
23-
"reason": "DEFAULT",
23+
"reason": "SPLIT; weight=30",
2424
"value": "test-value",
25+
"variant": "control",
2526
"metadata": {"id": 123},
2627
}
2728

@@ -34,6 +35,25 @@ def test_flag_from_evaluation_result() -> None:
3435
assert flag.feature_name == "test_feature"
3536
assert flag.feature_id == 123
3637
assert flag.is_default is False
38+
assert flag.variant == "control"
39+
40+
41+
def test_flag_from_evaluation_result__no_variant__is_none() -> None:
42+
# Given
43+
flag_result: SDKFlagResult = {
44+
"enabled": True,
45+
"name": "test_feature",
46+
"reason": "DEFAULT",
47+
"value": "test-value",
48+
"variant": None,
49+
"metadata": {"id": 123},
50+
}
51+
52+
# When
53+
flag = Flag.from_evaluation_result(flag_result)
54+
55+
# Then
56+
assert flag.variant is None
3757

3858

3959
@pytest.mark.parametrize(
@@ -47,6 +67,7 @@ def test_flag_from_evaluation_result() -> None:
4767
"name": "feature1",
4868
"reason": "DEFAULT",
4969
"value": "value1",
70+
"variant": None,
5071
"metadata": {"id": 1},
5172
}
5273
},
@@ -59,6 +80,7 @@ def test_flag_from_evaluation_result() -> None:
5980
"name": "feature1",
6081
"reason": "DEFAULT",
6182
"value": "value1",
83+
"variant": None,
6284
"metadata": {"id": 1},
6385
}
6486
},
@@ -71,20 +93,23 @@ def test_flag_from_evaluation_result() -> None:
7193
"name": "feature1",
7294
"reason": "DEFAULT",
7395
"value": "value1",
96+
"variant": None,
7497
"metadata": {"id": 1},
7598
},
7699
"feature2": {
77100
"enabled": True,
78101
"name": "feature2",
79102
"reason": "DEFAULT",
80103
"value": "value2",
104+
"variant": None,
81105
"metadata": {"id": 2},
82106
},
83107
"feature3": {
84108
"enabled": True,
85109
"name": "feature3",
86110
"reason": "DEFAULT",
87111
"value": 42,
112+
"variant": None,
88113
"metadata": {"id": 3},
89114
},
90115
},
@@ -136,6 +161,7 @@ def test_flag_from_evaluation_result_value_types(
136161
"name": "test_feature",
137162
"reason": "DEFAULT",
138163
"value": value,
164+
"variant": None,
139165
"metadata": {"id": 123},
140166
}
141167

@@ -153,13 +179,45 @@ def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None
153179
"name": "test_feature",
154180
"reason": "DEFAULT",
155181
"value": "test-value",
182+
"variant": None,
156183
}
157184

158185
# When & Then
159186
with pytest.raises(ValueError):
160187
Flag.from_evaluation_result(flag_result)
161188

162189

190+
def test_flag_from_api_flag__sets_variant() -> None:
191+
# Given
192+
flag_data = {
193+
"enabled": True,
194+
"feature_state_value": "test-value",
195+
"feature": {"name": "test_feature", "id": 123},
196+
"variant": "control",
197+
}
198+
199+
# When
200+
flag = Flag.from_api_flag(flag_data)
201+
202+
# Then
203+
assert flag.variant == "control"
204+
205+
206+
def test_flag_from_api_flag__no_variant__is_none() -> None:
207+
# Given - the REST API may not include `variant`
208+
flag_data = {
209+
"enabled": True,
210+
"feature_state_value": "test-value",
211+
"feature": {"name": "test_feature", "id": 123},
212+
}
213+
214+
# When
215+
flag = Flag.from_api_flag(flag_data)
216+
217+
# Then
218+
assert flag.variant is None
219+
220+
163221
def test_get_flag_without_pipeline_processor() -> None:
164222
flags = Flags(
165223
flags={
@@ -197,6 +255,7 @@ def make(
197255
"name": "target",
198256
"enabled": False,
199257
"value": "base-value",
258+
"variant": None,
200259
"metadata": {"id": 1},
201260
},
202261
}
@@ -206,6 +265,7 @@ def make(
206265
"name": f"noise_{i}",
207266
"enabled": True,
208267
"value": f"noise-value-{i}",
268+
"variant": None,
209269
"metadata": {"id": 100 + i},
210270
}
211271
return {

0 commit comments

Comments
 (0)