Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[submodule "tests/engine_tests/engine-test-data"]
path = tests/engine_tests/engine-test-data
url = https://github.com/flagsmith/engine-test-data.git
branch = feat/context-values
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to delete.

Copy link
Member Author

@khvn26 khvn26 Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A discussion point as well as merging Flagsmith/engine-test-data#8 will break CI for all local eval SDKs. It's a good incentive for closing Flagsmith/flagsmith#5676 ASAP, of course, but perhaps we should consider versioning engine-test-data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean hard versions? e.g. v1/ and v2/, etc? I support temporary branch pointing at main — given a reasonable plan — unless there's value in the former.

Copy link
Member Author

@khvn26 khvn26 Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant using version tags in engine-test-data and referencing them instead of main branch in submodules.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Yes, I think it makes sense to version the test data.

I guess the original thinking behind not versioning it, is that the test data was to be used for more of a regression test, but I don't think that makes sense here, so I'm happy to go with a versioned approach.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created https://github.com/Flagsmith/engine-test-data/releases/tag/v1.0.0 for the current SDKs to point to 👍

Empty file.
38 changes: 38 additions & 0 deletions flag_engine/context/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import typing

from flag_engine.context.types import EvaluationContext
from flag_engine.environments.models import EnvironmentModel
from flag_engine.identities.models import IdentityModel
from flag_engine.identities.traits.models import TraitModel


def map_environment_identity_to_context(
environment: EnvironmentModel,
identity: IdentityModel,
override_traits: typing.Optional[typing.List[TraitModel]],
) -> EvaluationContext:
"""
Maps an EnvironmentModel and IdentityModel to an EvaluationContext.
:param environment: The environment model object.
:param identity: The identity model object.
:return: An EvaluationContext containing the environment and identity.
"""
return {
"environment": {
"key": environment.api_key,
"name": environment.name or "",
},
"identity": {
"identifier": identity.identifier,
"key": str(identity.django_id or identity.composite_key),
"traits": {
trait.trait_key: trait.trait_value
for trait in (
override_traits
if override_traits is not None
else identity.identity_traits
)
},
},
}
28 changes: 28 additions & 0 deletions flag_engine/context/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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

from __future__ import annotations

from typing import Dict, Optional, TypedDict

from typing_extensions import NotRequired

from flag_engine.identities.traits.types import ContextValue
from flag_engine.utils.types import SupportsStr


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


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


class EvaluationContext(TypedDict):
environment: EnvironmentContext
identity: NotRequired[Optional[IdentityContext]]
62 changes: 43 additions & 19 deletions flag_engine/engine.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import typing

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


Expand Down Expand Up @@ -53,9 +55,17 @@ def get_identity_feature_states(
:return: list of feature state models based on the environment, any matching
segments and any specific identity overrides
"""
context = map_environment_identity_to_context(
environment=environment,
identity=identity,
override_traits=override_traits,
)

feature_states = list(
_get_identity_feature_states_dict(
environment, identity, override_traits
environment=environment,
identity=identity,
context=context,
).values()
)
if environment.get_hide_disabled_flags():
Expand All @@ -79,8 +89,16 @@ def get_identity_feature_state(
:return: feature state model based on the environment, any matching
segments and any specific identity overrides
"""
context = map_environment_identity_to_context(
environment=environment,
identity=identity,
override_traits=override_traits,
)

feature_states = _get_identity_feature_states_dict(
environment, identity, override_traits
environment=environment,
identity=identity,
context=context,
)
matching_feature = next(
filter(lambda feature: feature.name == feature_name, feature_states.keys()),
Expand All @@ -96,29 +114,35 @@ def get_identity_feature_state(
def _get_identity_feature_states_dict(
environment: EnvironmentModel,
identity: IdentityModel,
override_traits: typing.Optional[typing.List[TraitModel]],
context: EvaluationContext,
) -> typing.Dict[FeatureModel, FeatureStateModel]:
# Get feature states from the environment
feature_states = {fs.feature: fs for fs in environment.feature_states}
feature_states_by_feature = {fs.feature: fs for fs in environment.feature_states}

# Override with any feature states defined by matching segments
identity_segments = get_identity_segments(environment, identity, override_traits)
for matching_segment in identity_segments:
for feature_state in matching_segment.feature_states:
if feature_state.feature in feature_states:
if feature_states[feature_state.feature].is_higher_segment_priority(
feature_state
):
continue
feature_states[feature_state.feature] = feature_state
for context_segment in get_context_segments(
context=context,
segments=environment.project.segments,
):
for segment_feature_state in context_segment.feature_states:
if (
environment_feature_state := feature_states_by_feature.get(
segment_feature := segment_feature_state.feature
)
) and environment_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.update(
feature_states_by_feature.update(
{
fs.feature: fs
for fs in identity.identity_features
if fs.feature in feature_states
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
return feature_states_by_feature
4 changes: 2 additions & 2 deletions flag_engine/identities/traits/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from pydantic import BaseModel, Field

from flag_engine.identities.traits.types import TraitValue
from flag_engine.identities.traits.types import ContextValue


class TraitModel(BaseModel):
trait_key: str
trait_value: TraitValue = Field(...)
trait_value: ContextValue = Field(...)
12 changes: 6 additions & 6 deletions flag_engine/identities/traits/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

from flag_engine.identities.traits.constants import TRAIT_STRING_VALUE_MAX_LENGTH

_UnconstrainedTraitValue = Union[None, int, float, bool, str]
_UnconstrainedContextValue = Union[None, int, float, bool, str]


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


def _map_string_value_to_trait_value(value: str) -> _UnconstrainedTraitValue:
def _map_string_value_to_trait_value(value: str) -> _UnconstrainedContextValue:
if _int_pattern.fullmatch(value):
return int(value)
if _float_pattern.fullmatch(value):
return float(value)
return value


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


TraitValue = Annotated[
ContextValue = Annotated[
Union[
None,
StrictBool,
Expand Down
Loading