diff --git a/src/flagpole/conditions.py b/src/flagpole/conditions.py index ac530ffbeb6d..f8cfef4f0337 100644 --- a/src/flagpole/conditions.py +++ b/src/flagpole/conditions.py @@ -25,6 +25,11 @@ class ConditionOperatorKind(str, Enum): NOT_EQUALS = "not_equals" """Compare a value to not be equal to another. Values are compared with types""" + MATCHES = "matches" + """ + Provided a list of patterns, check if the property value matches any pattern. + """ + class ConditionTypeMismatchException(Exception): pass @@ -186,6 +191,73 @@ def _operator_match(self, condition_property: Any, segment_name: str): ) +def _glob_star_match(pattern: str, value: str) -> bool: + """ + Match value against a star-only glob pattern (case-insensitive). + + '*' matches zero or more of any character. Every other character, + including '?' and '[', is treated as a literal for now. + """ + pattern = pattern.lower() + value = value.lower() + # Split on '*' to get the literal segments that must appear in order. + # e.g. "a*b*c" -> ["a", "b", "c"] + parts = pattern.split("*") + # No wildcard — require exact equality. + if len(parts) == 1: + return value == pattern + # parts[0] is the prefix anchor; value must start with it. + if not value.startswith(parts[0]): + return False + # parts[-1] is the suffix anchor; value must end with it (unless the + # pattern ends with '*', in which case parts[-1] is "" and any suffix matches). + if not value.endswith(parts[-1]) and parts[-1] != "": + return False + # Narrow the search window to exclude the already-matched prefix and suffix. + end = len(value) - len(parts[-1]) if parts[-1] else len(value) + start = len(parts[0]) + # The prefix and suffix anchors overlap, meaning the + # value is shorter than prefix + suffix combined — no valid match possible. + if start > end: + return False + # Walk the middle segments left-to-right, advancing the cursor after each hit + # so that relative ordering is preserved. + for part in parts[1:-1]: + if not part: + # Consecutive '*'s produce empty segments — nothing to match, skip. + continue + idx = value.find(part, start, end) + if idx == -1: + return False + start = idx + len(part) + return True + + +MatchesOperatorValueTypes = list[str] + + +class MatchesCondition(ConditionBase): + value: MatchesOperatorValueTypes + operator: str = dataclasses.field(default="matches") + + def _operator_match(self, condition_property: Any, segment_name: str) -> bool: + if not isinstance(self.value, list): + raise ConditionTypeMismatchException( + f"'Matches' condition value must be a list of strings, but was provided a" + f" '{get_type_name(self.value)}' of segment {segment_name}" + ) + if isinstance(condition_property, (list, dict)): + raise ConditionTypeMismatchException( + "'Matches' condition property value must be a string, but was provided a" + f" '{get_type_name(condition_property)}' of segment {segment_name}" + ) + if condition_property is None: + return False + if not isinstance(condition_property, str): + return False + return any(_glob_star_match(pattern, condition_property) for pattern in self.value) + + OPERATOR_LOOKUP: Mapping[ConditionOperatorKind, type[ConditionBase]] = { ConditionOperatorKind.IN: InCondition, ConditionOperatorKind.NOT_IN: NotInCondition, @@ -193,6 +265,7 @@ def _operator_match(self, condition_property: Any, segment_name: str): ConditionOperatorKind.NOT_CONTAINS: NotContainsCondition, ConditionOperatorKind.EQUALS: EqualsCondition, ConditionOperatorKind.NOT_EQUALS: NotEqualsCondition, + ConditionOperatorKind.MATCHES: MatchesCondition, } diff --git a/src/flagpole/flagpole-schema.json b/src/flagpole/flagpole-schema.json index 463b283fab9b..46290a1389f1 100644 --- a/src/flagpole/flagpole-schema.json +++ b/src/flagpole/flagpole-schema.json @@ -307,6 +307,31 @@ }, "required": ["property", "value"] }, + "MatchesCondition": { + "title": "MatchesCondition", + "type": "object", + "properties": { + "property": { + "title": "Property", + "description": "The evaluation context property to match against.", + "type": "string" + }, + "operator": { + "title": "Operator", + "default": "matches", + "enum": ["matches"], + "type": "string" + }, + "value": { + "title": "Value", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["property", "value"] + }, "Segment": { "title": "Segment", "type": "object", @@ -330,7 +355,8 @@ "contains": "#/definitions/ContainsCondition", "not_contains": "#/definitions/NotContainsCondition", "equals": "#/definitions/EqualsCondition", - "not_equals": "#/definitions/NotEqualsCondition" + "not_equals": "#/definitions/NotEqualsCondition", + "matches": "#/definitions/MatchesCondition" } }, "oneOf": [ @@ -351,6 +377,9 @@ }, { "$ref": "#/definitions/NotEqualsCondition" + }, + { + "$ref": "#/definitions/MatchesCondition" } ] } diff --git a/src/sentry/runner/commands/createflag.py b/src/sentry/runner/commands/createflag.py index d6e677e8a384..d9e3492471e8 100644 --- a/src/sentry/runner/commands/createflag.py +++ b/src/sentry/runner/commands/createflag.py @@ -39,7 +39,11 @@ def condition_wizard(display_sample_condition_properties: bool = False) -> Condi operator_kind = click.prompt("Operator type", type=condition_type_choices, show_choices=True) value: str | list[str] = "" - if operator_kind in {ConditionOperatorKind.IN, ConditionOperatorKind.NOT_IN}: + if operator_kind in { + ConditionOperatorKind.IN, + ConditionOperatorKind.NOT_IN, + ConditionOperatorKind.MATCHES, + }: value = [] condition = { "property": property_name, diff --git a/tests/flagpole/test_conditions.py b/tests/flagpole/test_conditions.py index 1c4b8331269d..e0cfea3dfd2a 100644 --- a/tests/flagpole/test_conditions.py +++ b/tests/flagpole/test_conditions.py @@ -6,6 +6,7 @@ ContainsCondition, EqualsCondition, InCondition, + MatchesCondition, NotContainsCondition, NotEqualsCondition, NotInCondition, @@ -198,3 +199,107 @@ def test_equality_type_mismatch_strings(self) -> None: not_condition = NotEqualsCondition(property="foo", value=values) with pytest.raises(ConditionTypeMismatchException): not_condition.match(context=EvaluationContext({"foo": "foo"}), segment_name="test") + + +class TestMatchesConditions: + def test_literal_match(self) -> None: + condition = MatchesCondition(property="slug", value=["sentry"]) + assert condition.match(context=EvaluationContext({"slug": "sentry"}), segment_name="test") + assert not condition.match( + context=EvaluationContext({"slug": "getsentry"}), segment_name="test" + ) + + def test_prefix_wildcard(self) -> None: + condition = MatchesCondition(property="slug", value=["jayonb*"]) + assert condition.match(context=EvaluationContext({"slug": "jayonb73"}), segment_name="test") + assert condition.match(context=EvaluationContext({"slug": "jayonb"}), segment_name="test") + assert not condition.match( + context=EvaluationContext({"slug": "dangoldonb1"}), segment_name="test" + ) + + def test_prefix_and_suffix_wildcard(self) -> None: + condition = MatchesCondition(property="email", value=["jay.goss+onboarding*@sentry.io"]) + assert condition.match( + context=EvaluationContext({"email": "jay.goss+onboarding70@sentry.io"}), + segment_name="test", + ) + assert condition.match( + context=EvaluationContext({"email": "jay.goss+onboarding@sentry.io"}), + segment_name="test", + ) + assert not condition.match( + context=EvaluationContext({"email": "jay.goss+onboarding70@example.com"}), + segment_name="test", + ) + + def test_suffix_wildcard(self) -> None: + condition = MatchesCondition(property="email", value=["*@sentry.io"]) + assert condition.match( + context=EvaluationContext({"email": "user@sentry.io"}), segment_name="test" + ) + assert not condition.match( + context=EvaluationContext({"email": "user@example.com"}), segment_name="test" + ) + + def test_multi_segment_wildcard(self) -> None: + condition = MatchesCondition(property="name", value=["a*b*c"]) + assert condition.match(context=EvaluationContext({"name": "abc"}), segment_name="test") + assert condition.match(context=EvaluationContext({"name": "aXbYc"}), segment_name="test") + assert condition.match(context=EvaluationContext({"name": "aXXbYYc"}), segment_name="test") + assert not condition.match(context=EvaluationContext({"name": "aXXc"}), segment_name="test") + + def test_star_only_pattern(self) -> None: + condition = MatchesCondition(property="slug", value=["*"]) + assert condition.match(context=EvaluationContext({"slug": "anything"}), segment_name="test") + assert condition.match(context=EvaluationContext({"slug": ""}), segment_name="test") + + def test_case_insensitive(self) -> None: + condition = MatchesCondition(property="slug", value=["JAYONB*"]) + assert condition.match(context=EvaluationContext({"slug": "jayonb73"}), segment_name="test") + condition2 = MatchesCondition(property="slug", value=["jayonb*"]) + assert condition2.match( + context=EvaluationContext({"slug": "JAYONB73"}), segment_name="test" + ) + + def test_no_match(self) -> None: + condition = MatchesCondition(property="slug", value=["jayonb*"]) + assert not condition.match( + context=EvaluationContext({"slug": "dangoldonb1"}), segment_name="test" + ) + + def test_multiple_patterns_first_match_wins(self) -> None: + condition = MatchesCondition( + property="slug", value=["jayonb*", "dangoldonb*", "value-disc-*"] + ) + assert condition.match(context=EvaluationContext({"slug": "jayonb73"}), segment_name="test") + assert condition.match( + context=EvaluationContext({"slug": "dangoldonb3"}), segment_name="test" + ) + assert condition.match( + context=EvaluationContext({"slug": "value-disc-7"}), segment_name="test" + ) + assert not condition.match( + context=EvaluationContext({"slug": "other-org"}), segment_name="test" + ) + + def test_overlapping_prefix_suffix_anchors(self) -> None: + # "a*a" requires at least "aa" — a single "a" must not match. + condition = MatchesCondition(property="slug", value=["a*a"]) + assert not condition.match(context=EvaluationContext({"slug": "a"}), segment_name="test") + assert condition.match(context=EvaluationContext({"slug": "aa"}), segment_name="test") + # "ab*ab" requires at least "abab" — "ab" alone must not match. + condition2 = MatchesCondition(property="slug", value=["ab*ab"]) + assert not condition2.match(context=EvaluationContext({"slug": "ab"}), segment_name="test") + assert condition2.match(context=EvaluationContext({"slug": "abab"}), segment_name="test") + + def test_type_mismatch_list_property(self) -> None: + condition = MatchesCondition(property="foo", value=["bar*"]) + with pytest.raises(ConditionTypeMismatchException): + condition.match( + context=EvaluationContext({"foo": ["bar1", "bar2"]}), segment_name="test" + ) + + def test_type_mismatch_dict_property(self) -> None: + condition = MatchesCondition(property="foo", value=["bar*"]) + with pytest.raises(ConditionTypeMismatchException): + condition.match(context=EvaluationContext({"foo": {"key": "val"}}), segment_name="test") diff --git a/tests/sentry/runner/commands/test_createflag.py b/tests/sentry/runner/commands/test_createflag.py index 02f99ea5d90a..16d8ed6b07f8 100644 --- a/tests/sentry/runner/commands/test_createflag.py +++ b/tests/sentry/runner/commands/test_createflag.py @@ -83,7 +83,7 @@ def test_all_condition_types(self) -> None: assert new_segment.name == "New segment" assert new_segment.rollout == 100 - assert len(new_segment.conditions) == 6 + assert len(new_segment.conditions) == 7 for c_idx in range(len(conditions_tuples)): condition_tuple = conditions_tuples[c_idx]