Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions flag_engine/context/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]]
177 changes: 175 additions & 2 deletions flag_engine/segments/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/engine_tests/engine-test-data
Submodule engine-test-data updated 21 files
+1 −1 schema.json
+61 −0 test_cases/test_flag_dependency__absent_prerequisite__should_not_override.jsonc
+79 −0 test_cases/test_flag_dependency__bracket_rooted_property__should_not_override.jsonc
+81 −0 test_cases/test_flag_dependency__bracketed_field__should_override.jsonc
+108 −0 test_cases/test_flag_dependency__competing_overrides__lowest_priority_wins.jsonc
+87 −0 test_cases/test_flag_dependency__context_supplied_flags__should_be_ignored.jsonc
+106 −0 test_cases/test_flag_dependency__cyclic__should_not_override.jsonc
+98 −0 test_cases/test_flag_dependency__multiple_overrides__each_feature_gets_its_own.jsonc
+98 −0 test_cases/test_flag_dependency__nested_rule__should_override.jsonc
+59 −0 test_cases/test_flag_dependency__no_overrides__segment_still_reported.jsonc
+73 −0 test_cases/test_flag_dependency__prerequisite_disabled__should_not_override.jsonc
+81 −0 test_cases/test_flag_dependency__prerequisite_enabled__should_override.jsonc
+79 −0 test_cases/test_flag_dependency__quoted_feature_name__should_override.jsonc
+86 −0 test_cases/test_flag_dependency__segment_metadata__reported_with_dependency.jsonc
+181 −0 test_cases/test_flag_dependency__shared_segment__resolved_once_per_feature.jsonc
+120 −0 test_cases/test_flag_dependency__transitive__should_cascade.jsonc
+112 −0 test_cases/test_flag_dependency__transitive_unmet__should_not_cascade.jsonc
+76 −0 test_cases/test_flag_dependency__value__should_override.jsonc
+93 −0 test_cases/test_flag_dependency__variant__should_override.jsonc
+79 −0 test_cases/test_flag_dependency__wildcard_property__should_not_override.jsonc
+57 −0 test_cases/test_top_level_any_rule__should_match.jsonc
Loading