Skip to content

Commit 7350d09

Browse files
committed
feat: Support generic feature metadata
1 parent 07b1695 commit 7350d09

File tree

5 files changed

+231
-41
lines changed

5 files changed

+231
-41
lines changed

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: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from flag_engine.segments.types import (
2424
ConditionOperator,
2525
ContextValue,
26+
FeatureMetadataT,
2627
SegmentMetadataT,
2728
is_context_value,
2829
)
@@ -32,24 +33,27 @@
3233
from flag_engine.utils.types import SupportsStr, get_casting_function
3334

3435

35-
class FeatureContextWithSegmentName(typing.TypedDict):
36-
feature_context: FeatureContext
36+
class FeatureContextWithSegmentName(typing.TypedDict, typing.Generic[FeatureMetadataT]):
37+
feature_context: FeatureContext[FeatureMetadataT]
3738
segment_name: str
3839

3940

4041
def get_evaluation_result(
41-
context: EvaluationContext[SegmentMetadataT],
42-
) -> EvaluationResult[SegmentMetadataT]:
42+
context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
43+
) -> EvaluationResult[SegmentMetadataT, FeatureMetadataT]:
4344
"""
4445
Get the evaluation result for a given context.
4546
4647
:param context: the evaluation context
4748
:return: EvaluationResult containing the context, flags, and segments
4849
"""
4950
segments: list[SegmentResult[SegmentMetadataT]] = []
50-
flags: dict[str, FlagResult] = {}
51+
flags: dict[str, FlagResult[FeatureMetadataT]] = {}
5152

52-
segment_feature_contexts: dict[SupportsStr, FeatureContextWithSegmentName] = {}
53+
segment_feature_contexts: dict[
54+
SupportsStr,
55+
FeatureContextWithSegmentName[FeatureMetadataT],
56+
] = {}
5357

5458
for segment_context in (context.get("segments") or {}).values():
5559
if not is_context_in_segment(context, segment_context):
@@ -59,8 +63,8 @@ def get_evaluation_result(
5963
"key": segment_context["key"],
6064
"name": segment_context["name"],
6165
}
62-
if metadata := segment_context.get("metadata"):
63-
segment_result["metadata"] = metadata
66+
if segment_metadata := segment_context.get("metadata"):
67+
segment_result["metadata"] = segment_metadata
6468
segments.append(segment_result)
6569

6670
if overrides := segment_context.get("overrides"):
@@ -95,13 +99,16 @@ def get_evaluation_result(
9599
feature_context["feature_key"],
96100
):
97101
feature_context = feature_context_with_segment_name["feature_context"]
98-
flags[feature_name] = {
102+
flag_result: FlagResult[FeatureMetadataT]
103+
flags[feature_name] = flag_result = {
99104
"enabled": feature_context["enabled"],
100105
"feature_key": feature_context["feature_key"],
101106
"name": feature_context["name"],
102107
"reason": f"TARGETING_MATCH; segment={feature_context_with_segment_name['segment_name']}",
103108
"value": feature_context.get("value"),
104109
}
110+
if feature_metadata := feature_context.get("metadata"):
111+
flag_result["metadata"] = feature_metadata
105112
continue
106113
flags[feature_name] = get_flag_result_from_feature_context(
107114
feature_context=feature_context,
@@ -115,9 +122,9 @@ def get_evaluation_result(
115122

116123

117124
def get_flag_result_from_feature_context(
118-
feature_context: FeatureContext,
125+
feature_context: FeatureContext[FeatureMetadataT],
119126
key: typing.Optional[SupportsStr],
120-
) -> FlagResult:
127+
) -> FlagResult[FeatureMetadataT]:
121128
"""
122129
Get a feature value from the feature context
123130
for a given key.
@@ -126,6 +133,8 @@ def get_flag_result_from_feature_context(
126133
:param key: the key to get the value for
127134
:return: the value for the key in the feature context
128135
"""
136+
flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None
137+
129138
if key is not None and (variants := feature_context.get("variants")):
130139
percentage_value = get_hashed_percentage_for_object_ids(
131140
[feature_context["key"], key]
@@ -139,28 +148,35 @@ def get_flag_result_from_feature_context(
139148
):
140149
limit = (weight := variant["weight"]) + start_percentage
141150
if start_percentage <= percentage_value < limit:
142-
return {
151+
flag_result = {
143152
"enabled": feature_context["enabled"],
144153
"feature_key": feature_context["feature_key"],
145154
"name": feature_context["name"],
146155
"reason": f"SPLIT; weight={weight}",
147156
"value": variant["value"],
148157
}
158+
break
149159

150160
start_percentage = limit
151161

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-
}
162+
if flag_result is None:
163+
flag_result = {
164+
"enabled": feature_context["enabled"],
165+
"feature_key": feature_context["feature_key"],
166+
"name": feature_context["name"],
167+
"reason": "DEFAULT",
168+
"value": feature_context["value"],
169+
}
170+
171+
if metadata := feature_context.get("metadata"):
172+
flag_result["metadata"] = metadata
173+
174+
return flag_result
159175

160176

161177
def is_context_in_segment(
162-
context: EvaluationContext[SegmentMetadataT],
163-
segment_context: SegmentContext[SegmentMetadataT],
178+
context: EvaluationContext[typing.Any, typing.Any],
179+
segment_context: SegmentContext[typing.Any, typing.Any],
164180
) -> bool:
165181
return bool(rules := segment_context["rules"]) and all(
166182
context_matches_rule(
@@ -171,7 +187,7 @@ def is_context_in_segment(
171187

172188

173189
def context_matches_rule(
174-
context: EvaluationContext[SegmentMetadataT],
190+
context: EvaluationContext[typing.Any, typing.Any],
175191
rule: SegmentRule,
176192
segment_key: SupportsStr,
177193
) -> bool:
@@ -201,7 +217,7 @@ def context_matches_rule(
201217

202218

203219
def context_matches_condition(
204-
context: EvaluationContext[SegmentMetadataT],
220+
context: EvaluationContext[typing.Any, typing.Any],
205221
condition: SegmentCondition,
206222
segment_key: SupportsStr,
207223
) -> bool:
@@ -262,7 +278,7 @@ def context_matches_condition(
262278

263279

264280
def get_context_value(
265-
context: EvaluationContext[SegmentMetadataT],
281+
context: EvaluationContext[typing.Any, typing.Any],
266282
property: str,
267283
) -> ContextValue:
268284
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)