Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
69d156b
feat(v7): `get_evaluation_result`
khvn26 Aug 12, 2025
4364c6e
formatting
khvn26 Aug 12, 2025
5b607e8
fix coverage
khvn26 Aug 12, 2025
ab93e2a
make it importable
khvn26 Aug 12, 2025
a6c6874
formatting
khvn26 Aug 12, 2025
a670098
typing/coverage
khvn26 Aug 12, 2025
0dfd025
formatting
khvn26 Aug 12, 2025
ce13b3c
typing
khvn26 Aug 12, 2025
d5f633f
correct typing
khvn26 Aug 12, 2025
2c27a70
t y p i n g
khvn26 Aug 12, 2025
1db9872
moar typing
khvn26 Aug 12, 2025
33260a6
☠️ github
khvn26 Aug 12, 2025
15ec795
huh?
khvn26 Aug 12, 2025
395e9a6
fix coverage
khvn26 Aug 12, 2025
aaeb7f1
deprecate stuff
khvn26 Aug 12, 2025
2356f36
add test
khvn26 Aug 12, 2025
669567e
efficient loop
khvn26 Aug 12, 2025
3dafd51
more efficient loop
khvn26 Aug 12, 2025
25ad1b9
even more efficient loop
khvn26 Aug 12, 2025
aa3bdc6
improve typing, use defaultdict
khvn26 Aug 13, 2025
41e2cc3
improve docstrings
khvn26 Aug 13, 2025
381f6da
formatting
khvn26 Aug 13, 2025
be25c51
fix docstring
khvn26 Aug 14, 2025
aa9fb37
support null identity
khvn26 Aug 14, 2025
9aac8b5
scope, typing
khvn26 Aug 14, 2025
dc7faa3
fmt
khvn26 Aug 14, 2025
f970b11
fix
khvn26 Aug 15, 2025
6cdb8b6
annotate weight for `SPLIT` reasons
khvn26 Aug 15, 2025
266e2a3
account for non-required priority
khvn26 Aug 15, 2025
bc147a5
depreception
khvn26 Aug 15, 2025
46450b7
factor out override mapper, use segment ids as keys
khvn26 Aug 15, 2025
5f675eb
fix test
khvn26 Aug 15, 2025
9c79122
waste less cpu cycles
khvn26 Aug 15, 2025
a4b83f1
formatting
khvn26 Aug 15, 2025
3daf2d4
fix coverage
khvn26 Aug 15, 2025
4dba31a
restore a bit of functionality
khvn26 Aug 15, 2025
a3ffc24
fmt
khvn26 Aug 15, 2025
4ff0251
improve naming
khvn26 Aug 15, 2025
f45e0a2
improve readability
khvn26 Aug 15, 2025
7fb2748
use segment name for identity overrides
khvn26 Aug 15, 2025
fb31c9c
support JSON-encoded `IN` condition values
khvn26 Aug 15, 2025
1d42bf6
compare against lowest possible priority
khvn26 Aug 18, 2025
a519cf9
clarify segment key
khvn26 Aug 18, 2025
273a1da
fmt
khvn26 Aug 18, 2025
d1f6414
mypy not respecting notrequired >:(
khvn26 Aug 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 173 additions & 1 deletion flag_engine/context/mappers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import typing

from flag_engine.context.types import EvaluationContext
from flag_engine.context.types import (
EvaluationContext,
FeatureContext,
SegmentContext,
SegmentRule,
)
from flag_engine.environments.models import EnvironmentModel
from flag_engine.features.models import (
FeatureModel,
FeatureStateModel,
MultivariateFeatureStateValueModel,
)
from flag_engine.identities.models import IdentityModel
from flag_engine.identities.traits.models import TraitModel
from flag_engine.result.types import FlagResult
from flag_engine.segments.models import SegmentRuleModel


def map_environment_identity_to_context(
Expand All @@ -19,6 +31,76 @@ def map_environment_identity_to_context(
:param override_traits: A list of TraitModel objects, to be used in place of `identity.identity_traits` if provided.
:return: An EvaluationContext containing the environment and identity.
"""
features = map_feature_states_to_feature_contexts(environment.feature_states)
segments: typing.Dict[str, SegmentContext] = {}
for segment in environment.project.segments:
segment_ctx_data: SegmentContext = {
"key": str(segment.id),
"name": segment.name,
"rules": map_segment_rules_to_segment_context_rules(segment.rules),
}
if segment_feature_states := segment.feature_states:
segment_ctx_data["overrides"] = list(
map_feature_states_to_feature_contexts(segment_feature_states).values()
)
segments[segment.name] = segment_ctx_data
# Concatenate feature states overriden for identities
# to segment contexts
features_to_identifiers: typing.Dict[
tuple[tuple[str, str, bool, typing.Any], ...], list[str]
] = {}
for identity_override in (*environment.identity_overrides, identity):
identity_features: typing.List[FeatureStateModel] = (
identity_override.identity_features
)
if not identity_features:
continue
overrides_key = tuple(
(
str(feature_state.feature.id),
feature_state.feature.name,
feature_state.enabled,
feature_state.feature_state_value,
)
for feature_state in sorted(identity_features, key=_get_name)
)
features_to_identifiers.setdefault(overrides_key, []).append(
identity_override.identifier
)
for overrides_key, identifiers in features_to_identifiers.items():
segment_name = f"overrides_{abs(hash(overrides_key))}"
segments[segment_name] = SegmentContext(
key="", # Identity override segments never use % Split operator
name=segment_name,
rules=[
{
"type": "ALL",
"rules": [
{
"type": "ALL",
"conditions": [
{
"property": "$.identity.identifier",
"operator": "IN",
"value": ",".join(identifiers),
}
],
}
],
}
],
overrides=[
{
"key": "", # Identity overrides never carry multivariate options
"feature_key": feature_key,
"name": feature_name,
"enabled": feature_enabled,
"value": feature_value,
"priority": float("-inf"), # Highest possible priority
}
for feature_key, feature_name, feature_enabled, feature_value in overrides_key
],
)
return {
"environment": {
"key": environment.api_key,
Expand All @@ -36,4 +118,94 @@ def map_environment_identity_to_context(
)
},
},
"features": features,
"segments": segments,
}


def map_feature_states_to_feature_contexts(
feature_states: typing.List[FeatureStateModel],
*,
priority: int | float | None = None,
) -> typing.Dict[str, FeatureContext]:
features: typing.Dict[str, FeatureContext] = {}
for feature_state in feature_states:
feature_ctx_data: FeatureContext = {
"key": str(feature_state.django_id or feature_state.featurestate_uuid),
"feature_key": str(feature_state.feature.id),
"name": feature_state.feature.name,
"enabled": feature_state.enabled,
"value": feature_state.feature_state_value,
}
multivariate_feature_state_values: typing.List[
MultivariateFeatureStateValueModel
]
if (
multivariate_feature_state_values := feature_state.multivariate_feature_state_values
):
feature_ctx_data["variants"] = [
{
"value": multivariate_feature_state_value.multivariate_feature_option.value,
"weight": multivariate_feature_state_value.percentage_allocation,
}
for multivariate_feature_state_value in sorted(
multivariate_feature_state_values,
key=_get_multivariate_feature_state_value_id,
)
]
if feature_segment := feature_state.feature_segment:
priority = feature_segment.priority
if priority is not None:
feature_ctx_data["priority"] = priority
features[feature_state.feature.name] = feature_ctx_data
return features


def map_segment_rules_to_segment_context_rules(
rules: typing.List[SegmentRuleModel],
) -> typing.List[SegmentRule]:
return [
{
"type": rule.type,
"conditions": [
{
"property": condition.property_ or "",
"operator": condition.operator,
"value": condition.value or "",
}
for condition in rule.conditions
],
"rules": map_segment_rules_to_segment_context_rules(rule.rules),
}
for rule in rules
]


def map_flag_results_to_feature_states(
flag_results: typing.List[FlagResult],
) -> typing.List[FeatureStateModel]:
return [
FeatureStateModel(
feature=FeatureModel(
id=flag_result["feature_key"],
name=flag_result["name"],
type="STANDARD",
),
enabled=flag_result["enabled"],
feature_state_value=flag_result["value"],
)
for flag_result in flag_results
]


def _get_multivariate_feature_state_value_id(
multivariate_feature_state_value: MultivariateFeatureStateValueModel,
) -> int:
return (
multivariate_feature_state_value.id
or multivariate_feature_state_value.mv_fs_value_uuid.int
)


def _get_name(feature_state: FeatureStateModel) -> str:
return feature_state.feature.name
49 changes: 42 additions & 7 deletions flag_engine/context/types.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,63 @@
# generated by datamodel-codegen:
# filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/chore/update-evaluation-context/sdk/evaluation-context.json # noqa: E501
# timestamp: 2025-07-16T10:39:10+00:00
# filename: https://raw.githubusercontent.com/Flagsmith/flagsmith/chore/features-contexts-in-eval-context-schema/sdk/evaluation-context.json # noqa: E501
# timestamp: 2025-08-11T18:17:29+00:00

from __future__ import annotations

from typing import Dict, Optional, TypedDict
from typing import Any, Dict, List, Optional, TypedDict, Union

from typing_extensions import NotRequired

from flag_engine.identities.traits.types import ContextValue
from flag_engine.utils.types import SupportsStr
from flag_engine.segments.types import ConditionOperator, RuleType


class EnvironmentContext(TypedDict):
key: str
name: str


class FeatureValue(TypedDict):
value: Any
weight: float


class IdentityContext(TypedDict):
identifier: str
key: SupportsStr
traits: NotRequired[Dict[str, ContextValue]]
key: str
traits: NotRequired[Dict[str, Optional[Union[str, float, bool]]]]


class SegmentCondition(TypedDict):
property: NotRequired[str]
operator: ConditionOperator
value: str


class SegmentRule(TypedDict):
type: RuleType
conditions: NotRequired[List[SegmentCondition]]
rules: NotRequired[List[SegmentRule]]


class FeatureContext(TypedDict):
key: str
feature_key: str
name: str
enabled: bool
value: Any
variants: NotRequired[List[FeatureValue]]
priority: NotRequired[float]


class SegmentContext(TypedDict):
key: str
name: str
rules: List[SegmentRule]
overrides: NotRequired[List[FeatureContext]]


class EvaluationContext(TypedDict):
environment: EnvironmentContext
identity: NotRequired[Optional[IdentityContext]]
segments: NotRequired[Dict[str, SegmentContext]]
features: NotRequired[Dict[str, FeatureContext]]
76 changes: 21 additions & 55 deletions flag_engine/engine.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import typing

from flag_engine.context.mappers import map_environment_identity_to_context
from flag_engine.context.types import EvaluationContext
from flag_engine.context.mappers import (
map_environment_identity_to_context,
map_flag_results_to_feature_states,
)
from flag_engine.environments.models import EnvironmentModel
from flag_engine.features.models import FeatureModel, FeatureStateModel
from flag_engine.features.models import FeatureStateModel
from flag_engine.identities.models import IdentityModel
from flag_engine.identities.traits.models import TraitModel
from flag_engine.segments.evaluator import get_context_segments
from flag_engine.segments.evaluator import get_evaluation_result
from flag_engine.utils.exceptions import FeatureStateNotFound


Expand Down Expand Up @@ -61,13 +63,10 @@ def get_identity_feature_states(
override_traits=override_traits,
)

feature_states = list(
_get_identity_feature_states_dict(
environment=environment,
identity=identity,
context=context,
).values()
)
result = get_evaluation_result(context)

feature_states = map_flag_results_to_feature_states(result["flags"])

if environment.get_hide_disabled_flags():
return [fs for fs in feature_states if fs.enabled]
return feature_states
Expand Down Expand Up @@ -95,52 +94,19 @@ def get_identity_feature_state(
override_traits=override_traits,
)

feature_states = _get_identity_feature_states_dict(
environment=environment,
identity=identity,
context=context,
)
matching_feature = next(
filter(lambda feature: feature.name == feature_name, feature_states.keys()),
result = get_evaluation_result(context)

feature_states = map_flag_results_to_feature_states(result["flags"])

matching_feature_state = next(
filter(
lambda feature_state: feature_state.feature.name == feature_name,
feature_states,
),
None,
)

if not matching_feature:
if not matching_feature_state:
raise FeatureStateNotFound()

return feature_states[matching_feature]


def _get_identity_feature_states_dict(
environment: EnvironmentModel,
identity: IdentityModel,
context: EvaluationContext,
) -> typing.Dict[FeatureModel, FeatureStateModel]:
# Get feature states from the environment
feature_states_by_feature = {fs.feature: fs for fs in environment.feature_states}

# Override with any feature states defined by matching segments
for context_segment in get_context_segments(
context=context,
segments=environment.project.segments,
):
for segment_feature_state in context_segment.feature_states:
if (
feature_state := feature_states_by_feature.get(
segment_feature := segment_feature_state.feature
)
) and feature_state.is_higher_segment_priority(segment_feature_state):
continue
feature_states_by_feature[segment_feature] = segment_feature_state

# Override with any feature states defined directly the identity
feature_states_by_feature.update(
{
identity_feature: identity_feature_state
for identity_feature_state in identity.identity_features
if (identity_feature := identity_feature_state.feature)
in feature_states_by_feature
}
)

return feature_states_by_feature
return matching_feature_state
Loading
Loading