Skip to content

Commit 7fc7224

Browse files
authored
feat: surface the selected variant on flags (#221)
1 parent 7698afc commit 7fc7224

7 files changed

Lines changed: 139 additions & 13 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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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"
43+
44+
45+
def test_map_environment_document_to_context__null_variant_key__drops_key() -> None:
46+
# Given - an unkeyed variant is serialised with a null key
47+
environment = _environment_with_keyed_variant()
48+
environment["feature_states"][0]["multivariate_feature_state_values"][0][
49+
"multivariate_feature_option"
50+
]["key"] = None
51+
52+
# When
53+
context = map_environment_document_to_context(environment)
54+
55+
# Then - the null key is dropped, treated as no key
56+
variants = context["features"]["mv_feature"]["variants"]
57+
assert "key" not in variants[0]

tests/test_models.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def test_flag_from_evaluation_result() -> None:
2222
"name": "test_feature",
2323
"reason": "DEFAULT",
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)