Skip to content

Commit 4d0fa9a

Browse files
authored
fix: Implicit identity key not supported for % Split operator (#276)
1 parent 38c6673 commit 4d0fa9a

File tree

4 files changed

+102
-53
lines changed

4 files changed

+102
-53
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 = v3.1.0
4+
tag = v3.2.0

flag_engine/segments/evaluator.py

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@
3636
from flag_engine.utils.types import SupportsStr, get_casting_function
3737

3838

39-
class FeatureContextWithSegmentName(TypedDict, typing.Generic[FeatureMetadataT]):
39+
class SegmentOverride(TypedDict, typing.Generic[FeatureMetadataT]):
4040
feature_context: FeatureContext[FeatureMetadataT]
4141
segment_name: str
4242

4343

44+
SegmentOverrides = dict[str, SegmentOverride[FeatureMetadataT]]
45+
4446
# Type alias for EvaluationContext with any metadata types
4547
# used in internal evaluation logic
4648
_EvaluationContextAnyMeta = EvaluationContext[typing.Any, typing.Any]
@@ -55,15 +57,52 @@ def get_evaluation_result(
5557
:param context: the evaluation context
5658
:return: EvaluationResult containing the context, flags, and segments
5759
"""
58-
segments: list[SegmentResult[SegmentMetadataT]] = []
59-
flags: dict[str, FlagResult[FeatureMetadataT]] = {}
60+
context = get_enriched_context(context)
61+
segments, segment_overrides = evaluate_segments(context)
62+
flags = evaluate_features(context, segment_overrides)
63+
64+
return {
65+
"flags": flags,
66+
"segments": segments,
67+
}
68+
69+
70+
def get_enriched_context(
71+
context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
72+
) -> EvaluationContext[SegmentMetadataT, FeatureMetadataT]:
73+
"""
74+
Get an enriched version of the evaluation context by ensuring that:
75+
- `$.identity.key` is set
76+
77+
:param context: the evaluation context to enrich
78+
:return: the enriched evaluation context. If not modified, returns the original context.
79+
"""
80+
if identity_context := context.get("identity"):
81+
if not identity_context.get("key"):
82+
context = context.copy()
83+
context["identity"] = {
84+
**identity_context,
85+
"key": (
86+
f"{context['environment']['key']}_{identity_context['identifier']}"
87+
),
88+
}
89+
90+
return context
6091

61-
segment_feature_contexts: dict[
62-
SupportsStr,
63-
FeatureContextWithSegmentName[FeatureMetadataT],
64-
] = {}
6592

66-
for segment_context in (context.get("segments") or {}).values():
93+
def evaluate_segments(
94+
context: EvaluationContext[SegmentMetadataT, FeatureMetadataT],
95+
) -> typing.Tuple[
96+
list[SegmentResult[SegmentMetadataT]],
97+
SegmentOverrides[FeatureMetadataT],
98+
]:
99+
if not (segment_contexts := context.get("segments")):
100+
return [], {}
101+
102+
segment_results: list[SegmentResult[SegmentMetadataT]] = []
103+
segment_overrides: SegmentOverrides[FeatureMetadataT] = {}
104+
105+
for segment_context in segment_contexts.values():
67106
if not is_context_in_segment(context, segment_context):
68107
continue
69108

@@ -72,69 +111,75 @@ def get_evaluation_result(
72111
}
73112
if segment_metadata := segment_context.get("metadata"):
74113
segment_result["metadata"] = segment_metadata
75-
segments.append(segment_result)
114+
segment_results.append(segment_result)
76115

77116
if overrides := segment_context.get("overrides"):
78117
for override_feature_context in overrides:
79118
feature_name = override_feature_context["name"]
80119
if (
81-
feature_name not in segment_feature_contexts
120+
feature_name not in segment_overrides
82121
or override_feature_context.get(
83122
"priority",
84123
constants.DEFAULT_PRIORITY,
85124
)
86-
< (segment_feature_contexts[feature_name]["feature_context"]).get(
125+
< (segment_overrides[feature_name]["feature_context"]).get(
87126
"priority",
88127
constants.DEFAULT_PRIORITY,
89128
)
90129
):
91-
segment_feature_contexts[feature_name] = (
92-
FeatureContextWithSegmentName(
93-
feature_context=override_feature_context,
94-
segment_name=segment_context["name"],
95-
)
130+
segment_overrides[feature_name] = SegmentOverride(
131+
feature_context=override_feature_context,
132+
segment_name=segment_context["name"],
96133
)
97134

98-
identity_key = _get_identity_key(context)
135+
return segment_results, segment_overrides
136+
137+
138+
def evaluate_features(
139+
context: EvaluationContext[typing.Any, FeatureMetadataT],
140+
segment_overrides: SegmentOverrides[FeatureMetadataT],
141+
) -> dict[str, FlagResult[FeatureMetadataT]]:
142+
flags: dict[str, FlagResult[FeatureMetadataT]] = {}
143+
99144
for feature_context in (context.get("features") or {}).values():
100145
feature_name = feature_context["name"]
101-
if feature_context_with_segment_name := segment_feature_contexts.get(
146+
if segment_override := segment_overrides.get(
102147
feature_context["name"],
103148
):
104-
feature_context = feature_context_with_segment_name["feature_context"]
149+
feature_context = segment_override["feature_context"]
105150
flag_result: FlagResult[FeatureMetadataT]
106151
flags[feature_name] = flag_result = {
107152
"enabled": feature_context["enabled"],
108153
"name": feature_context["name"],
109-
"reason": f"TARGETING_MATCH; segment={feature_context_with_segment_name['segment_name']}",
154+
"reason": f"TARGETING_MATCH; segment={segment_override['segment_name']}",
110155
"value": feature_context.get("value"),
111156
}
112157
if feature_metadata := feature_context.get("metadata"):
113158
flag_result["metadata"] = feature_metadata
114159
continue
115-
flags[feature_name] = get_flag_result_from_feature_context(
116-
feature_context=feature_context,
117-
key=identity_key,
160+
flags[feature_name] = get_flag_result_from_context(
161+
context=context,
162+
feature_name=feature_name,
118163
)
119164

120-
return {
121-
"flags": flags,
122-
"segments": segments,
123-
}
165+
return flags
124166

125167

126-
def get_flag_result_from_feature_context(
127-
feature_context: FeatureContext[FeatureMetadataT],
128-
key: typing.Optional[SupportsStr],
168+
def get_flag_result_from_context(
169+
context: EvaluationContext[typing.Any, FeatureMetadataT],
170+
feature_name: str,
129171
) -> FlagResult[FeatureMetadataT]:
130172
"""
131-
Get a feature value from the feature context
132-
for a given key.
173+
Get a feature value from the evaluation context
174+
for a given feature name.
133175
134-
:param feature_context: the feature context
135-
:param key: the key to get the value for
136-
:return: the value for the key in the feature context
176+
:param context: the evaluation context
177+
:param feature_name: the feature name to get the value for
178+
:return: the value for the feature name in the evaluation context
137179
"""
180+
feature_context = context["features"][feature_name]
181+
key = _get_identity_key(context)
182+
138183
flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None
139184

140185
if key is not None and (variants := feature_context.get("variants")):
@@ -253,8 +298,8 @@ def context_matches_condition(
253298
if condition["operator"] == constants.PERCENTAGE_SPLIT:
254299
if context_value is not None:
255300
object_ids = [segment_key, context_value]
256-
elif identity_context := context.get("identity"):
257-
object_ids = [segment_key, identity_context["key"]]
301+
elif identity_key := _get_identity_key(context):
302+
object_ids = [segment_key, identity_key]
258303
else:
259304
return False
260305

@@ -376,10 +421,7 @@ def _get_identity_key(
376421
context: _EvaluationContextAnyMeta,
377422
) -> typing.Optional[SupportsStr]:
378423
if identity_context := context.get("identity"):
379-
return (
380-
identity_context.get("key")
381-
or f"{context['environment']['key']}_{identity_context['identifier']}"
382-
)
424+
return identity_context.get("key")
383425
return None
384426

385427

tests/unit/segments/test_segments_evaluator.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
_matches_context_value,
1818
context_matches_condition,
1919
get_context_value,
20-
get_flag_result_from_feature_context,
20+
get_flag_result_from_context,
2121
is_context_in_segment,
2222
)
2323
from flag_engine.segments.types import ConditionOperator
@@ -828,7 +828,8 @@ def test_segment_condition_matches_context_value_for_modulo(
828828
),
829829
),
830830
)
831-
def test_get_flag_result_from_feature_context__calls_returns_expected(
831+
def test_get_flag_result_from_context__calls_returns_expected(
832+
context: EvaluationContext,
832833
percentage_value: int,
833834
expected_result: FlagResult,
834835
mocker: MockerFixture,
@@ -855,11 +856,13 @@ def test_get_flag_result_from_feature_context__calls_returns_expected(
855856
{"value": "bar", "weight": 30, "priority": 2},
856857
],
857858
}
859+
context["features"]["my_feature"] = feature_context
860+
context["identity"] = {"identifier": expected_key, "key": expected_key}
858861

859862
# When
860-
result = get_flag_result_from_feature_context(
861-
feature_context=feature_context,
862-
key=expected_key,
863+
result = get_flag_result_from_context(
864+
context=context,
865+
feature_name="my_feature",
863866
)
864867

865868
# the value of the feature state is correct based on the percentage value returned
@@ -875,12 +878,11 @@ def test_get_flag_result_from_feature_context__calls_returns_expected(
875878

876879

877880
def test_get_flag_result_from_feature_context__null_key__calls_returns_expected(
881+
context: EvaluationContext,
878882
mocker: MockerFixture,
879883
) -> None:
880884
# Given
881885
expected_feature_context_key = "2"
882-
# a None key is provided (no identity context present)
883-
expected_key = None
884886

885887
get_hashed_percentage_for_object_ids_mock = mocker.patch(
886888
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids",
@@ -897,10 +899,15 @@ def test_get_flag_result_from_feature_context__null_key__calls_returns_expected(
897899
],
898900
}
899901

902+
context["features"]["my_feature"] = feature_context
903+
904+
# no identity context present
905+
context["identity"] = None
906+
900907
# When
901-
result = get_flag_result_from_feature_context(
902-
feature_context=feature_context,
903-
key=expected_key,
908+
result = get_flag_result_from_context(
909+
context=context,
910+
feature_name="my_feature",
904911
)
905912

906913
# Then

0 commit comments

Comments
 (0)