From 3a3a3e6aa1d72461a7242ba1c82fb7bdf68a8bb2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 21 Oct 2026 11:30:45 +0200 Subject: [PATCH] fix(flags): match explicit null properties with string operators --- .../pompous-guardian-joukahainen.md | 5 +++ posthog/feature_flags.py | 18 ++++------ posthog/test/test_feature_flags.py | 35 +++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 .sampo/changesets/pompous-guardian-joukahainen.md diff --git a/.sampo/changesets/pompous-guardian-joukahainen.md b/.sampo/changesets/pompous-guardian-joukahainen.md new file mode 100644 index 00000000..e90ec246 --- /dev/null +++ b/.sampo/changesets/pompous-guardian-joukahainen.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Fix local feature flag string and regex operators to match an explicit null property as "null", like the flags service. This can change flag values or selected variants for explicitly null person or group properties. Missing properties still allow remote evaluation fallback. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 7545306e..049e2fe4 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -698,6 +698,7 @@ def match_property( if ( operator not in NONE_VALUES_ALLOWED_OPERATORS + and operator not in STRING_OPERATORS and operator != "exact" and override_value is None ): @@ -750,17 +751,12 @@ def compute_exact_match(value, override_value): return not matched return matched - if operator == "regex": - return ( - is_valid_regex(str(value)) - and re.compile(str(value)).search(str(override_value)) is not None - ) - - if operator == "not_regex": - return ( - is_valid_regex(str(value)) - and re.compile(str(value)).search(str(override_value)) is None - ) + if operator in ("regex", "not_regex"): + if not is_valid_regex(str(value)): + return False + property_string = "null" if override_value is None else str(override_value) + matched = re.compile(str(value)).search(property_string) is not None + return not matched if operator == "not_regex" else matched if operator in ["is_date_before", "is_date_after"]: try: diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 374cb325..2c430336 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5654,6 +5654,41 @@ def test_string_operators_preserve_float_stringification( prop = self.property("key", value, operator) self.assertTrue(match_property(prop, {"key": property_value})) + @parameterized.expand( + [ + ("icontains", "nul", True), + ("icontains", "x", False), + ("not_icontains", "x", True), + ("not_icontains", "nul", False), + ("starts_with", "NU", True), + ("starts_with", "x", False), + ("not_starts_with", "x", True), + ("not_starts_with", "nu", False), + ("ends_with", "LL", True), + ("ends_with", "x", False), + ("not_ends_with", "x", True), + ("not_ends_with", "ll", False), + ("regex", "^nu", True), + ("regex", "Nu", False), + ("regex", "^None$", False), + ("regex", "x", False), + ("not_regex", "x", True), + ("not_regex", "^nu", False), + ("not_regex", "^None$", True), + ("regex", "[", False), + ("not_regex", "[", False), + ] + ) + def test_string_operators_match_explicit_null(self, operator, value, expected): + prop = self.property("key", value, operator) + self.assertEqual(expected, match_property(prop, {"key": None})) + + def test_string_operators_with_omitted_key_are_inconclusive(self): + for operator in posthog.feature_flags.STRING_OPERATORS: + with self.subTest(operator=operator): + with self.assertRaises(InconclusiveMatchError): + match_property(self.property("key", "x", operator), {}) + 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"}))