Skip to content

Commit b1e4447

Browse files
khvn26Zaimwa9emyller
authored
feat(v7): get_evaluation_result (#239)
Co-authored-by: Zaimwa9 <[email protected]> Co-authored-by: Evandro Myller <[email protected]>
1 parent ffadc79 commit b1e4447

File tree

19 files changed

+1168
-504
lines changed

19 files changed

+1168
-504
lines changed

.github/workflows/pull-request.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@ jobs:
5656
with:
5757
minimum_coverage: 100
5858
fail_below_threshold: true
59+
show_missing: true

flag_engine/context/mappers.py

Lines changed: 229 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,253 @@
1+
import json
12
import typing
3+
from collections import defaultdict
24

3-
from flag_engine.context.types import EvaluationContext
5+
from flag_engine.context.types import (
6+
EvaluationContext,
7+
FeatureContext,
8+
SegmentContext,
9+
SegmentRule,
10+
)
411
from flag_engine.environments.models import EnvironmentModel
12+
from flag_engine.features.models import (
13+
FeatureModel,
14+
FeatureStateModel,
15+
MultivariateFeatureStateValueModel,
16+
)
517
from flag_engine.identities.models import IdentityModel
618
from flag_engine.identities.traits.models import TraitModel
19+
from flag_engine.result.types import FlagResult
20+
from flag_engine.segments.models import SegmentRuleModel
21+
22+
OverrideKey = typing.Tuple[
23+
str,
24+
str,
25+
bool,
26+
typing.Any,
27+
]
28+
OverridesKey = typing.Tuple[OverrideKey, ...]
729

830

931
def map_environment_identity_to_context(
1032
environment: EnvironmentModel,
11-
identity: IdentityModel,
33+
identity: typing.Optional[IdentityModel],
1234
override_traits: typing.Optional[typing.List[TraitModel]],
1335
) -> EvaluationContext:
1436
"""
15-
Maps an EnvironmentModel and IdentityModel to an EvaluationContext.
37+
Map an EnvironmentModel and IdentityModel to an EvaluationContext.
1638
1739
:param environment: The environment model object.
1840
:param identity: The identity model object.
1941
:param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided.
2042
:return: An EvaluationContext containing the environment and identity.
2143
"""
44+
features = _map_feature_states_to_feature_contexts(environment.feature_states)
45+
segments: typing.Dict[str, SegmentContext] = {}
46+
for segment in environment.project.segments:
47+
segment_ctx_data: SegmentContext = {
48+
"key": str(segment.id),
49+
"name": segment.name,
50+
"rules": _map_segment_rules_to_segment_context_rules(segment.rules),
51+
}
52+
if segment_feature_states := segment.feature_states:
53+
segment_ctx_data["overrides"] = list(
54+
_map_feature_states_to_feature_contexts(segment_feature_states).values()
55+
)
56+
segments[str(segment.id)] = segment_ctx_data
57+
identity_overrides = environment.identity_overrides + [identity] if identity else []
58+
segments.update(_map_identity_overrides_to_segment_contexts(identity_overrides))
2259
return {
2360
"environment": {
2461
"key": environment.api_key,
2562
"name": environment.name or "",
2663
},
27-
"identity": {
28-
"identifier": identity.identifier,
29-
"key": str(identity.django_id or identity.composite_key),
30-
"traits": {
31-
trait.trait_key: trait.trait_value
32-
for trait in (
33-
override_traits
34-
if override_traits is not None
35-
else identity.identity_traits
36-
)
37-
},
38-
},
64+
"identity": (
65+
{
66+
"identifier": identity.identifier,
67+
"key": str(identity.django_id or identity.composite_key),
68+
"traits": {
69+
trait.trait_key: trait.trait_value
70+
for trait in (
71+
override_traits
72+
if override_traits is not None
73+
else identity.identity_traits
74+
)
75+
},
76+
}
77+
if identity
78+
else None
79+
),
80+
"features": features,
81+
"segments": segments,
3982
}
83+
84+
85+
def _map_identity_overrides_to_segment_contexts(
86+
identity_overrides: typing.List[IdentityModel],
87+
) -> typing.Dict[str, SegmentContext]:
88+
"""
89+
Map identity overrides to segment contexts.
90+
91+
:param identity_overrides: A list of IdentityModel objects.
92+
:return: A dictionary mapping segment ids to SegmentContext objects.
93+
"""
94+
features_to_identifiers: typing.Dict[
95+
OverridesKey,
96+
typing.List[str],
97+
] = defaultdict(list)
98+
for identity_override in identity_overrides:
99+
identity_features: typing.List[FeatureStateModel] = (
100+
identity_override.identity_features
101+
)
102+
if not identity_features:
103+
continue
104+
overrides_key = tuple(
105+
(
106+
str(feature_state.feature.id),
107+
feature_state.feature.name,
108+
feature_state.enabled,
109+
feature_state.feature_state_value,
110+
)
111+
for feature_state in sorted(identity_features, key=_get_name)
112+
)
113+
features_to_identifiers[overrides_key].append(identity_override.identifier)
114+
segment_contexts: typing.Dict[str, SegmentContext] = {}
115+
for overrides_key, identifiers in features_to_identifiers.items():
116+
# Create a segment context for each unique set of overrides
117+
# Generate a unique key to avoid collisions
118+
segment_key = str(hash(overrides_key))
119+
segment_contexts[segment_key] = SegmentContext(
120+
key="", # Identity override segments never use % Split operator
121+
name="identity_overrides",
122+
rules=[
123+
{
124+
"type": "ALL",
125+
"conditions": [
126+
{
127+
"property": "$.identity.identifier",
128+
"operator": "IN",
129+
"value": json.dumps(identifiers),
130+
}
131+
],
132+
}
133+
],
134+
overrides=[
135+
{
136+
"key": "", # Identity overrides never carry multivariate options
137+
"feature_key": feature_key,
138+
"name": feature_name,
139+
"enabled": feature_enabled,
140+
"value": feature_value,
141+
"priority": float("-inf"), # Highest possible priority
142+
}
143+
for feature_key, feature_name, feature_enabled, feature_value in overrides_key
144+
],
145+
)
146+
return segment_contexts
147+
148+
149+
def _map_feature_states_to_feature_contexts(
150+
feature_states: typing.List[FeatureStateModel],
151+
) -> typing.Dict[str, FeatureContext]:
152+
"""
153+
Map feature states to feature contexts.
154+
155+
:param feature_states: A list of FeatureStateModel objects.
156+
:return: A dictionary mapping feature names to their contexts.
157+
"""
158+
features: typing.Dict[str, FeatureContext] = {}
159+
for feature_state in feature_states:
160+
feature_context: FeatureContext = {
161+
"key": str(feature_state.django_id or feature_state.featurestate_uuid),
162+
"feature_key": str(feature_state.feature.id),
163+
"name": feature_state.feature.name,
164+
"enabled": feature_state.enabled,
165+
"value": feature_state.feature_state_value,
166+
}
167+
multivariate_feature_state_values: typing.List[
168+
MultivariateFeatureStateValueModel
169+
]
170+
if (
171+
multivariate_feature_state_values := feature_state.multivariate_feature_state_values
172+
):
173+
feature_context["variants"] = [
174+
{
175+
"value": multivariate_feature_state_value.multivariate_feature_option.value,
176+
"weight": multivariate_feature_state_value.percentage_allocation,
177+
}
178+
for multivariate_feature_state_value in sorted(
179+
multivariate_feature_state_values,
180+
key=_get_multivariate_feature_state_value_id,
181+
)
182+
]
183+
if feature_segment := feature_state.feature_segment:
184+
if (priority := feature_segment.priority) is not None:
185+
feature_context["priority"] = priority
186+
features[feature_state.feature.name] = feature_context
187+
return features
188+
189+
190+
def _map_segment_rules_to_segment_context_rules(
191+
rules: typing.List[SegmentRuleModel],
192+
) -> typing.List[SegmentRule]:
193+
"""
194+
Map segment rules to segment rules for the evaluation context.
195+
196+
:param rules: A list of SegmentRuleModel objects.
197+
:return: A list of SegmentRule objects.
198+
"""
199+
return [
200+
{
201+
"type": rule.type,
202+
"conditions": [
203+
{
204+
"property": condition.property_ or "",
205+
"operator": condition.operator,
206+
"value": condition.value or "",
207+
}
208+
for condition in rule.conditions
209+
],
210+
"rules": _map_segment_rules_to_segment_context_rules(rule.rules),
211+
}
212+
for rule in rules
213+
]
214+
215+
216+
def map_flag_results_to_feature_states(
217+
flag_results: typing.List[FlagResult],
218+
) -> typing.List[FeatureStateModel]:
219+
"""
220+
Map flag results to feature states.
221+
222+
:param flag_results: A list of FlagResult objects.
223+
:return: A list of FeatureStateModel objects.
224+
"""
225+
return [
226+
FeatureStateModel(
227+
feature=FeatureModel(
228+
id=flag_result["feature_key"],
229+
name=flag_result["name"],
230+
type=(
231+
"MULTIVARIATE"
232+
if flag_result.get("reason", "").startswith("SPLIT")
233+
else "STANDARD"
234+
),
235+
),
236+
enabled=flag_result["enabled"],
237+
feature_state_value=flag_result["value"],
238+
)
239+
for flag_result in flag_results
240+
]
241+
242+
243+
def _get_multivariate_feature_state_value_id(
244+
multivariate_feature_state_value: MultivariateFeatureStateValueModel,
245+
) -> int:
246+
return (
247+
multivariate_feature_state_value.id
248+
or multivariate_feature_state_value.mv_fs_value_uuid.int
249+
)
250+
251+
252+
def _get_name(feature_state: FeatureStateModel) -> str:
253+
return feature_state.feature.name

flag_engine/context/types.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,63 @@
11
# generated by datamodel-codegen:
2-
# filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/chore/update-evaluation-context/sdk/evaluation-context.json # noqa: E501
3-
# timestamp: 2025-07-16T10:39:10+00:00
2+
# filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/chore/features-contexts-in-eval-context-schema/sdk/evaluation-context.json # noqa: E501
3+
# timestamp: 2025-08-11T18:17:29+00:00
44

55
from __future__ import annotations
66

7-
from typing import Dict, Optional, TypedDict
7+
from typing import Any, Dict, List, Optional, TypedDict, Union
88

99
from typing_extensions import NotRequired
1010

11-
from flag_engine.identities.traits.types import ContextValue
12-
from flag_engine.utils.types import SupportsStr
11+
from flag_engine.segments.types import ConditionOperator, RuleType
1312

1413

1514
class EnvironmentContext(TypedDict):
1615
key: str
1716
name: str
1817

1918

19+
class FeatureValue(TypedDict):
20+
value: Any
21+
weight: float
22+
23+
2024
class IdentityContext(TypedDict):
2125
identifier: str
22-
key: SupportsStr
23-
traits: NotRequired[Dict[str, ContextValue]]
26+
key: str
27+
traits: NotRequired[Dict[str, Optional[Union[str, float, bool]]]]
28+
29+
30+
class SegmentCondition(TypedDict):
31+
property: NotRequired[str]
32+
operator: ConditionOperator
33+
value: str
34+
35+
36+
class SegmentRule(TypedDict):
37+
type: RuleType
38+
conditions: NotRequired[List[SegmentCondition]]
39+
rules: NotRequired[List[SegmentRule]]
40+
41+
42+
class FeatureContext(TypedDict):
43+
key: str
44+
feature_key: str
45+
name: str
46+
enabled: bool
47+
value: Any
48+
variants: NotRequired[List[FeatureValue]]
49+
priority: NotRequired[float]
50+
51+
52+
class SegmentContext(TypedDict):
53+
key: str
54+
name: str
55+
rules: List[SegmentRule]
56+
overrides: NotRequired[List[FeatureContext]]
2457

2558

2659
class EvaluationContext(TypedDict):
2760
environment: EnvironmentContext
2861
identity: NotRequired[Optional[IdentityContext]]
62+
segments: NotRequired[Dict[str, SegmentContext]]
63+
features: NotRequired[Dict[str, FeatureContext]]

0 commit comments

Comments
 (0)