Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "tests/engine_tests/engine-test-data"]
path = tests/engine_tests/engine-test-data
url = https://github.com/Flagsmith/engine-test-data.git
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
4 changes: 4 additions & 0 deletions flagsmith/local_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Flag,
SegmentOverridesIndex,
build_segment_overrides_index,
find_flag_dependent_features,
)
from flagsmith.types import (
FeatureMetadata,
Expand All @@ -40,6 +41,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 Down
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
158 changes: 158 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,161 @@ def identities_json() -> typing.Generator[str, None, None]:
def mocked_responses() -> Generator["responses.RequestsMock", None, None]:
with responses.RequestsMock() as rsps:
yield rsps


@pytest.fixture
def flag_dependency_context() -> SDKEvaluationContext:
"""Features whose segment overrides depend on other flags.

`dependent` is overridden when `prerequisite` is enabled, `transitive`
when `dependent` is enabled, and `cyclic_a` and `cyclic_b` each when
the other is enabled. Features are declared in reverse dependency order.
"""
return {
"environment": {"key": "env-key", "name": "env"},
"identity": {"identifier": "user-1", "key": "env-key_user-1"},
"features": {
"transitive": {
"key": "4",
"name": "transitive",
"enabled": False,
"value": "transitive-off",
"metadata": {"id": 4},
},
"dependent": {
"key": "3",
"name": "dependent",
"enabled": False,
"value": "dependent-off",
"metadata": {"id": 3},
},
"prerequisite": {
"key": "2",
"name": "prerequisite",
"enabled": True,
"value": None,
"metadata": {"id": 2},
},
"cyclic_a": {
"key": "5",
"name": "cyclic_a",
"enabled": False,
"value": None,
"metadata": {"id": 5},
},
"cyclic_b": {
"key": "6",
"name": "cyclic_b",
"enabled": False,
"value": None,
"metadata": {"id": 6},
},
},
"segments": {
"dependent_enabled": {
"key": "dependent_enabled",
"name": "dependent_enabled",
"rules": [
{
"type": "ALL",
"conditions": [
{
"property": "$.flags.dependent.enabled",
"operator": "EQUAL",
"value": "true",
},
],
}
],
"overrides": [
{
"key": "4",
"name": "transitive",
"enabled": True,
"value": "transitive-on",
"metadata": {"id": 4},
},
],
},
"prerequisite_enabled": {
"key": "prerequisite_enabled",
"name": "prerequisite_enabled",
"rules": [
{
"type": "ALL",
"rules": [
{
"type": "ANY",
"conditions": [
{
"property": "$.flags.prerequisite.enabled",
"operator": "EQUAL",
"value": "true",
},
],
}
],
}
],
"overrides": [
{
"key": "3",
"name": "dependent",
"enabled": True,
"value": "dependent-on",
"metadata": {"id": 3},
},
],
},
"cyclic_b_enabled": {
"key": "cyclic_b_enabled",
"name": "cyclic_b_enabled",
"rules": [
{
"type": "ALL",
"conditions": [
{
"property": "$.flags.cyclic_b.enabled",
"operator": "EQUAL",
"value": "true",
},
],
}
],
"overrides": [
{
"key": "5",
"name": "cyclic_a",
"enabled": True,
"value": None,
"metadata": {"id": 5},
},
],
},
"cyclic_a_enabled": {
"key": "cyclic_a_enabled",
"name": "cyclic_a_enabled",
"rules": [
{
"type": "ALL",
"conditions": [
{
"property": "$.flags.cyclic_a.enabled",
"operator": "EQUAL",
"value": "true",
},
],
}
],
"overrides": [
{
"key": "6",
"name": "cyclic_b",
"enabled": True,
"value": None,
"metadata": {"id": 6},
},
],
},
},
}
1 change: 1 addition & 0 deletions tests/engine_tests/engine-test-data
Submodule engine-test-data added at cb0674
Loading
Loading