Skip to content

Commit 1bbbdf8

Browse files
authored
feat: Support feature metadata (#163)
1 parent 4f84044 commit 1bbbdf8

File tree

7 files changed

+82
-52
lines changed

7 files changed

+82
-52
lines changed

flagsmith/mappers.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@
2222
)
2323
from flagsmith.models import Segment
2424
from flagsmith.types import (
25+
FeatureMetadata,
2526
SDKEvaluationContext,
2627
SegmentMetadata,
2728
StreamEvent,
2829
TraitConfig,
2930
)
3031

3132
OverrideKey = typing.Tuple[
32-
str,
33+
int,
3334
str,
3435
bool,
3536
typing.Any,
@@ -148,7 +149,7 @@ def map_environment_document_to_context(
148149

149150
def _map_identity_overrides_to_segments(
150151
identity_overrides: list[IdentityModel],
151-
) -> dict[str, SegmentContext[SegmentMetadata]]:
152+
) -> dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]:
152153
features_to_identifiers: typing.Dict[
153154
OverridesKey,
154155
typing.List[str],
@@ -159,7 +160,7 @@ def _map_identity_overrides_to_segments(
159160
continue
160161
overrides_key = tuple(
161162
(
162-
str(feature_state["feature"]["id"]),
163+
feature_state["feature"]["id"],
163164
feature_state["feature"]["name"],
164165
feature_state["enabled"],
165166
feature_state["feature_state_value"],
@@ -170,7 +171,13 @@ def _map_identity_overrides_to_segments(
170171
)
171172
)
172173
features_to_identifiers[overrides_key].append(identity_override["identifier"])
173-
segment_contexts: typing.Dict[str, SegmentContext[SegmentMetadata]] = {}
174+
segment_contexts: typing.Dict[
175+
str,
176+
SegmentContext[
177+
SegmentMetadata,
178+
FeatureMetadata,
179+
],
180+
] = {}
174181
for overrides_key, identifiers in features_to_identifiers.items():
175182
# Create a segment context for each unique set of overrides
176183
# Generate a unique key to avoid collisions
@@ -193,13 +200,14 @@ def _map_identity_overrides_to_segments(
193200
overrides=[
194201
{
195202
"key": "", # Identity overrides never carry multivariate options
196-
"feature_key": feature_key,
203+
"feature_key": str(flagsmith_id),
197204
"name": feature_name,
198205
"enabled": feature_enabled,
199206
"value": feature_value,
200207
"priority": float("-inf"), # Highest possible priority
208+
"metadata": {"flagsmith_id": flagsmith_id},
201209
}
202-
for feature_key, feature_name, feature_enabled, feature_value in overrides_key
210+
for flagsmith_id, feature_name, feature_enabled, feature_value in overrides_key
203211
],
204212
metadata=SegmentMetadata(source="identity_overrides"),
205213
)
@@ -230,16 +238,18 @@ def _map_environment_document_rules_to_context_rules(
230238

231239
def _map_environment_document_feature_states_to_feature_contexts(
232240
feature_states: list[FeatureStateModel],
233-
) -> typing.Iterable[FeatureContext]:
241+
) -> typing.Iterable[FeatureContext[FeatureMetadata]]:
234242
for feature_state in feature_states:
235-
feature_context = FeatureContext(
243+
metadata: FeatureMetadata = {"flagsmith_id": feature_state["feature"]["id"]}
244+
feature_context = FeatureContext[FeatureMetadata](
236245
key=str(
237246
feature_state.get("django_id") or feature_state["featurestate_uuid"]
238247
),
239248
feature_key=str(feature_state["feature"]["id"]),
240249
name=feature_state["feature"]["name"],
241250
enabled=feature_state["enabled"],
242251
value=feature_state["feature_state_value"],
252+
metadata=metadata,
243253
)
244254
if multivariate_feature_state_values := feature_state.get(
245255
"multivariate_feature_state_values"

flagsmith/models.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
import typing
44
from dataclasses import dataclass, field
55

6-
from flag_engine.result.types import FlagResult
7-
86
from flagsmith.analytics import AnalyticsProcessor
97
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
10-
from flagsmith.types import SDKEvaluationResult
8+
from flagsmith.types import SDKEvaluationResult, SDKFlagResult
119

1210

1311
@dataclass
@@ -30,13 +28,18 @@ class Flag(BaseFlag):
3028
@classmethod
3129
def from_evaluation_result(
3230
cls,
33-
flag: FlagResult,
31+
flag_result: SDKFlagResult,
3432
) -> Flag:
35-
return Flag(
36-
enabled=flag["enabled"],
37-
value=flag["value"],
38-
feature_name=flag["name"],
39-
feature_id=int(flag["feature_key"]),
33+
if metadata := flag_result.get("metadata"):
34+
return Flag(
35+
enabled=flag_result["enabled"],
36+
value=flag_result["value"],
37+
feature_name=flag_result["name"],
38+
feature_id=metadata["flagsmith_id"],
39+
)
40+
raise ValueError(
41+
"FlagResult metadata is missing. Cannot create Flag instance. "
42+
"This means a bug in the SDK, please report it."
4043
)
4144

4245
@classmethod
@@ -64,13 +67,9 @@ def from_evaluation_result(
6467
) -> Flags:
6568
return cls(
6669
flags={
67-
flag_name: Flag(
68-
enabled=flag["enabled"],
69-
value=flag["value"],
70-
feature_name=flag["name"],
71-
feature_id=int(flag["feature_key"]),
72-
)
73-
for flag_name, flag in evaluation_result["flags"].items()
70+
flag_name: flag
71+
for flag_name, flag_result in evaluation_result["flags"].items()
72+
if (flag := Flag.from_evaluation_result(flag_result))
7473
},
7574
default_flag_handler=default_flag_handler,
7675
_analytics_processor=analytics_processor,

flagsmith/types.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from flag_engine.context.types import EvaluationContext
55
from flag_engine.engine import ContextValue
6-
from flag_engine.result.types import EvaluationResult
6+
from flag_engine.result.types import EvaluationResult, FlagResult
77
from typing_extensions import NotRequired, TypeAlias
88

99
_JsonScalarType: TypeAlias = typing.Union[
@@ -44,5 +44,11 @@ class SegmentMetadata(typing.TypedDict):
4444
"""The source of the segment, e.g. 'api', 'identity_overrides'."""
4545

4646

47-
SDKEvaluationContext = EvaluationContext[SegmentMetadata]
48-
SDKEvaluationResult = EvaluationResult[SegmentMetadata]
47+
class FeatureMetadata(typing.TypedDict):
48+
flagsmith_id: int
49+
"""The ID of the feature used in Flagsmith API."""
50+
51+
52+
SDKEvaluationContext = EvaluationContext[SegmentMetadata, FeatureMetadata]
53+
SDKEvaluationResult = EvaluationResult[SegmentMetadata, FeatureMetadata]
54+
SDKFlagResult = FlagResult[FeatureMetadata]

poetry.lock

Lines changed: 4 additions & 4 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 = "^9.0.0"
13+
flagsmith-flag-engine = "^9.1.0"
1414
python = ">=3.9,<4"
1515
requests = "^2.32.3"
1616
requests-futures = "^1.0.1"

tests/test_flagsmith.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def test_get_identity_flags_uses_local_environment_when_available(
166166
"enabled": True,
167167
"value": "some-feature-state-value",
168168
"feature_key": "1",
169+
"metadata": {"flagsmith_id": 1},
169170
}
170171
},
171172
"segments": [],

tests/test_models.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
import typing
22

33
import pytest
4-
from flag_engine.result.types import FlagResult
54

65
from flagsmith.models import Flag, Flags
7-
from flagsmith.types import SDKEvaluationResult
6+
from flagsmith.types import SDKEvaluationResult, SDKFlagResult
87

98

109
def test_flag_from_evaluation_result() -> None:
1110
# Given
12-
flag_result: FlagResult = {
11+
flag_result: SDKFlagResult = {
1312
"enabled": True,
1413
"feature_key": "123",
1514
"name": "test_feature",
1615
"reason": "DEFAULT",
1716
"value": "test-value",
17+
"metadata": {"flagsmith_id": 123},
1818
}
1919

2020
# When
21-
flag: Flag = Flag.from_evaluation_result(flag_result)
21+
flag = Flag.from_evaluation_result(flag_result)
2222

2323
# Then
2424
assert flag.enabled is True
@@ -29,9 +29,9 @@ def test_flag_from_evaluation_result() -> None:
2929

3030

3131
@pytest.mark.parametrize(
32-
"flags_result,expected_count,expected_names",
32+
"flags_result,expected_names",
3333
[
34-
({}, 0, []),
34+
({}, []),
3535
(
3636
{
3737
"feature1": {
@@ -40,9 +40,9 @@ def test_flag_from_evaluation_result() -> None:
4040
"name": "feature1",
4141
"reason": "DEFAULT",
4242
"value": "value1",
43+
"metadata": {"flagsmith_id": 1},
4344
}
4445
},
45-
1,
4646
["feature1"],
4747
),
4848
(
@@ -53,9 +53,9 @@ def test_flag_from_evaluation_result() -> None:
5353
"name": "feature1",
5454
"reason": "DEFAULT",
5555
"value": "value1",
56+
"metadata": {"flagsmith_id": 1},
5657
}
5758
},
58-
1,
5959
["feature1"],
6060
),
6161
(
@@ -66,30 +66,31 @@ def test_flag_from_evaluation_result() -> None:
6666
"name": "feature1",
6767
"reason": "DEFAULT",
6868
"value": "value1",
69+
"metadata": {"flagsmith_id": 1},
6970
},
7071
"feature2": {
7172
"enabled": True,
7273
"feature_key": "2",
7374
"name": "feature2",
7475
"reason": "DEFAULT",
7576
"value": "value2",
77+
"metadata": {"flagsmith_id": 2},
7678
},
7779
"feature3": {
7880
"enabled": True,
7981
"feature_key": "3",
8082
"name": "feature3",
8183
"reason": "DEFAULT",
8284
"value": 42,
85+
"metadata": {"flagsmith_id": 3},
8386
},
8487
},
85-
3,
8688
["feature1", "feature2", "feature3"],
8789
),
8890
],
8991
)
9092
def test_flags_from_evaluation_result(
91-
flags_result: typing.Dict[str, FlagResult],
92-
expected_count: int,
93+
flags_result: typing.Dict[str, SDKFlagResult],
9394
expected_names: typing.List[str],
9495
) -> None:
9596
# Given
@@ -106,13 +107,10 @@ def test_flags_from_evaluation_result(
106107
)
107108

108109
# Then
109-
assert len(flags.flags) == expected_count
110-
111-
for name in expected_names:
112-
assert name in flags.flags
113-
flag: Flag = flags.flags[name]
114-
assert isinstance(flag, Flag)
115-
assert flag.feature_name == name
110+
assert set(flags.flags.keys()) == set(expected_names)
111+
assert set(flag.feature_name for flag in flags.flags.values()) == set(
112+
expected_names
113+
)
116114

117115

118116
@pytest.mark.parametrize(
@@ -130,16 +128,32 @@ def test_flag_from_evaluation_result_value_types(
130128
value: typing.Any, expected: typing.Any
131129
) -> None:
132130
# Given
133-
flag_result: FlagResult = {
131+
flag_result: SDKFlagResult = {
134132
"enabled": True,
135133
"feature_key": "123",
136134
"name": "test_feature",
137135
"reason": "DEFAULT",
138136
"value": value,
137+
"metadata": {"flagsmith_id": 123},
139138
}
140139

141140
# When
142-
flag: Flag = Flag.from_evaluation_result(flag_result)
141+
flag = Flag.from_evaluation_result(flag_result)
143142

144143
# Then
145144
assert flag.value == expected
145+
146+
147+
def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None:
148+
# Given
149+
flag_result: SDKFlagResult = {
150+
"enabled": True,
151+
"feature_key": "123",
152+
"name": "test_feature",
153+
"reason": "DEFAULT",
154+
"value": "test-value",
155+
}
156+
157+
# When & Then
158+
with pytest.raises(ValueError):
159+
Flag.from_evaluation_result(flag_result)

0 commit comments

Comments
 (0)