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/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) 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