Skip to content
Merged
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
73 changes: 73 additions & 0 deletions src/flagpole/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -186,13 +191,81 @@ def _operator_match(self, condition_property: Any, segment_name: str):
)


def _glob_star_match(pattern: str, value: str) -> bool:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we don't use glob_match instead of this custom function?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Longer term, we need the same behavior in both the current python implementation (this change), and in sentry-options. Using a more fully feature glob implementation is ok if we can have the same behavior across the python/rust implementations.

Copy link
Copy Markdown
Member

@wedamija wedamija May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

glob_match implemented in rust in sentry-relay, so possibly there's a way we can share this logic between both systems at some point

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — longer term there's a path to aligning with relay's glob implementation if it ever becomes a shared crate. For now, moving forward with this hand-rolled star-only impl since it's easy to verify for cross-language parity and covers our use case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could work well. I'm hoping to not have the python implementation by the end of this year.

"""
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])
Comment thread
sentry[bot] marked this conversation as resolved.
# 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
Comment thread
cursor[bot] marked this conversation as resolved.


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,
ConditionOperatorKind.CONTAINS: ContainsCondition,
ConditionOperatorKind.NOT_CONTAINS: NotContainsCondition,
ConditionOperatorKind.EQUALS: EqualsCondition,
ConditionOperatorKind.NOT_EQUALS: NotEqualsCondition,
ConditionOperatorKind.MATCHES: MatchesCondition,
}


Expand Down
31 changes: 30 additions & 1 deletion src/flagpole/flagpole-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand All @@ -351,6 +377,9 @@
},
{
"$ref": "#/definitions/NotEqualsCondition"
},
{
"$ref": "#/definitions/MatchesCondition"
}
]
}
Expand Down
6 changes: 5 additions & 1 deletion src/sentry/runner/commands/createflag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions tests/flagpole/test_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ContainsCondition,
EqualsCondition,
InCondition,
MatchesCondition,
NotContainsCondition,
NotEqualsCondition,
NotInCondition,
Expand Down Expand Up @@ -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")
2 changes: 1 addition & 1 deletion tests/sentry/runner/commands/test_createflag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.

for c_idx in range(len(conditions_tuples)):
condition_tuple = conditions_tuples[c_idx]
Expand Down
Loading