Skip to content

Commit 01e7a56

Browse files
authored
feat: Context values for Segments (#220)
1 parent 088b864 commit 01e7a56

File tree

15 files changed

+586
-227
lines changed

15 files changed

+586
-227
lines changed

.gitmodules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +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+
branch = feat/context-values

flag_engine/context/__init__.py

Whitespace-only changes.

flag_engine/context/mappers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import typing
2+
3+
from flag_engine.context.types import EvaluationContext
4+
from flag_engine.environments.models import EnvironmentModel
5+
from flag_engine.identities.models import IdentityModel
6+
from flag_engine.identities.traits.models import TraitModel
7+
8+
9+
def map_environment_identity_to_context(
10+
environment: EnvironmentModel,
11+
identity: IdentityModel,
12+
override_traits: typing.Optional[typing.List[TraitModel]],
13+
) -> EvaluationContext:
14+
"""
15+
Maps an EnvironmentModel and IdentityModel to an EvaluationContext.
16+
17+
:param environment: The environment model object.
18+
:param identity: The identity model object.
19+
:param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided.
20+
:return: An EvaluationContext containing the environment and identity.
21+
"""
22+
return {
23+
"environment": {
24+
"key": environment.api_key,
25+
"name": environment.name or "",
26+
},
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+
},
39+
}

flag_engine/context/types.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 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
4+
5+
from __future__ import annotations
6+
7+
from typing import Dict, Optional, TypedDict
8+
9+
from typing_extensions import NotRequired
10+
11+
from flag_engine.identities.traits.types import ContextValue
12+
from flag_engine.utils.types import SupportsStr
13+
14+
15+
class EnvironmentContext(TypedDict):
16+
key: str
17+
name: str
18+
19+
20+
class IdentityContext(TypedDict):
21+
identifier: str
22+
key: SupportsStr
23+
traits: NotRequired[Dict[str, ContextValue]]
24+
25+
26+
class EvaluationContext(TypedDict):
27+
environment: EnvironmentContext
28+
identity: NotRequired[Optional[IdentityContext]]

flag_engine/engine.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import typing
22

3+
from flag_engine.context.mappers import map_environment_identity_to_context
4+
from flag_engine.context.types import EvaluationContext
35
from flag_engine.environments.models import EnvironmentModel
46
from flag_engine.features.models import FeatureModel, FeatureStateModel
57
from flag_engine.identities.models import IdentityModel
68
from flag_engine.identities.traits.models import TraitModel
7-
from flag_engine.segments.evaluator import get_identity_segments
9+
from flag_engine.segments.evaluator import get_context_segments
810
from flag_engine.utils.exceptions import FeatureStateNotFound
911

1012

@@ -53,9 +55,17 @@ def get_identity_feature_states(
5355
:return: list of feature state models based on the environment, any matching
5456
segments and any specific identity overrides
5557
"""
58+
context = map_environment_identity_to_context(
59+
environment=environment,
60+
identity=identity,
61+
override_traits=override_traits,
62+
)
63+
5664
feature_states = list(
5765
_get_identity_feature_states_dict(
58-
environment, identity, override_traits
66+
environment=environment,
67+
identity=identity,
68+
context=context,
5969
).values()
6070
)
6171
if environment.get_hide_disabled_flags():
@@ -79,8 +89,16 @@ def get_identity_feature_state(
7989
:return: feature state model based on the environment, any matching
8090
segments and any specific identity overrides
8191
"""
92+
context = map_environment_identity_to_context(
93+
environment=environment,
94+
identity=identity,
95+
override_traits=override_traits,
96+
)
97+
8298
feature_states = _get_identity_feature_states_dict(
83-
environment, identity, override_traits
99+
environment=environment,
100+
identity=identity,
101+
context=context,
84102
)
85103
matching_feature = next(
86104
filter(lambda feature: feature.name == feature_name, feature_states.keys()),
@@ -96,29 +114,33 @@ def get_identity_feature_state(
96114
def _get_identity_feature_states_dict(
97115
environment: EnvironmentModel,
98116
identity: IdentityModel,
99-
override_traits: typing.Optional[typing.List[TraitModel]],
117+
context: EvaluationContext,
100118
) -> typing.Dict[FeatureModel, FeatureStateModel]:
101119
# Get feature states from the environment
102-
feature_states = {fs.feature: fs for fs in environment.feature_states}
120+
feature_states_by_feature = {fs.feature: fs for fs in environment.feature_states}
103121

104122
# Override with any feature states defined by matching segments
105-
identity_segments = get_identity_segments(environment, identity, override_traits)
106-
for matching_segment in identity_segments:
107-
for feature_state in matching_segment.feature_states:
108-
if feature_state.feature in feature_states:
109-
if feature_states[feature_state.feature].is_higher_segment_priority(
110-
feature_state
111-
):
112-
continue
113-
feature_states[feature_state.feature] = feature_state
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
114135

115136
# Override with any feature states defined directly the identity
116-
feature_states.update(
137+
feature_states_by_feature.update(
117138
{
118-
fs.feature: fs
119-
for fs in identity.identity_features
120-
if fs.feature in feature_states
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
121143
}
122144
)
123145

124-
return feature_states
146+
return feature_states_by_feature
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from pydantic import BaseModel, Field
22

3-
from flag_engine.identities.traits.types import TraitValue
3+
from flag_engine.identities.traits.types import ContextValue
44

55

66
class TraitModel(BaseModel):
77
trait_key: str
8-
trait_value: TraitValue = Field(...)
8+
trait_value: ContextValue = Field(...)

flag_engine/identities/traits/types.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
from flag_engine.identities.traits.constants import TRAIT_STRING_VALUE_MAX_LENGTH
1010

11-
_UnconstrainedTraitValue = Union[None, int, float, bool, str]
11+
_UnconstrainedContextValue = Union[None, int, float, bool, str]
1212

1313

14-
def map_any_value_to_trait_value(value: Any) -> _UnconstrainedTraitValue:
14+
def map_any_value_to_trait_value(value: Any) -> _UnconstrainedContextValue:
1515
"""
1616
Try to coerce a value of arbitrary type to a trait value type.
1717
Union member-specific constraints, such as max string value length, are ignored here.
@@ -36,19 +36,19 @@ def map_any_value_to_trait_value(value: Any) -> _UnconstrainedTraitValue:
3636
_float_pattern = re.compile(r"-?[0-9]+\.[0-9]+")
3737

3838

39-
def _map_string_value_to_trait_value(value: str) -> _UnconstrainedTraitValue:
39+
def _map_string_value_to_trait_value(value: str) -> _UnconstrainedContextValue:
4040
if _int_pattern.fullmatch(value):
4141
return int(value)
4242
if _float_pattern.fullmatch(value):
4343
return float(value)
4444
return value
4545

4646

47-
def _is_trait_value(value: Any) -> TypeGuard[_UnconstrainedTraitValue]:
48-
return isinstance(value, get_args(_UnconstrainedTraitValue))
47+
def _is_trait_value(value: Any) -> TypeGuard[_UnconstrainedContextValue]:
48+
return isinstance(value, get_args(_UnconstrainedContextValue))
4949

5050

51-
TraitValue = Annotated[
51+
ContextValue = Annotated[
5252
Union[
5353
None,
5454
StrictBool,

0 commit comments

Comments
 (0)