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 00000000..806f9c44 --- /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 8d95ced3..bc7569a3 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 = ( @@ -538,6 +547,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)) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 497069ef..9c8fd27a 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4741,6 +4741,65 @@ def test_match_properties_icontains(self): self.assertFalse(match_property(property_b, {"key": "three"})) + def test_match_properties_starts_with(self): + property_a = self.property(key="key", value="Val", operator="starts_with") + self.assertTrue(match_property(property_a, {"key": "value"})) + self.assertTrue(match_property(property_a, {"key": "VALUE"})) + self.assertTrue(match_property(property_a, {"key": "vaLue4"})) + + self.assertFalse(match_property(property_a, {"key": "prevalue"})) + self.assertFalse(match_property(property_a, {"key": "Alakazam"})) + self.assertFalse(match_property(property_a, {"key": 123})) + + property_b = self.property(key="key", value="3", operator="starts_with") + self.assertTrue(match_property(property_b, {"key": "3"})) + self.assertTrue(match_property(property_b, {"key": 323})) + + self.assertFalse(match_property(property_b, {"key": 123})) + self.assertFalse(match_property(property_b, {"key": "val3"})) + + property_c = self.property(key="key", value="Val", operator="not_starts_with") + self.assertFalse(match_property(property_c, {"key": "value"})) + self.assertFalse(match_property(property_c, {"key": "VALUE"})) + + self.assertTrue(match_property(property_c, {"key": "prevalue"})) + self.assertTrue(match_property(property_c, {"key": "Alakazam"})) + + with self.assertRaises(InconclusiveMatchError): + match_property(property_a, {"key2": "value"}) + with self.assertRaises(InconclusiveMatchError): + match_property(property_a, {}) + + def test_match_properties_ends_with(self): + property_a = self.property(key="key", value="lUe", operator="ends_with") + self.assertTrue(match_property(property_a, {"key": "value"})) + self.assertTrue(match_property(property_a, {"key": "VALUE"})) + self.assertTrue(match_property(property_a, {"key": "343tfvalue"})) + + self.assertFalse(match_property(property_a, {"key": "value2"})) + self.assertFalse(match_property(property_a, {"key": "Alakazam"})) + self.assertFalse(match_property(property_a, {"key": 123})) + + property_b = self.property(key="key", value="3", operator="ends_with") + self.assertTrue(match_property(property_b, {"key": "3"})) + self.assertTrue(match_property(property_b, {"key": 323})) + self.assertTrue(match_property(property_b, {"key": 13})) + + self.assertFalse(match_property(property_b, {"key": 321})) + self.assertFalse(match_property(property_b, {"key": "3val"})) + + property_c = self.property(key="key", value="lUe", operator="not_ends_with") + self.assertFalse(match_property(property_c, {"key": "value"})) + self.assertFalse(match_property(property_c, {"key": "VALUE"})) + + self.assertTrue(match_property(property_c, {"key": "value2"})) + self.assertTrue(match_property(property_c, {"key": "Alakazam"})) + + with self.assertRaises(InconclusiveMatchError): + match_property(property_a, {"key2": "value"}) + with self.assertRaises(InconclusiveMatchError): + match_property(property_a, {}) + 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 834e6ac4..781460a9 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 8080ab90..b9f2433e 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):