Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.6.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.6.0
spec-ref: v0.7.0
language: python
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "tests/engine_tests/engine-test-data"]
path = tests/engine_tests/engine-test-data
url = https://github.com/Flagsmith/engine-test-data.git
[submodule "tests/data/sdk-benchmarks"]
path = tests/data/sdk-benchmarks
url = https://github.com/Flagsmith/sdk-benchmarks.git
Expand Down
1 change: 1 addition & 0 deletions flagsmith/flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ def _get_identity_flags_from_document(
return Flags.from_evaluation_context(
context=context,
overrides_index=overrides_index,
flag_dependent_features=local_evaluation.flag_dependent_features,
analytics_processor=self._analytics_processor,
default_flag_handler=self.default_flag_handler,
)
Expand Down
41 changes: 36 additions & 5 deletions flagsmith/local_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from functools import cached_property

from flag_engine import engine
from flag_engine.context.types import SegmentContext
from flag_engine.context.types import SegmentContext, SegmentRule

from flagsmith.api.types import EnvironmentModel
from flagsmith.mappers import (
Expand All @@ -17,6 +17,7 @@
Flag,
SegmentOverridesIndex,
build_segment_overrides_index,
find_flag_dependent_features,
)
from flagsmith.types import (
FeatureMetadata,
Expand All @@ -25,6 +26,13 @@
TraitMapping,
)

IDENTITY_FREE_PROPERTY_PREFIXES = (
"$.environment.",
"$.environment[",
"$.flags.",
"$.flags[",
)


class LocalEvaluation:
"""Per-environment-document state for local evaluation."""
Expand All @@ -40,6 +48,9 @@ def __init__(
self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index(
context
)
self.flag_dependent_features = find_flag_dependent_features(
self.overrides_index
)
self._identity_overrides = {
identifier: (segment_context, self._index_overrides(segment_context))
for identifier, segment_context in (
Expand All @@ -63,11 +74,20 @@ def from_environment_document(

@cached_property
def environment_flags(self) -> typing.Dict[str, Flag]:
"""Environment flags, which depend on the environment document only."""
context_without_segments = self.context.copy()
context_without_segments.pop("segments", None)
"""Environment flags, which depend on the environment document only.

Evaluated without an identity, so segments reading traits or identity
context are left out.
"""
environment_context = self.context.copy()
if segments := environment_context.get("segments"):
environment_context["segments"] = {
segment_key: segment_context
for segment_key, segment_context in segments.items()
if _is_identity_free(segment_context["rules"])
}
evaluation_result = engine.get_evaluation_result(
context=context_without_segments,
context=environment_context,
)
return {
feature_name: Flag.from_evaluation_result(flag_result)
Expand Down Expand Up @@ -109,3 +129,14 @@ def _index_overrides(
for override in segment_context.get("overrides") or ()
if (feature_name := override["name"])
}


def _is_identity_free(rules: typing.Iterable[SegmentRule]) -> bool:
return all(
all(
condition["property"].startswith(IDENTITY_FREE_PROPERTY_PREFIXES)
for condition in rule.get("conditions") or ()
)
and _is_identity_free(rule.get("rules") or ())
for rule in rules
)
66 changes: 56 additions & 10 deletions flagsmith/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass, field

from flag_engine import engine
from flag_engine.context.types import SegmentContext
from flag_engine.context.types import SegmentContext, SegmentRule

from flagsmith.analytics import AnalyticsProcessor
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
Expand All @@ -23,6 +23,8 @@
]
SegmentOverridesIndex = typing.MutableMapping[FeatureName, SegmentContexts]

FLAGS_JSONPATH_PREFIX = "$.flags"


def build_segment_overrides_index(
context: SDKEvaluationContext,
Expand All @@ -44,6 +46,35 @@ def build_segment_overrides_index(
return index


def find_flag_dependent_features(
overrides_index: SegmentOverridesIndex,
) -> typing.FrozenSet[FeatureName]:
"""Features overridden by a segment with a condition on another flag.

Such a condition may read any flag in the environment, transitively,
so these features can only be resolved against the full context.
"""
return frozenset(
feature_name
for feature_name, segment_contexts in overrides_index.items()
if any(
_rules_read_flags(segment_context["rules"])
for segment_context in segment_contexts.values()
)
)


def _rules_read_flags(rules: typing.Iterable[SegmentRule]) -> bool:
return any(
any(
condition["property"].startswith(FLAGS_JSONPATH_PREFIX)
for condition in rule.get("conditions") or ()
)
or _rules_read_flags(rule.get("rules") or ())
for rule in rules
)


@dataclass(frozen=True)
class ExperimentMetadata:
"""The running experiment a flag was evaluated under; identity evaluations only."""
Expand Down Expand Up @@ -130,6 +161,7 @@ class Flags:
# paths (`from_evaluation_result` / `from_api_flags`).
_context: typing.Optional[SDKEvaluationContext] = None
_overrides_index: typing.Optional[SegmentOverridesIndex] = None
_flag_dependent_features: typing.AbstractSet[FeatureName] = frozenset()
_fully_materialised: bool = False

@classmethod
Expand All @@ -154,6 +186,7 @@ def from_evaluation_context(
cls,
context: SDKEvaluationContext,
overrides_index: SegmentOverridesIndex,
flag_dependent_features: typing.AbstractSet[FeatureName],
analytics_processor: typing.Optional[AnalyticsProcessor],
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
) -> Flags:
Expand All @@ -162,14 +195,17 @@ def from_evaluation_context(
No engine work is done here — flags are resolved on first access
via :meth:`_resolve_flag`. Reusing the same `overrides_index`
across calls amortises its construction cost (it's rebuilt only
when the environment doc refreshes, not per identity).
when the environment doc refreshes, not per identity). The same
goes for `flag_dependent_features`, see
:func:`find_flag_dependent_features`.
"""
return cls(
flags={},
default_flag_handler=default_flag_handler,
_analytics_processor=analytics_processor,
_context=context,
_overrides_index=overrides_index,
_flag_dependent_features=flag_dependent_features,
)

@classmethod
Expand Down Expand Up @@ -201,16 +237,19 @@ def all_flags(self) -> typing.List[Flag]:

:return: list of Flag objects.
"""
if self._context is not None and not self._fully_materialised:
result = engine.get_evaluation_result(self._context)
for feature_name, flag_result in result["flags"].items():
if feature_name not in self.flags:
self.flags[feature_name] = Flag.from_evaluation_result(
flag_result,
)
self._fully_materialised = True
self._materialise()
return list(self.flags.values())

def _materialise(self) -> None:
"""Resolve every feature in the lazy context, once."""
if self._context is None or self._fully_materialised:
return
result = engine.get_evaluation_result(self._context)
for feature_name, flag_result in result["flags"].items():
if feature_name not in self.flags:
self.flags[feature_name] = Flag.from_evaluation_result(flag_result)
self._fully_materialised = True

def is_feature_enabled(self, feature_name: str) -> bool:
"""
Check whether a given feature is enabled.
Expand Down Expand Up @@ -276,13 +315,20 @@ def _resolve_flag(self, feature_name: str) -> Flag:
that could override it, looked up in O(1) via the precomputed
reverse index — so the engine's full pipeline runs against an
input small enough to evaluate in ~1 µs.

Features overridden on other flags' values need the full context
instead, so they are resolved along with every other feature.
"""
context = self._context
overrides_index = self._overrides_index
# `get_flag` / `all_flags` gate this call behind the same
# non-None checks; assert here so type checkers can narrow.
assert context is not None and overrides_index is not None

if feature_name in self._flag_dependent_features:
self._materialise()
return self.flags[feature_name]

trimmed: SDKEvaluationContext = {
**context,
"features": {feature_name: context["features"][feature_name]},
Expand Down
11 changes: 11 additions & 0 deletions sdk-spec/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ def create_client_with_events(
)


# Create the SDK client with local evaluation
def create_client_with_local_evaluation(
environment_key: str, api_url: str
) -> Flagsmith:
return Flagsmith(
environment_key=environment_key,
api_url=api_url,
enable_local_evaluation=True,
)


# When the application gets the environment flags
def get_environment_flags(flagsmith: Flagsmith) -> list[SpecFlag]:
return as_spec_flags(flagsmith.get_environment_flags())
Expand Down
Loading
Loading