From 300b23438a3fd5fee24969d4dd17109de00d5536 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 28 Aug 2026 18:17:21 +0100 Subject: [PATCH 1/2] feat: evaluate flags that depend on other flags Adds support for segments conditioned on another flag's result, via a `$.flags.` condition property, as the evaluation half of dependent flags. Segment conditions are already JSONPath, so a dependency needs no new operator or condition type. What it does need is for the flag to be resolved by the time the condition reads it, and evaluation resolved every segment before any flag. Rather than establish up front which segments depend on which flags, `$.flags` is a mapping that resolves a flag when a condition first reads it. Resolving a flag evaluates the segments overriding it, which in turn resolves whatever flags their conditions read, memoised throughout. The existing single pass is otherwise untouched: a context whose segments read no flag never enters the resolver, and pays only for one empty mapping. Resolving on read rather than in advance means there is no need to recognise a dependency in a condition property, and so no need to reimplement enough of the JSONPath grammar to tell that `$.flags.a['enabled']` and `$.flags.a.enabled` are the same query. Every spelling works because resolution is triggered by the read itself. It also costs only what is read: rule short-circuiting means a condition that is never evaluated resolves nothing. Measured against main across the benchmark contexts, interleaved to cancel drift: +3.4%, from the mapping and a shallow copy of the context to hold it. Establishing up front that an environment has no dependencies would remove that residual entirely, but needs a field on the context to carry it. A flag caught in a dependency cycle is left unresolved rather than raising, so a cycle reaching the engine degrades to a non-matching condition instead of breaking evaluation for the whole context. Such a flag serves its environment default and reports `ERROR; code=CIRCULAR_DEPENDENCY`, so that a flag which could not be resolved is distinguishable from one that was never gated. Only flags in the cycle are reported that way; a flag merely depending on one resolves normally. Cycles are still expected to be rejected where dependencies are written. Behaviour is covered by test cases in Flagsmith/engine-test-data#59 rather than by unit tests here, so that every engine is held to it. --- flag_engine/context/types.py | 2 + flag_engine/segments/evaluator.py | 177 +++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/flag_engine/context/types.py b/flag_engine/context/types.py index 328face..9e23dcc 100644 --- a/flag_engine/context/types.py +++ b/flag_engine/context/types.py @@ -8,6 +8,7 @@ from typing_extensions import NotRequired, TypedDict +from flag_engine.result.types import FlagResult from flag_engine.segments.types import ( ConditionOperator, ContextValue, @@ -79,3 +80,4 @@ class EvaluationContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]): identity: NotRequired[Optional[IdentityContext]] segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT, FeatureMetadataT]]] features: NotRequired[Dict[str, FeatureContext[FeatureMetadataT]]] + flags: NotRequired[Dict[str, FlagResult[FeatureMetadataT]]] diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index 149e02e..65480d8 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -6,7 +6,7 @@ import typing import warnings from contextlib import suppress -from functools import lru_cache, partial, wraps +from functools import cached_property, lru_cache, partial, wraps import jsonpath_rfc9535 import semver @@ -58,15 +58,52 @@ def get_evaluation_result( :return: EvaluationResult containing the context, flags, and segments """ context = get_enriched_context(context) + + resolved: _LazyFlags = _LazyFlags() + context = {**context, "flags": resolved} + resolved.bind(context) + segments, segment_overrides = evaluate_segments(context) flags = evaluate_features(context, segment_overrides) + if resolved: + # Only reached when a segment condition read a flag. Those results take + # precedence: they were resolved with the cycle guard, unlike the + # single-pass recomputation above. + flags.update(resolved) + return { "flags": flags, "segments": segments, } +class _LazyFlags(dict[str, FlagResult[typing.Any]]): + """ + The `$.flags` mapping, resolving a flag when a condition first reads it. + + A dict subclass rather than a Mapping, because the JSONPath implementation + only traverses real dicts; `__missing__` is what makes the read lazy. The + resolver is only ever entered by a read, so a context whose segments read + no flag never leaves the single-pass path. + """ + + _context: _EvaluationContextAnyMeta + + def bind(self, context: _EvaluationContextAnyMeta) -> None: + self._context = context + + @cached_property + def _resolver(self) -> _DependencyResolver[typing.Any, typing.Any]: + # Built on the first read of a flag, so a context whose segments read + # none never pays for it. + return _DependencyResolver(self._context, self) + + def __missing__(self, key: str) -> typing.Optional[FlagResult[typing.Any]]: + self._resolver.resolve_feature(key) + return self.get(key) + + def get_enriched_context( context: EvaluationContext[SegmentMetadataT, FeatureMetadataT], ) -> EvaluationContext[SegmentMetadataT, FeatureMetadataT]: @@ -162,6 +199,142 @@ def evaluate_features( return flags +# A condition property is only treated as a JSONPath query when it carries this +# prefix; anything else is a trait key. +_JSONPATH_PREFIX = "$." + +# Reported in place of `DEFAULT` for a flag that could not be resolved because +# its dependencies form a cycle, so that the flag serving its environment +# default is distinguishable from one that was never gated at all. +CIRCULAR_DEPENDENCY_REASON = "ERROR; code=CIRCULAR_DEPENDENCY" + + +class _DependencyResolver(typing.Generic[SegmentMetadataT, FeatureMetadataT]): + """ + Resolves flags and segment membership for a context with flag dependencies, + memoising both. + + A flag is resolved by first evaluating every segment that overrides it, + which in turn resolves any flag those segments are conditioned on. A flag + involved in a dependency cycle is left unresolved rather than raising, so + that a cycle degrades to a non-matching condition instead of breaking + evaluation for the whole context. Cycles are expected to be rejected when + dependencies are written, not here. + """ + + def __init__( + self, + context: EvaluationContext[SegmentMetadataT, FeatureMetadataT], + flags: dict[str, FlagResult[FeatureMetadataT]], + ) -> None: + self._context = context + self._flags = flags + self._cycle_hits = 0 + self._cyclic: set[str] = set() + self._segment_matches: dict[str, bool] = {} + self._resolving: list[str] = [] + # Feature name to the keys of the segments overriding it, in context + # order, so that override precedence doesn't depend on resolution order. + self._segment_keys_by_feature_name: dict[str, list[str]] = {} + for segment_key, segment_context in (context.get("segments") or {}).items(): + for override in segment_context.get("overrides") or (): + self._segment_keys_by_feature_name.setdefault( + override["name"], [] + ).append(segment_key) + + def resolve_feature(self, feature_name: str) -> None: + # Only ever reached from `_LazyFlags.__missing__`, so the flag is known + # not to be resolved yet; a second read of a resolved flag is a plain + # dict hit and never arrives here. + if feature_name in self._resolving: + # Cyclic dependency. Leave the flag unresolved so that the + # condition that led here sees no value, and so doesn't match. + # Every flag from the re-entered one upwards is part of the cycle, + # and is reported as such; anything below merely depends on it. + cycle_start = self._resolving.index(feature_name) + self._cyclic.update(self._resolving[cycle_start:]) + self._cycle_hits += 1 + return + if not ( + feature_context := (self._context.get("features") or {}).get(feature_name) + ): + # Depending on a feature absent from the context is not an error; + # it resolves to no value, as an unset property would. + return + + self._resolving.append(feature_name) + try: + segment_override = self._get_segment_override(feature_name) + finally: + # A `KeyError` raised under here is swallowed by the JSONPath + # implementation and evaluation carries on, so a name left on the + # stack would silently look like a cycle to a later read. + self._resolving.pop() + + if segment_override is not None: + segment_name = segment_override["segment_name"] + self._flags[feature_name] = get_flag_result_from_context( + context=self._context, + feature_context=segment_override["feature_context"], + reason=f"TARGETING_MATCH; segment={segment_name}", + ) + else: + self._flags[feature_name] = get_flag_result_from_context( + context=self._context, + feature_context=feature_context, + reason=( + CIRCULAR_DEPENDENCY_REASON + if feature_name in self._cyclic + else "DEFAULT" + ), + ) + + def matches_segment(self, segment_key: str) -> bool: + if (matches := self._segment_matches.get(segment_key)) is not None: + return matches + + cycle_hits = self._cycle_hits + matches = is_context_in_segment( + self._context, + (self._context.get("segments") or {})[segment_key], + ) + + if self._cycle_hits == cycle_hits: + # Only memoise a verdict reached without breaking a cycle. In a + # cycle a segment can be evaluated against an unresolved flag, and + # that verdict mustn't be reused afterwards. + self._segment_matches[segment_key] = matches + + return matches + + def _get_segment_override( + self, + feature_name: str, + ) -> typing.Optional[SegmentOverride[FeatureMetadataT]]: + segment_override: typing.Optional[SegmentOverride[FeatureMetadataT]] = None + override_priority = constants.DEFAULT_PRIORITY + + for segment_key in self._segment_keys_by_feature_name.get(feature_name) or (): + if not self.matches_segment(segment_key): + continue + segment_context = (self._context.get("segments") or {})[segment_key] + for override_feature_context in segment_context.get("overrides") or (): + if override_feature_context["name"] != feature_name: + continue + priority = override_feature_context.get( + "priority", + constants.DEFAULT_PRIORITY, + ) + if segment_override is None or priority < override_priority: + segment_override = SegmentOverride( + feature_context=override_feature_context, + segment_name=segment_context["name"], + ) + override_priority = priority + + return segment_override + + def get_flag_result_from_context( context: _EvaluationContextAnyMeta, feature_context: FeatureContext[FeatureMetadataT], @@ -321,7 +494,7 @@ def get_context_value( property: str, ) -> ContextValue: value = None - if property.startswith("$."): + if property.startswith(_JSONPATH_PREFIX): value = _get_context_value_getter(property)(context) else: value = _get_trait_value(context, property) From 63716d9824bcf7aba494eb2720da05d8c30bcaf8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 28 Aug 2026 18:17:26 +0100 Subject: [PATCH 2/2] chore: point engine-test-data at the flag dependencies branch REVERT BEFORE MERGE. Dependent flags behaviour is covered by test cases in Flagsmith/engine-test-data#59 rather than by unit tests here, so that every engine is held to it. Those cases aren't in a release yet, so without this the new code is exercised by nothing and CI's 100% coverage gate fails. Once #59 is merged and tagged, this goes back to a semver tag, which Renovate now tracks as of #337. --- .gitmodules | 2 +- tests/engine_tests/engine-test-data | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 606c611..8e1f9fd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +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 = v3.9.0 + branch = test/flag-dependencies diff --git a/tests/engine_tests/engine-test-data b/tests/engine_tests/engine-test-data index 5031065..30e30f2 160000 --- a/tests/engine_tests/engine-test-data +++ b/tests/engine_tests/engine-test-data @@ -1 +1 @@ -Subproject commit 5031065965d5ddbb499e1f5430657b80a609337c +Subproject commit 30e30f2b81b144c7e188dfa03228e3c574383476