Skip to content

Commit bbb5be0

Browse files
authored
feat: Support generic feature metadata (#269)
1 parent 07b1695 commit bbb5be0

File tree

7 files changed

+181
-43
lines changed

7 files changed

+181
-43
lines changed

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[submodule "tests/engine_tests/engine-test-data"]
22
path = tests/engine_tests/engine-test-data
33
url = https://github.com/flagsmith/engine-test-data.git
4-
tag = v2.4.0
4+
tag = v2.5.0

flag_engine/context/types.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from flag_engine.segments.types import (
1212
ConditionOperator,
1313
ContextValue,
14+
FeatureMetadataT,
1415
RuleType,
1516
SegmentMetadataT,
1617
)
@@ -54,26 +55,27 @@ class SegmentRule(TypedDict):
5455
rules: NotRequired[List[SegmentRule]]
5556

5657

57-
class FeatureContext(TypedDict):
58+
class FeatureContext(TypedDict, Generic[FeatureMetadataT]):
5859
key: str
5960
feature_key: str
6061
name: str
6162
enabled: bool
6263
value: Any
6364
variants: NotRequired[List[FeatureValue]]
6465
priority: NotRequired[float]
66+
metadata: NotRequired[FeatureMetadataT]
6567

6668

67-
class SegmentContext(TypedDict, Generic[SegmentMetadataT]):
69+
class SegmentContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]):
6870
key: str
6971
name: str
7072
rules: List[SegmentRule]
71-
overrides: NotRequired[List[FeatureContext]]
73+
overrides: NotRequired[List[FeatureContext[FeatureMetadataT]]]
7274
metadata: NotRequired[SegmentMetadataT]
7375

7476

75-
class EvaluationContext(TypedDict, Generic[SegmentMetadataT]):
77+
class EvaluationContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]):
7678
environment: EnvironmentContext
7779
identity: NotRequired[Optional[IdentityContext]]
78-
segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT]]]
79-
features: NotRequired[Dict[str, FeatureContext]]
80+
segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT, FeatureMetadataT]]]
81+
features: NotRequired[Dict[str, FeatureContext[FeatureMetadataT]]]

flag_engine/result/types.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88

99
from typing_extensions import NotRequired, TypedDict
1010

11-
from flag_engine.segments.types import SegmentMetadataT
11+
from flag_engine.segments.types import FeatureMetadataT, SegmentMetadataT
1212

1313

14-
class FlagResult(TypedDict):
14+
class FlagResult(TypedDict, Generic[FeatureMetadataT]):
1515
feature_key: str
1616
name: str
1717
enabled: bool
1818
value: Any
1919
reason: str
20+
metadata: NotRequired[FeatureMetadataT]
2021

2122

2223
class SegmentResult(TypedDict, Generic[SegmentMetadataT]):
@@ -25,6 +26,6 @@ class SegmentResult(TypedDict, Generic[SegmentMetadataT]):
2526
metadata: NotRequired[SegmentMetadataT]
2627

2728

28-
class EvaluationResult(TypedDict, Generic[SegmentMetadataT]):
29-
flags: Dict[str, FlagResult]
29+
class EvaluationResult(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]):
30+
flags: Dict[str, FlagResult[FeatureMetadataT]]
3031
segments: List[SegmentResult[SegmentMetadataT]]

flag_engine/segments/evaluator.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import json
24
import operator
35
import re
@@ -8,6 +10,7 @@
810

911
import jsonpath_rfc9535
1012
import semver
13+
from typing_extensions import TypedDict
1114

1215
from flag_engine.context.mappers import map_any_value_to_context_value
1316
from flag_engine.context.types import (
@@ -23,6 +26,7 @@
2326
from flag_engine.segments.types import (
2427
ConditionOperator,
2528
ContextValue,
29+
FeatureMetadataT,
2630
SegmentMetadataT,
2731
is_context_value,
2832
)
@@ -32,24 +36,27 @@
3236
from flag_engine.utils.types import SupportsStr, get_casting_function
3337

3438

35-
class FeatureContextWithSegmentName(typing.TypedDict):
36-
feature_context: FeatureContext
39+
class FeatureContextWithSegmentName(TypedDict, typing.Generic[FeatureMetadataT]):
40+
feature_context: FeatureContext[FeatureMetadataT]
3741
segment_name: str
3842

3943

4044
def get_evaluation_result(
41-
context: EvaluationContext[SegmentMetadataT],
42-
) -> EvaluationResult[SegmentMetadataT]:
45+
context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
46+
) -> EvaluationResult[SegmentMetadataT, FeatureMetadataT]:
4347
"""
4448
Get the evaluation result for a given context.
4549
4650
:param context: the evaluation context
4751
:return: EvaluationResult containing the context, flags, and segments
4852
"""
4953
segments: list[SegmentResult[SegmentMetadataT]] = []
50-
flags: dict[str, FlagResult] = {}
54+
flags: dict[str, FlagResult[FeatureMetadataT]] = {}
5155

52-
segment_feature_contexts: dict[SupportsStr, FeatureContextWithSegmentName] = {}
56+
segment_feature_contexts: dict[
57+
SupportsStr,
58+
FeatureContextWithSegmentName[FeatureMetadataT],
59+
] = {}
5360

