Skip to content

Commit baf774a

Browse files
committed
feat(v7): get_evaluation_result
1 parent bef422c commit baf774a

File tree

15 files changed

+661
-272
lines changed

15 files changed

+661
-272
lines changed

flag_engine/context/mappers.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import typing
22

3-
from flag_engine.context.types import EvaluationContext
3+
from flag_engine.context.types import (
4+
EvaluationContext,
5+
FeatureContext,
6+
SegmentContext,
7+
SegmentRule,
8+
)
49
from flag_engine.environments.models import EnvironmentModel
10+
from flag_engine.features.models import (
11+
FeatureModel,
12+
FeatureStateModel,
13+
MultivariateFeatureStateValueModel,
14+
)
515
from flag_engine.identities.models import IdentityModel
616
from flag_engine.identities.traits.models import TraitModel
17+
from flag_engine.result.types import FlagResult
18+
from flag_engine.segments.models import SegmentRuleModel
719

820

921
def map_environment_identity_to_context(
@@ -19,6 +31,76 @@ def map_environment_identity_to_context(
1931
:param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided.
2032
:return: An EvaluationContext containing the environment and identity.
2133
"""
34+
features = map_feature_states_to_feature_contexts(environment.feature_states)
35+
segments: typing.Dict[str, SegmentContext] = {}
36+
for segment in environment.project.segments:
37+
segment_ctx_data: SegmentContext = {
38+
"key": str(segment.id),
39+
"name": segment.name,
40+
"rules": map_segment_rules_to_segment_context_rules(segment.rules),
41+
}
42+
if segment_feature_states := segment.feature_states:
43+
segment_ctx_data["overrides"] = list(
44+
map_feature_states_to_feature_contexts(segment_feature_states).values()
45+
)
46+
segments[segment.name] = segment_ctx_data
47+
# Concatenate feature states overriden for identities
48+
# to segment contexts
49+
features_to_identifiers: typing.Dict[
50+
tuple[tuple[str, str, bool, typing.Any], ...], list[str]
51+
] = {}
52+
for identity_override in (*environment.identity_overrides, identity):
53+
identity_features: typing.List[FeatureStateModel] = (
54+
identity_override.identity_features
55+
)
56+
if not identity_features:
57+
continue
58+
overrides_key = tuple(
59+
(
60+
str(feature_state.feature.id),
61+
feature_state.feature.name,
62+
feature_state.enabled,
63+
feature_state.feature_state_value,
64+
)
65+
for feature_state in sorted(identity_features, key=_get_name)
66+
)
67+
features_to_identifiers.setdefault(overrides_key, []).append(
68+
identity_override.identifier
69+
)
70+
for overrides_key, identifiers in features_to_identifiers.items():
71+
segment_name = f"overrides_{abs(hash(overrides_key))}"
72+
segments[segment_name] = SegmentContext(
73+
key="", # Identity override segments never use % Split operator
74+
name=segment_name,
75+
rules=[
76+
{
77+
"type": "ALL",
78+
"rules": [
79+
{
80+
"type": "ALL",
81+
"conditions": [
82+
{
83+
"property": "$.identity.identifier",
84+
"operator": "IN",
85+
"value": ",".join(identifiers),
86+
}
87+
],
88+
}
89+
],
90+
}
91+
],
92+
overrides=[
93+
{
94+
"key": "", # Identity overrides never carry multivariate options
95+
"feature_key": feature_key,
96+
"name": feature_name,
97+
"enabled": feature_enabled,
98+
"value": feature_value,
99+
"priority": float("-inf"), # Highest possible priority
100+
}
101+
for feature_key, feature_name, feature_enabled, feature_value in overrides_key
102+
],
103+
)
22104
return {
23105
"environment": {
24106
"key": environment.api_key,
@@ -36,4 +118,95 @@ def map_environment_identity_to_context(
36118
)
37119
},
38120
},
121+
"features": features,
122+
"segments": segments,
39123
}
124+
125+
126+
def map_feature_states_to_feature_contexts(
127+
feature_states: typing.List[FeatureStateModel],
128+
*,
129+
priority: int | float | None = None,
130+
) -> typing.Dict[str, FeatureContext]:
131+
features: typing.Dict[str, FeatureContext] = {}
132+
for feature_state in feature_states:
133+
feature_ctx_data: FeatureContext = {
134+
"key": str(feature_state.django_id or feature_state.featurestate_uuid),
135+
"feature_key": str(feature_state.feature.id),
136+
"name": feature_state.feature.name,
137+
"enabled": feature_state.enabled,
138+
"value": feature_state.feature_state_value,
139+
}
140+
multivariate_feature_state_values: typing.List[
141+
MultivariateFeatureStateValueModel
142+
]
143+
if (
144+
multivariate_feature_state_values
145+
:= feature_state.multivariate_feature_state_values
146+
):
147+
feature_ctx_data["variants"] = [
148+
{
149+
"value": multivariate_feature_state_value.multivariate_feature_option.value,
150+
"weight": multivariate_feature_state_value.percentage_allocation,
151+
}
152+
for multivariate_feature_state_value in sorted(
153+
multivariate_feature_state_values,
154+
key=_get_multivariate_feature_state_value_id,
155+
)
156+
]
157+
if feature_segment := feature_state.feature_segment:
158+
priority = feature_segment.priority
159+
if priority is not None:
160+
feature_ctx_data["priority"] = priority
161+
features[feature_state.feature.name] = feature_ctx_data
162+
return features
163+
164+
165+
def map_segment_rules_to_segment_context_rules(
166+
rules: typing.List[SegmentRuleModel],
167+
) -> typing.List[SegmentRule]:
168+
return [
169+
{
170+
"type": rule.type,
171+
"conditions": [
172+
{
173+
"property": condition.property_ or "",
174+
"operator": condition.operator,
175+
"value": condition.value or "",
176+
}
177+
for condition in rule.conditions
178+
],
179+
"rules": map_segment_rules_to_segment_context_rules(rule.rules),
180+
}
181+
for rule in rules
182+
]
183+
184+
185+
def map_flag_results_to_feature_states(
186+
flag_results: typing.List[FlagResult],
187+
) -> typing.List[FeatureStateModel]:
188+
return [
189+
FeatureStateModel(
190+
feature=FeatureModel(
191+
id=flag_result["feature_key"],
192+
name=flag_result["name"],
193+
type="STANDARD",
194+
),
195+
enabled=flag_result["enabled"],
196+
feature_state_value=flag_result["value"],
197+
)
198+
for flag_result in flag_results
199+
]
200+
201+
202+
def _get_multivariate_feature_state_value_id(
203+
multivariate_feature_state_value: MultivariateFeatureStateValueModel,
204+
) -> int:
205+
return (
206+
multivariate_feature_state_value.id
207+
or multivariate_feature_state_value.mv_fs_value_uuid.int
208+
)
209+
210+
211+
def _get_name(feature_state: FeatureStateModel) -> str:
212+
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
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]]

flag_engine/engine.py

Lines changed: 21 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import typing
22

3-
from flag_engine.context.mappers import map_environment_identity_to_context
4-
from flag_engine.context.types import EvaluationContext
3+
from flag_engine.context.mappers import (
4+
map_environment_identity_to_context,
5+
map_flag_results_to_feature_states,
6+
)
57
from flag_engine.environments.models import EnvironmentModel
6-
from flag_engine.features.models import FeatureModel, FeatureStateModel
8+
from flag_engine.features.models import FeatureStateModel
79
from flag_engine.identities.models import IdentityModel
810
from flag_engine.identities.traits.models import TraitModel
9-
from flag_engine.segments.evaluator import get_context_segments
11+
from flag_engine.segments.evaluator import get_evaluation_result
1012
from flag_engine.utils.exceptions import FeatureStateNotFound
1113

1214

@@ -61,13 +63,10 @@ def get_identity_feature_states(
6163
override_traits=override_traits,
6264
)
6365

64-
feature_states = list(
65-
_get_identity_feature_states_dict(
66-
environment=environment,
67-
identity=identity,
68-
context=context,
69-
).values()
70-
)
66+
result = get_evaluation_result(context)
67+
68+
feature_states = map_flag_results_to_feature_states(result["flags"])
69+
7170
if environment.get_hide_disabled_flags():
7271
return [fs for fs in feature_states if fs.enabled]
7372
return feature_states
@@ -95,52 +94,19 @@ def get_identity_feature_state(
9594
override_traits=override_traits,
9695
)
9796

98-
feature_states = _get_identity_feature_states_dict(
99-
environment=environment,
100-
identity=identity,
101-
context=context,
102-
)
103-
matching_feature = next(
104-
filter(lambda feature: feature.name == feature_name, feature_states.keys()),
97+
result = get_evaluation_result(context)
98+
99+
feature_states = map_flag_results_to_feature_states(result["flags"])
100+
101+
matching_feature_state = next(
102+
filter(
103+
lambda feature_state: feature_state.feature.name == feature_name,
104+
feature_states,
105+
),
105106
None,
106107
)
107108

108-
if not matching_feature:
109+
if not matching_feature_state:
109110
raise FeatureStateNotFound()
110111

111-
return feature_states[matching_feature]
112-
113-
114-
def _get_identity_feature_states_dict(
115-
environment: EnvironmentModel,
116-
identity: IdentityModel,
117-
context: EvaluationContext,
118-
) -> typing.Dict[FeatureModel, FeatureStateModel]:
119-
# Get feature states from the environment
120-
feature_states_by_feature = {fs.feature: fs for fs in environment.feature_states}
121-
122-
# Override with any feature states defined by matching segments
123-
for context_segment in get_context_segments(
124-
context=context,
125-
segments=environment.project.segments,
126-
):
127-
for segment_feature_state in context_segment.feature_states:
128-
if (
129-
feature_state := feature_states_by_feature.get(
130-
segment_feature := segment_feature_state.feature
131-
)
132-
) and feature_state.is_higher_segment_priority(segment_feature_state):
133-
continue
134-
feature_states_by_feature[segment_feature] = segment_feature_state
135-
136-
# Override with any feature states defined directly the identity
137-
feature_states_by_feature.update(
138-
{
139-
identity_feature: identity_feature_state
140-
for identity_feature_state in identity.identity_features
141-
if (identity_feature := identity_feature_state.feature)
142-
in feature_states_by_feature
143-
}
144-
)
145-
146-
return feature_states_by_feature
112+
return matching_feature_state

flag_engine/result/__init__.py

Whitespace-only changes.

flag_engine/result/types.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# generated by datamodel-codegen:
2+
# filename: result.json
3+
# timestamp: 2025-08-11T11:47:46+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Any, List, Optional, TypedDict
8+
9+
from typing_extensions import NotRequired
10+
11+
from flag_engine.context.types import EvaluationContext
12+
13+
14+
class FlagResult(TypedDict):
15+
name: str
16+
feature_key: str
17+
enabled: bool
18+
value: NotRequired[Optional[Any]]
19+
reason: NotRequired[str]
20+
21+
22+
class SegmentResult(TypedDict):
23+
key: str
24+
name: str
25+
26+
27+
class EvaluationResult(TypedDict):
28+
context: EvaluationContext
29+
flags: List[FlagResult]
30+
segments: List[SegmentResult]

0 commit comments

Comments
 (0)