Skip to content

Commit ab669bb

Browse files
linear-code[bot]emyllerclaude
authored
feat(Flag Dependency): Evaluate flags that depend on other flags (#267)
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> Co-authored-by: Evandro Myller <22429+emyller@users.noreply.github.com> Co-authored-by: Evandro Myller <evandro.myller@flagsmith.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4d5c27f commit ab669bb

12 files changed

Lines changed: 426 additions & 22 deletions

File tree

‎.github/workflows/pytest.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
uses: actions/checkout@v6
2121
with:
2222
fetch-depth: 0
23+
submodules: recursive
2324

2425
- name: Set up Python ${{ matrix.python-version }}
2526
uses: actions/setup-python@v5

‎.github/workflows/sdk-spec.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ permissions:
1111
jobs:
1212
sdk-spec:
1313
# Keep the ref in `uses:` and `spec-ref` in sync.
14-
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.6.0
14+
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
1515
with:
16-
spec-ref: v0.6.0
16+
spec-ref: v0.7.0
1717
language: python

‎.gitmodules‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
[submodule "tests/engine_tests/engine-test-data"]
2+
path = tests/engine_tests/engine-test-data
3+
url = https://github.com/Flagsmith/engine-test-data.git
14
[submodule "tests/data/sdk-benchmarks"]
25
path = tests/data/sdk-benchmarks
36
url = https://github.com/Flagsmith/sdk-benchmarks.git

‎flagsmith/flagsmith.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ def _get_identity_flags_from_document(
519519
return Flags.from_evaluation_context(
520520
context=context,
521521
overrides_index=overrides_index,
522+
flag_dependent_features=local_evaluation.flag_dependent_features,
522523
analytics_processor=self._analytics_processor,
523524
default_flag_handler=self.default_flag_handler,
524525
)

‎flagsmith/local_evaluation.py‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from functools import cached_property
66

77
from flag_engine import engine
8-
from flag_engine.context.types import SegmentContext
8+
from flag_engine.context.types import SegmentContext, SegmentRule
99

1010
from flagsmith.api.types import EnvironmentModel
1111
from flagsmith.mappers import (
@@ -17,6 +17,7 @@
1717
Flag,
1818
SegmentOverridesIndex,
1919
build_segment_overrides_index,
20+
find_flag_dependent_features,
2021
)
2122
from flagsmith.types import (
2223
FeatureMetadata,
@@ -25,6 +26,13 @@
2526
TraitMapping,
2627
)
2728

29+
IDENTITY_FREE_PROPERTY_PREFIXES = (
30+
"$.environment.",
31+
"$.environment[",
32+
"$.flags.",
33+
"$.flags[",
34+
)
35+
2836

2937
class LocalEvaluation:
3038
"""Per-environment-document state for local evaluation."""
@@ -40,6 +48,9 @@ def __init__(
4048
self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index(
4149
context
4250
)
51+
self.flag_dependent_features = find_flag_dependent_features(
52+
self.overrides_index
53+
)
4354
self._identity_overrides = {
4455
identifier: (segment_context, self._index_overrides(segment_context))
4556
for identifier, segment_context in (
@@ -63,11 +74,20 @@ def from_environment_document(
6374

6475
@cached_property
6576
def environment_flags(self) -> typing.Dict[str, Flag]:
66-
"""Environment flags, which depend on the environment document only."""
67-
context_without_segments = self.context.copy()
68-
context_without_segments.pop("segments", None)
77+
"""Environment flags, which depend on the environment document only.
78+
79+
Evaluated without an identity, so segments reading traits or identity
80+
context are left out.
81+
"""
82+
environment_context = self.context.copy()
83+
if segments := environment_context.get("segments"):
84+
environment_context["segments"] = {
85+
segment_key: segment_context
86+
for segment_key, segment_context in segments.items()
87+
if _is_identity_free(segment_context["rules"])
88+
}
6989
evaluation_result = engine.get_evaluation_result(
70-
context=context_without_segments,
90+
context=environment_context,
7191
)
7292
return {
7393
feature_name: Flag.from_evaluation_result(flag_result)
@@ -109,3 +129,14 @@ def _index_overrides(
109129
for override in segment_context.get("overrides") or ()
110130
if (feature_name := override["name"])
111131
}
132+
133+
134+
def _is_identity_free(rules: typing.Iterable[SegmentRule]) -> bool:
135+
return all(
136+
all(
137+
condition["property"].startswith(IDENTITY_FREE_PROPERTY_PREFIXES)
138+
for condition in rule.get("conditions") or ()
139+
)
140+
and _is_identity_free(rule.get("rules") or ())
141+
for rule in rules
142+
)

‎flagsmith/models.py‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, field
55

66
from flag_engine import engine
7-
from flag_engine.context.types import SegmentContext
7+
from flag_engine.context.types import SegmentContext, SegmentRule
88

99
from flagsmith.analytics import AnalyticsProcessor
1010
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
@@ -23,6 +23,8 @@
2323
]
2424
SegmentOverridesIndex = typing.MutableMapping[FeatureName, SegmentContexts]
2525

26+
FLAGS_JSONPATH_PREFIX = "$.flags"
27+
2628

2729
def build_segment_overrides_index(
2830
context: SDKEvaluationContext,
@@ -44,6 +46,35 @@ def build_segment_overrides_index(
4446
return index
4547

4648

49+
def find_flag_dependent_features(
50+
overrides_index: SegmentOverridesIndex,
51+
) -> typing.FrozenSet[FeatureName]:
52+
"""Features overridden by a segment with a condition on another flag.
53+
54+
Such a condition may read any flag in the environment, transitively,
55+
so these features can only be resolved against the full context.
56+
"""
57+
return frozenset(
58+
feature_name
59+
for feature_name, segment_contexts in overrides_index.items()
60+
if any(
61+
_rules_read_flags(segment_context["rules"])
62+
for segment_context in segment_contexts.values()
63+
)
64+
)
65+
66+
67+
def _rules_read_flags(rules: typing.Iterable[SegmentRule]) -> bool:
68+
return any(
69+
any(
70+
condition["property"].startswith(FLAGS_JSONPATH_PREFIX)
71+
for condition in rule.get("conditions") or ()
72+
)
73+
or _rules_read_flags(rule.get("rules") or ())
74+
for rule in rules
75+
)
76+
77+
4778
@dataclass(frozen=True)
4879
class ExperimentMetadata:
4980
"""The running experiment a flag was evaluated under; identity evaluations only."""
@@ -130,6 +161,7 @@ class Flags:
130161
# paths (`from_evaluation_result` / `from_api_flags`).
131162
_context: typing.Optional[SDKEvaluationContext] = None
132163
_overrides_index: typing.Optional[SegmentOverridesIndex] = None
164+
_flag_dependent_features: typing.AbstractSet[FeatureName] = frozenset()
133165
_fully_materialised: bool = False
134166

135167
@classmethod
@@ -154,6 +186,7 @@ def from_evaluation_context(
154186
cls,
155187
context: SDKEvaluationContext,
156188
overrides_index: SegmentOverridesIndex,
189+
flag_dependent_features: typing.AbstractSet[FeatureName],
157190
analytics_processor: typing.Optional[AnalyticsProcessor],
158191
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
159192
) -> Flags:
@@ -162,14 +195,17 @@ def from_evaluation_context(
162195
No engine work is done here — flags are resolved on first access
163196
via :meth:`_resolve_flag`. Reusing the same `overrides_index`
164197
across calls amortises its construction cost (it's rebuilt only
165-
when the environment doc refreshes, not per identity).
198+
when the environment doc refreshes, not per identity). The same
199+
goes for `flag_dependent_features`, see
200+
:func:`find_flag_dependent_features`.
166201
"""
167202
return cls(
168203
flags={},
169204
default_flag_handler=default_flag_handler,
170205
_analytics_processor=analytics_processor,
171206
_context=context,
172207
_overrides_index=overrides_index,
208+
_flag_dependent_features=flag_dependent_features,
173209
)
174210

175211
@classmethod
@@ -201,16 +237,19 @@ def all_flags(self) -> typing.List[Flag]:
201237
202238
:return: list of Flag objects.
203239
"""
204-
if self._context is not None and not self._fully_materialised:
205-
result = engine.get_evaluation_result(self._context)
206-
for feature_name, flag_result in result["flags"].items():
207-
if feature_name not in self.flags:
208-
self.flags[feature_name] = Flag.from_evaluation_result(
209-
flag_result,
210-
)
211-
self._fully_materialised = True
240+
self._materialise()
212241
return list(self.flags.values())
213242

243+
def _materialise(self) -> None:
244+
"""Resolve every feature in the lazy context, once."""
245+
if self._context is None or self._fully_materialised:
246+
return
247+
result = engine.get_evaluation_result(self._context)
248+
for feature_name, flag_result in result["flags"].items():
249+
if feature_name not in self.flags:
250+
self.flags[feature_name] = Flag.from_evaluation_result(flag_result)
251+
self._fully_materialised = True
252+
214253
def is_feature_enabled(self, feature_name: str) -> bool:
215254
"""
216255
Check whether a given feature is enabled.
@@ -276,13 +315,20 @@ def _resolve_flag(self, feature_name: str) -> Flag:
276315
that could override it, looked up in O(1) via the precomputed
277316
reverse index — so the engine's full pipeline runs against an
278317
input small enough to evaluate in ~1 µs.
318+
319+
Features overridden on other flags' values need the full context
320+
instead, so they are resolved along with every other feature.
279321
"""
280322
context = self._context
281323
overrides_index = self._overrides_index
282324
# `get_flag` / `all_flags` gate this call behind the same
283325
# non-None checks; assert here so type checkers can narrow.
284326
assert context is not None and overrides_index is not None
285327

328+
if feature_name in self._flag_dependent_features:
329+
self._materialise()
330+
return self.flags[feature_name]
331+
286332
trimmed: SDKEvaluationContext = {
287333
**context,
288334
"features": {feature_name: context["features"][feature_name]},

‎sdk-spec/steps.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ def create_client_with_events(
3939
)
4040

4141

42+
# Create the SDK client with local evaluation
43+
def create_client_with_local_evaluation(
44+
environment_key: str, api_url: str
45+
) -> Flagsmith:
46+
return Flagsmith(
47+
environment_key=environment_key,
48+
api_url=api_url,
49+
enable_local_evaluation=True,
50+
)
51+
52+
4253
# When the application gets the environment flags
4354
def get_environment_flags(flagsmith: Flagsmith) -> list[SpecFlag]:
4455
return as_spec_flags(flagsmith.get_environment_flags())

0 commit comments

Comments
 (0)