From 80f054d05a879183c1a2150f4c19d8ac9b2d08df Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 07:24:52 +0200 Subject: [PATCH 1/4] fix(flags): align local case folding with the flags service --- .../standardize-flag-case-folding.md | 5 ++ posthog/feature_flags.py | 51 +++++++++++-------- posthog/test/test_feature_flags.py | 43 ++++++++++++++++ 3 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 .sampo/changesets/standardize-flag-case-folding.md diff --git a/.sampo/changesets/standardize-flag-case-folding.md b/.sampo/changesets/standardize-flag-case-folding.md new file mode 100644 index 000000000..bb01bc2a2 --- /dev/null +++ b/.sampo/changesets/standardize-flag-case-folding.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Match local feature flag string operators using the same ASCII and Unicode lowercasing rules as the flags service. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index e6c880d48..9b9a8cffe 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -7,7 +7,6 @@ from enum import Enum from typing import Optional -from posthog import utils from posthog.types import FlagValue from posthog.utils import convert_to_datetime_aware, is_valid_regex @@ -507,6 +506,13 @@ def is_condition_match( # 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" +_ASCII_LOWER_TRANSLATION = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz" +) + + +def _ascii_lower(value) -> str: + return str(value).translate(_ASCII_LOWER_TRANSLATION) def match_property(property, property_values) -> bool: @@ -534,34 +540,37 @@ def match_property(property, property_values) -> bool: if operator in ("exact", "is_not"): def compute_exact_match(value, override_value): + override_value = str(override_value).lower() if isinstance(value, list): - return str(override_value).casefold() in [ - str(val).casefold() for val in value - ] - return utils.str_iequals(value, override_value) + return override_value in [str(val).lower() for val in value] + return str(value).lower() == override_value if operator == "exact": return compute_exact_match(value, override_value) else: return not compute_exact_match(value, override_value) - if operator == "icontains": - return utils.str_icontains(override_value, 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 in ( + "icontains", + "not_icontains", + "starts_with", + "not_starts_with", + "ends_with", + "not_ends_with", + ): + property_string = _ascii_lower(override_value) + filter_string = _ascii_lower(value) + + if operator in ("icontains", "not_icontains"): + matched = filter_string in property_string + elif operator in ("starts_with", "not_starts_with"): + matched = property_string.startswith(filter_string) + else: + matched = property_string.endswith(filter_string) - if operator == "not_ends_with": - return not utils.str_iendswith(override_value, value) + if operator.startswith("not_"): + return not matched + return matched if operator == "regex": return ( diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 68873f46c..517b5fd8e 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5094,6 +5094,29 @@ def test_match_properties_exact(self): with self.assertRaises(InconclusiveMatchError): match_property(property_c, {"key2": "value"}) + def test_match_properties_exact_uses_unicode_lowercase(self): + matching_cases = [("Ä", "ä"), ("323.0", 323.0)] + non_matching_cases = [("ß", "ss"), ("Σ", "ς")] + + for filter_value, property_value in matching_cases: + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertTrue(match_property(exact, {"key": property_value})) + self.assertFalse(match_property(is_not, {"key": property_value})) + + for filter_value, property_value in non_matching_cases: + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertFalse(match_property(exact, {"key": property_value})) + self.assertTrue(match_property(is_not, {"key": property_value})) + + exact_array = self.property("key", ["free", "Ä"], "exact") + is_not_array = self.property("key", ["free", "Ä"], "is_not") + self.assertTrue(match_property(exact_array, {"key": "ä"})) + self.assertFalse(match_property(is_not_array, {"key": "ä"})) + self.assertFalse(match_property(exact_array, {"key": "paid"})) + self.assertTrue(match_property(is_not_array, {"key": "paid"})) + def test_match_properties_not_in(self): property_a = self.property(key="key", value="value", operator="is_not") self.assertTrue(match_property(property_a, {"key": "value2"})) @@ -5202,6 +5225,26 @@ def test_match_properties_starts_with_and_ends_with( with self.assertRaises(InconclusiveMatchError): match_property(prop, missing_properties) + @parameterized.expand( + [ + ("icontains", "prefixÄsuffix"), + ("starts_with", "Äsuffix"), + ("ends_with", "prefixÄ"), + ] + ) + def test_string_operators_use_ascii_only_case_folding(self, operator, value): + positive = self.property("key", "ä", operator) + negative = self.property("key", "ä", f"not_{operator}") + self.assertFalse(match_property(positive, {"key": value})) + self.assertTrue(match_property(negative, {"key": value})) + + def test_string_operators_preserve_float_stringification(self): + contains = self.property("key", ".0", "icontains") + starts_with = self.property("key", "323", "starts_with") + ends_with = self.property("key", ".0", "ends_with") + for prop in (contains, starts_with, ends_with): + self.assertTrue(match_property(prop, {"key": 323.0})) + 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"})) From 6b6f16cfe392044ddfa3a41a09da889e7f0e368a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 07:48:00 +0200 Subject: [PATCH 2/4] fix(flags): preserve the utils module export --- posthog/feature_flags.py | 1 + posthog/test/test_feature_flags.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 9b9a8cffe..cbcfc06cf 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -7,6 +7,7 @@ from enum import Enum from typing import Optional +from posthog import utils as utils from posthog.types import FlagValue from posthog.utils import convert_to_datetime_aware, is_valid_regex diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 517b5fd8e..eed6a8da5 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -217,7 +217,7 @@ def test_distinct_id_property_is_available_for_local_evaluation_only( self.assertEqual(person_properties, {"region": "USA"}) self.assertEqual(patch_flags.call_count, 0) - def test_case_insensitive_matching(self): + def test_exact_matching_uses_unicode_lowercase(self): self.client.feature_flags = [ { "id": 1, @@ -259,26 +259,34 @@ def test_case_insensitive_matching(self): "person-flag", "some-distinct-id", person_properties={"location": "straße"}, + only_evaluate_locally=True, ) ) - self.assertTrue( + self.assertFalse( self.client.get_feature_flag( "person-flag", "some-distinct-id", person_properties={"location": "strasse"}, + only_evaluate_locally=True, ) ) self.assertTrue( self.client.get_feature_flag( - "person-flag", "some-distinct-id", person_properties={"star": "ſun"} + "person-flag", + "some-distinct-id", + person_properties={"star": "ſun"}, + only_evaluate_locally=True, ) ) - self.assertTrue( + self.assertFalse( self.client.get_feature_flag( - "person-flag", "some-distinct-id", person_properties={"star": "sun"} + "person-flag", + "some-distinct-id", + person_properties={"star": "sun"}, + only_evaluate_locally=True, ) ) From ac6fd9234e11ac28766a88cc17d8083fdd11e8d2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 27 Aug 2026 08:43:33 +0200 Subject: [PATCH 3/4] test(flags): parameterize string matching cases --- posthog/test/test_feature_flags.py | 47 +++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index eed6a8da5..82c6c5532 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5102,22 +5102,23 @@ def test_match_properties_exact(self): with self.assertRaises(InconclusiveMatchError): match_property(property_c, {"key2": "value"}) - def test_match_properties_exact_uses_unicode_lowercase(self): - matching_cases = [("Ä", "ä"), ("323.0", 323.0)] - non_matching_cases = [("ß", "ss"), ("Σ", "ς")] - - for filter_value, property_value in matching_cases: - exact = self.property("key", filter_value, "exact") - is_not = self.property("key", filter_value, "is_not") - self.assertTrue(match_property(exact, {"key": property_value})) - self.assertFalse(match_property(is_not, {"key": property_value})) - - for filter_value, property_value in non_matching_cases: - exact = self.property("key", filter_value, "exact") - is_not = self.property("key", filter_value, "is_not") - self.assertFalse(match_property(exact, {"key": property_value})) - self.assertTrue(match_property(is_not, {"key": property_value})) + @parameterized.expand( + [ + ("non_ascii_case_variant", "Ä", "ä", True), + ("float_stringification", "323.0", 323.0, True), + ("casefold_expansion", "ß", "ss", False), + ("final_sigma", "Σ", "ς", False), + ] + ) + def test_match_properties_exact_uses_unicode_lowercase( + self, _name, filter_value, property_value, expected + ): + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertEqual(expected, match_property(exact, {"key": property_value})) + self.assertEqual(not expected, match_property(is_not, {"key": property_value})) + def test_match_properties_exact_array_uses_unicode_lowercase(self): exact_array = self.property("key", ["free", "Ä"], "exact") is_not_array = self.property("key", ["free", "Ä"], "is_not") self.assertTrue(match_property(exact_array, {"key": "ä"})) @@ -5246,12 +5247,16 @@ def test_string_operators_use_ascii_only_case_folding(self, operator, value): self.assertFalse(match_property(positive, {"key": value})) self.assertTrue(match_property(negative, {"key": value})) - def test_string_operators_preserve_float_stringification(self): - contains = self.property("key", ".0", "icontains") - starts_with = self.property("key", "323", "starts_with") - ends_with = self.property("key", ".0", "ends_with") - for prop in (contains, starts_with, ends_with): - self.assertTrue(match_property(prop, {"key": 323.0})) + @parameterized.expand( + [ + ("icontains", ".0"), + ("starts_with", "323"), + ("ends_with", ".0"), + ] + ) + def test_string_operators_preserve_float_stringification(self, operator, value): + prop = self.property("key", value, operator) + self.assertTrue(match_property(prop, {"key": 323.0})) def test_match_properties_regex(self): property_a = self.property(key="key", value=r"\.com$", operator="regex") From 83e1c27f39d554d1e9c6764b6c5424077fff653f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 28 Aug 2026 08:22:49 +0200 Subject: [PATCH 4/4] fix(flags): match backend exact value semantics --- .../standardize-flag-case-folding.md | 2 +- posthog/feature_flags.py | 98 +++++++++++++++- posthog/test/test_feature_flags.py | 106 ++++++++++++++++-- 3 files changed, 193 insertions(+), 13 deletions(-) diff --git a/.sampo/changesets/standardize-flag-case-folding.md b/.sampo/changesets/standardize-flag-case-folding.md index bb01bc2a2..24714d642 100644 --- a/.sampo/changesets/standardize-flag-case-folding.md +++ b/.sampo/changesets/standardize-flag-case-folding.md @@ -2,4 +2,4 @@ pypi/posthog: patch --- -Match local feature flag string operators using the same ASCII and Unicode lowercasing rules as the flags service. +Match local feature flag string operators using the flags service's boolean coercion, JSON stringification, and casing rules. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index cbcfc06cf..808053e32 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -1,9 +1,12 @@ import calendar import datetime import hashlib +import json import logging +import math import re import warnings +from decimal import Decimal from enum import Enum from typing import Optional @@ -512,8 +515,82 @@ def is_condition_match( ) +def _format_json_float(value: float) -> str: + if not math.isfinite(value): + raise InconclusiveMatchError( + "Non-finite property values cannot be represented by the flags service" + ) + + representation = repr(value) + if "e" not in representation: + return representation + + mantissa, exponent_text = representation.split("e") + exponent = int(exponent_text) + if -6 < exponent < 0: + return format(Decimal(representation), "f") + + sign = "+" if exponent >= 0 else "" + return f"{mantissa}e{sign}{exponent}" + + +def _json_value_to_string(value) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return _format_json_float(value) + if isinstance(value, list): + return "[" + ",".join(_json_value_to_string(item) for item in value) + "]" + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise InconclusiveMatchError( + "Property object keys must be strings for local evaluation" + ) + items = ( + f"{json.dumps(key, ensure_ascii=False)}:{_json_value_to_string(value[key])}" + for key in sorted(value) + ) + return "{" + ",".join(items) + "}" + + raise InconclusiveMatchError( + f"Property value of type {type(value).__name__} is not JSON-compatible" + ) + + +def _value_to_string(value) -> str: + if isinstance(value, str): + return value + return _json_value_to_string(value) + + def _ascii_lower(value) -> str: - return str(value).translate(_ASCII_LOWER_TRANSLATION) + return _value_to_string(value).translate(_ASCII_LOWER_TRANSLATION) + + +def _is_truthy_or_falsy_property_value(value) -> bool: + if isinstance(value, bool): + return True + if isinstance(value, str): + return value.lower() in ("true", "false") + if isinstance(value, list): + return all(_is_truthy_or_falsy_property_value(item) for item in value) + return False + + +def _is_truthy_property_value(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() == "true" + if isinstance(value, list): + return all(_is_truthy_property_value(item) for item in value) + return False def match_property(property, property_values) -> bool: @@ -535,16 +612,27 @@ def match_property(property, property_values) -> bool: override_value = property_values[key] - if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None: + if ( + operator not in NONE_VALUES_ALLOWED_OPERATORS + and operator != "exact" + and override_value is None + ): return False if operator in ("exact", "is_not"): def compute_exact_match(value, override_value): - override_value = str(override_value).lower() + override_string = _value_to_string(override_value).lower() + if _is_truthy_or_falsy_property_value(value): + return _is_truthy_property_value(value) == _is_truthy_property_value( + override_value + ) + if isinstance(value, list): - return override_value in [str(val).lower() for val in value] - return str(value).lower() == override_value + return override_string in [ + _value_to_string(candidate).lower() for candidate in value + ] + return _value_to_string(value).lower() == override_string if operator == "exact": return compute_exact_match(value, override_value) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 82c6c5532..107f9a930 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -5107,7 +5107,11 @@ def test_match_properties_exact(self): ("non_ascii_case_variant", "Ä", "ä", True), ("float_stringification", "323.0", 323.0, True), ("casefold_expansion", "ß", "ss", False), - ("final_sigma", "Σ", "ς", False), + ("single_final_sigma", "Σ", "ς", False), + ("word_final_sigma", "ΟΔΟΣ", "οδος", True), + ("word_medial_sigma", "ΟΔΟΣ", "οδοσ", False), + ("dotted_capital_i", "İ", "i\u0307", True), + ("plain_i", "İ", "i", False), ] ) def test_match_properties_exact_uses_unicode_lowercase( @@ -5126,6 +5130,87 @@ def test_match_properties_exact_array_uses_unicode_lowercase(self): self.assertFalse(match_property(exact_array, {"key": "paid"})) self.assertTrue(match_property(is_not_array, {"key": "paid"})) + @parameterized.expand( + [ + ("false_matches_non_truthy_string", False, "banana", True), + ("false_string_matches_zero", "false", 0, True), + ("false_array_matches_null", ["false"], None, True), + ("mixed_boolean_array_rejects_true", ["true", "false"], "true", False), + ("mixed_boolean_array_matches_non_truthy", ["true", "false"], "pro", True), + ("empty_array_matches_true", [], True, True), + ("empty_array_matches_empty_array", [], [], True), + ("empty_array_rejects_false", [], False, False), + ("ordinary_array_uses_any", ["FREE", "PRO"], "pro", True), + ] + ) + def test_match_properties_exact_uses_backend_boolean_precedence( + self, _name, filter_value, property_value, expected + ): + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertEqual(expected, match_property(exact, {"key": property_value})) + self.assertEqual(not expected, match_property(is_not, {"key": property_value})) + + @parameterized.expand( + [ + ("compact_array", "[1,2]", [1, 2], True), + ("python_array_spelling", "[1, 2]", [1, 2], False), + ( + "sorted_object", + '{"a":1,"b":2}', + {"b": 2, "a": 1}, + True, + ), + ( + "recursive_sorting", + '{"a":"x","z":[{"a":2,"b":1}]}', + {"z": [{"b": 1, "a": 2}], "a": "x"}, + True, + ), + ] + ) + def test_match_properties_exact_uses_backend_json_stringification( + self, _name, filter_value, property_value, expected + ): + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertEqual(expected, match_property(exact, {"key": property_value})) + self.assertEqual(not expected, match_property(is_not, {"key": property_value})) + + @parameterized.expand( + [ + ("integral_float", "323.0", 323.0, True), + ("negative_zero", "-0.0", -0.0, True), + ("small_exponent", "1e-7", 1e-7, True), + ("large_exponent", "1e+16", 1e16, True), + ("fixed_small_decimal", "0.00001", 1e-5, True), + ("fixed_small_decimal_fraction", "0.000099", 9.9e-5, True), + ("python_padded_exponent", "1e-07", 1e-7, False), + ] + ) + def test_match_properties_exact_uses_backend_float_stringification( + self, _name, filter_value, property_value, expected + ): + exact = self.property("key", filter_value, "exact") + is_not = self.property("key", filter_value, "is_not") + self.assertEqual(expected, match_property(exact, {"key": property_value})) + self.assertEqual(not expected, match_property(is_not, {"key": property_value})) + + @parameterized.expand( + [ + ("nan", "value", float("nan")), + ("positive_infinity", "value", float("inf")), + ("negative_infinity", "value", float("-inf")), + ("boolean_filter_with_nan", False, float("nan")), + ] + ) + def test_match_properties_exact_non_json_numbers_are_inconclusive( + self, _name, filter_value, property_value + ): + exact = self.property("key", filter_value, "exact") + with self.assertRaises(InconclusiveMatchError): + match_property(exact, {"key": property_value}) + def test_match_properties_not_in(self): property_a = self.property(key="key", value="value", operator="is_not") self.assertTrue(match_property(property_a, {"key": "value2"})) @@ -5249,14 +5334,18 @@ def test_string_operators_use_ascii_only_case_folding(self, operator, value): @parameterized.expand( [ - ("icontains", ".0"), - ("starts_with", "323"), - ("ends_with", ".0"), + ("icontains_integral_float", "icontains", ".0", 323.0), + ("starts_with_integral_float", "starts_with", "323", 323.0), + ("ends_with_integral_float", "ends_with", ".0", 323.0), + ("small_exponent", "ends_with", "e-7", 1e-7), + ("fixed_small_decimal", "starts_with", "0.00001", 1e-5), ] ) - def test_string_operators_preserve_float_stringification(self, operator, value): + def test_string_operators_preserve_float_stringification( + self, _name, operator, value, property_value + ): prop = self.property("key", value, operator) - self.assertTrue(match_property(prop, {"key": 323.0})) + self.assertTrue(match_property(prop, {"key": property_value})) def test_match_properties_regex(self): property_a = self.property(key="key", value=r"\.com$", operator="regex") @@ -5570,9 +5659,12 @@ def test_match_property_relative_date_operators(self): def test_none_property_value_with_all_operators(self): property_a = self.property(key="key", value="none", operator="is_not") - self.assertFalse(match_property(property_a, {"key": None})) + self.assertTrue(match_property(property_a, {"key": None})) self.assertTrue(match_property(property_a, {"key": "non"})) + exact_null = self.property(key="key", value="null", operator="exact") + self.assertTrue(match_property(exact_null, {"key": None})) + property_c = self.property(key="key", value="no", operator="icontains") self.assertFalse(match_property(property_c, {"key": None})) self.assertFalse(match_property(property_c, {"key": "smh"}))