diff --git a/contract-tests/service.py b/contract-tests/service.py index 888dfaf..6ef7790 100644 --- a/contract-tests/service.py +++ b/contract-tests/service.py @@ -77,7 +77,8 @@ def status(): 'inline-context', 'anonymous-redaction', 'evaluation-hooks', - 'omit-anonymous-contexts' + 'omit-anonymous-contexts', + 'client-prereq-events' ] } return (json.dumps(body), 200, {'Content-type': 'application/json'}) diff --git a/ldclient/client.py b/ldclient/client.py index 1925a62..1ce6bd4 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -558,7 +558,8 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: if client_only and not flag.get('clientSide', False): continue try: - detail = self._evaluator.evaluate(flag, context, self._event_factory_default).detail + result = self._evaluator.evaluate(flag, context, self._event_factory_default) + detail = result.detail except Exception as e: log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e))) log.debug(traceback.format_exc()) @@ -572,6 +573,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState: 'variation': detail.variation_index, 'reason': detail.reason, 'version': flag['version'], + 'prerequisites': result.prerequisites, 'trackEvents': flag.get('trackEvents', False) or requires_experiment_data, 'trackReason': requires_experiment_data, 'debugEventsUntilDate': flag.get('debugEventsUntilDate', None), diff --git a/ldclient/evaluation.py b/ldclient/evaluation.py index 6bc786c..cdece76 100644 --- a/ldclient/evaluation.py +++ b/ldclient/evaluation.py @@ -52,7 +52,7 @@ def reason(self) -> dict: * ``errorKind``: further describes the nature of the error if the kind was ``ERROR``, e.g. ``"FLAG_NOT_FOUND"`` - + * ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if the flag evaluation required querying at least one Big Segment; otherwise it returns None. Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the @@ -65,7 +65,7 @@ def is_default_value(self) -> bool: variations. """ return self.__variation_index is None - + def __eq__(self, other) -> bool: return self.value == other.value and self.variation_index == other.variation_index and self.reason == other.reason @@ -141,6 +141,8 @@ def add_flag(self, flag_state, with_reasons, details_only_if_tracked): if not omit_details: meta['version'] = flag_state['version'] + if 'prerequisites' in flag_state and len(flag_state['prerequisites']) > 0: + meta['prerequisites'] = flag_state['prerequisites'] if flag_state['variation'] is not None: meta['variation'] = flag_state['variation'] if trackEvents: diff --git a/ldclient/impl/evaluator.py b/ldclient/impl/evaluator.py index 0ceed11..223918b 100644 --- a/ldclient/impl/evaluator.py +++ b/ldclient/impl/evaluator.py @@ -24,7 +24,7 @@ # ended up having to do for the context. class EvalResult: __slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership', - 'original_flag_key', 'prereq_stack', 'segment_stack'] + 'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites'] def __init__(self): self.detail = None @@ -34,6 +34,12 @@ def __init__(self): self.original_flag_key = None # type: Optional[str] self.prereq_stack = None # type: Optional[List[str]] self.segment_stack = None # type: Optional[List[str]] + self.depth = 0 + self.prerequisites = [] # type: List[str] + + def record_prerequisite(self, key: str): + if self.depth == 0: + self.prerequisites.append(key) def add_event(self, event: EventInputEvaluation): if self.events is None: @@ -48,7 +54,7 @@ class EvaluationException(Exception): def __init__(self, message: str, error_kind: str = 'MALFORMED_FLAG'): self._message = message self._error_kind = error_kind - + @property def message(self) -> str: return self._message @@ -125,7 +131,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR prereq_res = None if flag.prerequisites.count == 0: return None - + try: # We use the state object to guard against circular references in prerequisites. To avoid # the overhead of creating the state.prereq_stack list in the most common case where @@ -136,7 +142,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR if state.prereq_stack is None: state.prereq_stack = [] state.prereq_stack.append(flag_key) - + for prereq in flag.prerequisites: prereq_key = prereq.key if (prereq_key == state.original_flag_key or @@ -145,11 +151,15 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR ' this is probably a temporary condition due to an incomplete update') % prereq_key) prereq_flag = self.__get_flag(prereq_key) + state.record_prerequisite(prereq_key) + if prereq_flag is None: log.warning("Missing prereq flag: " + prereq_key) failed_prereq = prereq else: + state.depth += 1 prereq_res = self._evaluate(prereq_flag, context, state, event_factory) + state.depth -= 1 # Note that if the prerequisite flag is off, we don't consider it a match no matter what its # off variation was. But we still need to evaluate it in order to generate an event. if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation: @@ -208,7 +218,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR if segment is not None and self._segment_matches_context(segment, context, state): return _maybe_negate(clause, True) return _maybe_negate(clause, False) - + attr = clause.attribute if attr is None: return False @@ -220,7 +230,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR context_value = _get_context_value_by_attr_ref(actual_context, attr) if context_value is None: return False - + # is the attr an array? if isinstance(context_value, (list, tuple)): for v in context_value: @@ -287,7 +297,7 @@ def _big_segment_match_context(self, segment: Segment, context: Context, state: # that as a "not configured" condition. state.big_segments_status = BigSegmentsStatus.NOT_CONFIGURED return False - + # A big segment can only apply to one context kind, so if we don't have a key for that kind, # we don't need to bother querying the data. match_context = context.get_individual_context(segment.unbounded_context_kind or Context.DEFAULT_KIND) @@ -357,7 +367,7 @@ def _variation_index_for_context(flag: FeatureFlag, vr: VariationOrRollout, cont variations = rollout.variations if len(variations) == 0: return (None, False) - + bucket_by = None if rollout.is_experiment else rollout.bucket_by bucket = _bucket_context( rollout.seed, diff --git a/ldclient/testing/test_ldclient_evaluation.py b/ldclient/testing/test_ldclient_evaluation.py index 04fcf97..b3d7eea 100644 --- a/ldclient/testing/test_ldclient_evaluation.py +++ b/ldclient/testing/test_ldclient_evaluation.py @@ -217,6 +217,86 @@ def test_all_flags_state_returns_state(): '$valid': True } + +def test_all_flags_state_only_includes_top_level_prereqs(): + store = InMemoryFeatureStore() + store.init( + { + FEATURES: { + 'top-level-has-prereqs-1': { + 'key': 'top-level-has-prereqs-1', + 'version': 100, + 'on': True, + 'fallthrough': {'variation': 0}, + 'variations': ['value'], + 'prerequisites': [ + {'key': 'prereq1', 'variation': 0}, + {'key': 'prereq2', 'variation': 0} + ], + }, + 'top-level-has-prereqs-2': { + 'key': 'top-level-has-prereqs-2', + 'version': 100, + 'on': True, + 'fallthrough': {'variation': 0}, + 'variations': ['value'], + 'prerequisites': [ + {'key': 'prereq3', 'variation': 0} + ], + }, + 'prereq1': { + 'key': 'prereq1', + 'version': 200, + 'on': True, + 'fallthrough': {'variation': 0}, + 'variations': ['value'], + }, + 'prereq2': { + 'key': 'prereq2', + 'version': 200, + 'on': True, + 'fallthrough': {'variation': 0}, + 'variations': ['value'], + }, + 'prereq3': { + 'key': 'prereq3', + 'version': 200, + 'on': True, + 'fallthrough': {'variation': 0}, + 'variations': ['value'], + }, + } + } + ) + client = make_client(store) + state = client.all_flags_state(user) + assert state.valid + result = state.to_json_dict() + assert result == { + 'top-level-has-prereqs-1': 'value', + 'top-level-has-prereqs-2': 'value', + 'prereq1': 'value', + 'prereq2': 'value', + 'prereq3': 'value', + '$flagsState': { + 'top-level-has-prereqs-1': { + 'variation': 0, + 'version': 100, + 'prerequisites': ['prereq1', 'prereq2'] + }, + 'top-level-has-prereqs-2': { + 'variation': 0, + 'version': 100, + 'prerequisites': ['prereq3'] + }, + 'prereq1': {'variation': 0, 'version': 200}, + 'prereq2': {'variation': 0, 'version': 200}, + 'prereq3': {'variation': 0, 'version': 200}, + }, + '$valid': True + } + + def test_all_flags_state_returns_state_with_reasons(): store = InMemoryFeatureStore() store.init({ FEATURES: { 'key1': flag1, 'key2': flag2 } })