5461
for segment_context in (context.get("segments") or {}).values():
5562
if not is_context_in_segment(context, segment_context):
@@ -59,8 +66,8 @@ def get_evaluation_result(
5966
"key": segment_context["key"],
6067
"name": segment_context["name"],
6168
}
62-
if metadata := segment_context.get("metadata"):
63-
segment_result["metadata"] = metadata
69+
if segment_metadata := segment_context.get("metadata"):
70+
segment_result["metadata"] = segment_metadata
6471
segments.append(segment_result)
6572

6673
if overrides := segment_context.get("overrides"):
@@ -95,13 +102,16 @@ def get_evaluation_result(
95102
feature_context["feature_key"],
96103
):
97104
feature_context = feature_context_with_segment_name["feature_context"]
98-
flags[feature_name] = {
105+
flag_result: FlagResult[FeatureMetadataT]
106+
flags[feature_name] = flag_result = {
99107
"enabled": feature_context["enabled"],
100108
"feature_key": feature_context["feature_key"],
101109
"name": feature_context["name"],
102110
"reason": f"TARGETING_MATCH; segment={feature_context_with_segment_name['segment_name']}",
103111
"value": feature_context.get("value"),
104112
}
113+
if feature_metadata := feature_context.get("metadata"):
114+
flag_result["metadata"] = feature_metadata
105115
continue
106116
flags[feature_name] = get_flag_result_from_feature_context(
107117
feature_context=feature_context,
@@ -115,9 +125,9 @@ def get_evaluation_result(
115125

116126

117127
def get_flag_result_from_feature_context(
118-
feature_context: FeatureContext,
128+
feature_context: FeatureContext[FeatureMetadataT],
119129
key: typing.Optional[SupportsStr],
120-
) -> FlagResult:
130+
) -> FlagResult[FeatureMetadataT]:
121131
"""
122132
Get a feature value from the feature context
123133
for a given key.
@@ -126,6 +136,8 @@ def get_flag_result_from_feature_context(
126136
:param key: the key to get the value for
127137
:return: the value for the key in the feature context
128138
"""
139+
flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None
140+
129141
if key is not None and (variants := feature_context.get("variants")):
130142
percentage_value = get_hashed_percentage_for_object_ids(
131143
[feature_context["key"], key]
@@ -139,28 +151,35 @@ def get_flag_result_from_feature_context(
139151
):
140152
limit = (weight := variant["weight"]) + start_percentage
141153
if start_percentage <= percentage_value < limit:
142-
return {
154+
flag_result = {
143155
"enabled": feature_context["enabled"],
144156
"feature_key": feature_context["feature_key"],
145157
"name": feature_context["name"],
146158
"reason": f"SPLIT; weight={weight}",
147159
"value": variant["value"],
148160
}
161+
break
149162

150163
start_percentage = limit
151164

152-
return {
153-
"enabled": feature_context["enabled"],
154-
"feature_key": feature_context["feature_key"],
155-
"name": feature_context["name"],
156-
"reason": "DEFAULT",
157-
"value": feature_context["value"],
158-
}
165+
if flag_result is None:
166+
flag_result = {
167+
"enabled": feature_context["enabled"],
168+
"feature_key": feature_context["feature_key"],
169+
"name": feature_context["name"],
170+
"reason": "DEFAULT",
171+
"value": feature_context["value"],
172+
}
173+
174+
if metadata := feature_context.get("metadata"):
175+
flag_result["metadata"] = metadata
176+
177+
return flag_result
159178

160179

161180
def is_context_in_segment(
162-
context: EvaluationContext[SegmentMetadataT],
163-
segment_context: SegmentContext[SegmentMetadataT],
181+
context: EvaluationContext[typing.Any, typing.Any],
182+
segment_context: SegmentContext[typing.Any, typing.Any],
164183
) -> bool:
165184
return bool(rules := segment_context["rules"]) and all(
166185
context_matches_rule(
@@ -171,7 +190,7 @@ def is_context_in_segment(
171190

172191

173192
def context_matches_rule(
174-
context: EvaluationContext[SegmentMetadataT],
193+
context: EvaluationContext[typing.Any, typing.Any],
175194
rule: SegmentRule,
176195
segment_key: SupportsStr,
177196
) -> bool:
@@ -201,7 +220,7 @@ def context_matches_rule(
201220

202221

203222
def context_matches_condition(
204-
context: EvaluationContext[SegmentMetadataT],
223+
context: EvaluationContext[typing.Any, typing.Any],
205224
condition: SegmentCondition,
206225
segment_key: SupportsStr,
207226
) -> bool:
@@ -262,7 +281,7 @@ def context_matches_condition(
262281

263282

264283
def get_context_value(
265-
context: EvaluationContext[SegmentMetadataT],
284+
context: EvaluationContext[typing.Any, typing.Any],
266285
property: str,
267286
) -> ContextValue:
268287
value = None

flag_engine/segments/types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

3-
from typing import Any, Dict, Literal, Union, get_args
3+
from typing import Any, Literal, Mapping, Union, get_args
44

55
from typing_extensions import TypeGuard, TypeVar
66

7-
SegmentMetadataT = TypeVar("SegmentMetadataT", default=Dict[str, object])
7+
SegmentMetadataT = TypeVar("SegmentMetadataT", default=Mapping[str, object])
8+
FeatureMetadataT = TypeVar("FeatureMetadataT", default=Mapping[str, object])
89

910
ConditionOperator = Literal[
1011
"EQUAL",

0 commit comments

Comments
 (0)