Skip to content
Open
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
5 changes: 5 additions & 0 deletions .sampo/changesets/standardize-flag-case-folding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Match local feature flag string operators using the flags service's boolean coercion, JSON stringification, and casing rules.
140 changes: 119 additions & 21 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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

from posthog import utils
from posthog import utils as utils
from posthog.types import FlagValue
from posthog.utils import convert_to_datetime_aware, is_valid_regex

Expand Down Expand Up @@ -507,6 +510,87 @@ 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 _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 _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:
Expand All @@ -528,40 +612,54 @@ 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_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 str(override_value).casefold() in [
str(val).casefold() for val in value
return override_string in [
_value_to_string(candidate).lower() for candidate in value
]
return utils.str_iequals(value, override_value)
return _value_to_string(value).lower() == override_string

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 (
Expand Down
160 changes: 154 additions & 6 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -5094,6 +5102,115 @@ def test_match_properties_exact(self):
with self.assertRaises(InconclusiveMatchError):
match_property(property_c, {"key2": "value"})

@parameterized.expand(
[
("non_ascii_case_variant", "脛", "盲", True),
("float_stringification", "323.0", 323.0, True),
("casefold_expansion", "脽", "ss", 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(
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": "盲"}))
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"}))

@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"}))
Expand Down Expand Up @@ -5202,6 +5319,34 @@ 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}))

@parameterized.expand(
[
("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, _name, operator, value, property_value
):
prop = self.property("key", value, operator)
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")
self.assertTrue(match_property(property_a, {"key": "value.com"}))
Expand Down Expand Up @@ -5514,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"}))
Expand Down