diff --git a/.sampo/changesets/starts-with-ends-with-operators.md b/.sampo/changesets/starts-with-ends-with-operators.md new file mode 100644 index 000000000..806f9c440 --- /dev/null +++ b/.sampo/changesets/starts-with-ends-with-operators.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`, so flags using these operators no longer fall back to remote evaluation. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 8d95ced3f..87fbbb6f6 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -58,7 +58,16 @@ class ConditionMatch(Enum): # All operators supported by match_property, grouped by category. EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set") -STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex") +STRING_OPERATORS = ( + "icontains", + "not_icontains", + "regex", + "not_regex", + "starts_with", + "not_starts_with", + "ends_with", + "not_ends_with", +) NUMERIC_OPERATORS = ("gt", "gte", "lt", "lte") DATE_OPERATORS = ("is_date_before", "is_date_after") SEMVER_COMPARISON_OPERATORS = ( @@ -492,6 +501,12 @@ def is_condition_match( return ConditionMatch.MATCH +# Raised when an operator passes the PROPERTY_OPERATORS gate but has no dispatch +# branch in match_property. Distinct from the unknown-operator rejection at the top +# of the function so the dispatch-completeness test can tell the two apart. +_UNHANDLED_OPERATOR_MESSAGE = "has no match_property branch" + + def match_property(property, property_values) -> bool: # only looks for matches where key exists in override_property_values # doesn't support operator is_not_set @@ -538,6 +553,18 @@ def compute_exact_match(value, override_value): if operator == "not_icontains": return not utils.str_icontains(override_value, value) + if operator == "starts_with": + return utils.str_istartswith(override_value, value) + + if operator == "not_starts_with": + return not utils.str_istartswith(override_value, value) + + if operator == "ends_with": + return utils.str_iendswith(override_value, value) + + if operator == "not_ends_with": + return not utils.str_iendswith(override_value, value) + if operator == "regex": return ( is_valid_regex(str(value)) @@ -680,7 +707,7 @@ def compare(lhs, rhs, operator): # Unreachable: all operators in PROPERTY_OPERATORS are handled above, # and unknown operators are rejected at the top of this function. - raise InconclusiveMatchError(f"Unknown operator {operator}") + raise InconclusiveMatchError(f"Operator {operator} {_UNHANDLED_OPERATOR_MESSAGE}") def match_cohort( diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 497069efd..ddb2efbeb 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -12,6 +12,8 @@ from posthog.client import Client import posthog.feature_flags from posthog.feature_flags import ( + PROPERTY_OPERATORS, + _UNHANDLED_OPERATOR_MESSAGE, InconclusiveMatchError, match_property, parse_datetime, @@ -4659,6 +4661,18 @@ def property(self, key, value, operator=None): return result + def test_every_supported_operator_has_a_dispatch_branch(self): + # PROPERTY_OPERATORS gates local evaluation, but match_property dispatches + # on a hand-written if-chain. An operator listed in the tuple but missing a + # branch falls through to the "unreachable" raise at the end of the function, + # silently pushing the flag back to remote evaluation. + for operator in PROPERTY_OPERATORS: + prop = self.property(key="key", value="1.0.0", operator=operator) + try: + match_property(prop, {"key": "1.0.0"}) + except InconclusiveMatchError as error: + self.assertNotIn(_UNHANDLED_OPERATOR_MESSAGE, str(error), operator) + def test_match_properties_exact(self): property_a = self.property(key="key", value="value") @@ -4741,6 +4755,45 @@ def test_match_properties_icontains(self): self.assertFalse(match_property(property_b, {"key": "three"})) + @parameterized.expand( + [ + ( + "starts_with", + "Val", + ["value", "VALUE", "vaLue4"], + ["prevalue", "Alakazam", 123], + ), + ("starts_with", "3", ["3", 323], [123, "val3"]), + ( + "ends_with", + "lUe", + ["value", "VALUE", "343tfvalue"], + ["value2", "Alakazam", 123], + ), + ("ends_with", "3", ["3", 323, 13], [321, "3val"]), + ] + ) + def test_match_properties_starts_with_and_ends_with( + self, operator, flag_value, matching, non_matching + ): + prop = self.property(key="key", value=flag_value, operator=operator) + for value in matching: + self.assertTrue(match_property(prop, {"key": value}), value) + for value in non_matching: + self.assertFalse(match_property(prop, {"key": value}), value) + + # For non-None values, the negated operator is the exact inverse. + negated = self.property(key="key", value=flag_value, operator=f"not_{operator}") + for value in matching: + self.assertFalse(match_property(negated, {"key": value}), value) + for value in non_matching: + self.assertTrue(match_property(negated, {"key": value}), value) + + # A missing key is inconclusive rather than a non-match. + for missing_properties in ({"other_key": "value"}, {}): + with self.assertRaises(InconclusiveMatchError): + match_property(prop, missing_properties) + def test_match_properties_regex(self): property_a = self.property(key="key", value=r"\.com$", operator="regex") self.assertTrue(match_property(property_a, {"key": "value.com"})) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 834e6ac40..781460a9b 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -279,6 +279,10 @@ def test_regex_datetime_and_case_helpers(self): assert utils.str_icontains("Hello World", "python") is False assert utils.str_iequals("Hello World", "hello world") is True assert utils.str_iequals("Hello World", "hello") is False + assert utils.str_istartswith("Hello World", "HELLO") is True + assert utils.str_istartswith("Hello World", "World") is False + assert utils.str_iendswith("Hello World", "WORLD") is True + assert utils.str_iendswith("Hello World", "Hello") is False @parameterized.expand( [ diff --git a/posthog/utils.py b/posthog/utils.py index 8080ab903..b9f2433e0 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -495,6 +495,46 @@ def str_iequals(value, comparand): return str(value).casefold() == str(comparand).casefold() +def str_istartswith(source, search): + """ + Check if a string starts with another string, ignoring case. + + Args: + source: The string to check + search: The prefix to look for + + Returns: + bool: True if source starts with search (case-insensitive), False otherwise + + Examples: + >>> str_istartswith("Hello World", "HELLO") + True + >>> str_istartswith("Hello World", "World") + False + """ + return str(source).casefold().startswith(str(search).casefold()) + + +def str_iendswith(source, search): + """ + Check if a string ends with another string, ignoring case. + + Args: + source: The string to check + search: The suffix to look for + + Returns: + bool: True if source ends with search (case-insensitive), False otherwise + + Examples: + >>> str_iendswith("Hello World", "WORLD") + True + >>> str_iendswith("Hello World", "Hello") + False + """ + return str(source).casefold().endswith(str(search).casefold()) + + def _platform_release(): release = getattr(platform, "release", None) if callable(release): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d83fbb742..d9c8f311a 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -664,7 +664,7 @@ attribute posthog.feature_flags.PROPERTY_OPERATORS = EQUALITY_OPERATORS + STRING attribute posthog.feature_flags.SEMVER_COMPARISON_OPERATORS = ('semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte') attribute posthog.feature_flags.SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + SEMVER_RANGE_OPERATORS attribute posthog.feature_flags.SEMVER_RANGE_OPERATORS = ('semver_tilde', 'semver_caret', 'semver_wildcard') -attribute posthog.feature_flags.STRING_OPERATORS = ('icontains', 'not_icontains', 'regex', 'not_regex') +attribute posthog.feature_flags.STRING_OPERATORS = ('icontains', 'not_icontains', 'regex', 'not_regex', 'starts_with', 'not_starts_with', 'ends_with', 'not_ends_with') attribute posthog.feature_flags.log = logging.getLogger('posthog') attribute posthog.feature_flags_request_max_retries = 1 attribute posthog.feature_flags_request_timeout_seconds = 3 @@ -1163,7 +1163,9 @@ function posthog.utils.is_naive(dt: datetime) -> bool function posthog.utils.is_valid_regex(value) -> bool function posthog.utils.remove_trailing_slash(host: str) -> str function posthog.utils.str_icontains(source, search) +function posthog.utils.str_iendswith(source, search) function posthog.utils.str_iequals(value, comparand) +function posthog.utils.str_istartswith(source, search) function posthog.utils.system_context() -> dict[str, Any] function posthog.utils.total_seconds(delta: timedelta) -> float method posthog.ai.anthropic.anthropic.WrappedMessages.create(posthog_distinct_id: Optional[str] = None, posthog_trace_id: Optional[str] = None, posthog_properties: Optional[Dict[str, Any]] = None, posthog_privacy_mode: bool = False, posthog_groups: Optional[Dict[str, Any]] = None, **kwargs: Any)