From e5ec11d1fffaf7830ae9c3bcbdc2cf4b294ce1c6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 19 Oct 2026 11:19:41 +0200 Subject: [PATCH] perf: Match IN conditions against lists without building sets A list IN condition value was collected into a set of strings on every evaluation, as lists cannot be cached. It is now scanned instead, which is about 2x faster at any length. Results are unchanged, and are now covered by a test. --- flag_engine/segments/evaluator.py | 26 +++++++++---- .../unit/segments/test_segments_evaluator.py | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index f4edd98e..628f795f 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -515,11 +515,12 @@ def context_matches_condition( context_value = get_context_value(context, condition_property) if condition_operator == constants.IN: - in_values = _get_in_values(condition["value"]) # Guard against comparing boolean values to numeric strings. if type(context_value) is int: context_value = str(context_value) - return context_value in in_values + if isinstance(segment_value := condition["value"], list): + return _is_in_values_list(context_value, segment_value) + return context_value in _parse_in_values_str(segment_value) condition = typing.cast(StrValueSegmentCondition, condition) @@ -586,12 +587,21 @@ def _parse_in_values_str(segment_value: str) -> frozenset[str]: return frozenset(segment_value.split(",")) -def _get_in_values( - segment_value: typing.Union[str, list[typing.Any]], -) -> frozenset[str]: - if isinstance(segment_value, list): - return frozenset(v if type(v) is str else str(v) for v in segment_value) - return _parse_in_values_str(segment_value) +def _is_in_values_list( + context_value: ContextValue, + segment_value: list[typing.Any], +) -> bool: + """ + Whether a context value is among a list IN condition value's values, as + strings. Scanned rather than collected into a set: lists can't be cached, + and collecting them on every evaluation costs more than scanning. + """ + if not isinstance(context_value, str): + return False + for value in segment_value: + if (value if type(value) is str else str(value)) == context_value: + return True + return False def _evaluate_not_contains( diff --git a/tests/unit/segments/test_segments_evaluator.py b/tests/unit/segments/test_segments_evaluator.py index 7564509e..f2cc85b8 100644 --- a/tests/unit/segments/test_segments_evaluator.py +++ b/tests/unit/segments/test_segments_evaluator.py @@ -1049,3 +1049,40 @@ def __missing__(self, key: str) -> None: # Then assert result is None + + +@pytest.mark.parametrize( + "trait_value, segment_value, expected_result", + [ + ("foo", ["foo", "bar"], True), + ("baz", ["foo", "bar"], False), + ("2", [1, 2, 3], True), + (2, [1, 2, 3], True), + (2, ["1", "2", "3"], True), + (5, [1, 2, 3], False), + (1.5, ["1.5"], False), + (1.5, [1.5], False), + (True, ["True", "true"], False), + (False, ["False", "false"], False), + ("foo", [], False), + ], +) +def test_context_matches_condition__in_list__returns_expected( + trait_value: typing.Any, + segment_value: list[typing.Any], + expected_result: bool, +) -> None: + # Given + context = typing.cast( + EvaluationContext, {"identity": {"traits": {"trait": trait_value}}} + ) + condition = typing.cast( + SegmentCondition, + {"operator": constants.IN, "property": "trait", "value": segment_value}, + ) + + # When + result = context_matches_condition(context, condition, "segment_key") + + # Then + assert result is expected_result