Compare commits

...
7 Commits
12 changed files with 811 additions and 81 deletions
+1 -1
View File
@@ -62,4 +62,4 @@ jobs:
- name: Run posthog tests
run: |
python setup.py test
pytest --verbose --timeout=30
+25
View File
@@ -1,3 +1,28 @@
## 3.3.1 - 2024-01-10
1. Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
## 3.3.0 - 2024-01-09
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
## 3.2.0 - 2024-01-09
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
2. Add support for relative date operators for local evaluation.
## 3.1.0 - 2023-12-04
1. Increase maximum event size and batch size
## 3.0.2 - 2023-08-17
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
## 3.0.1 - 2023-04-21
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
## 3.0.0 - 2023-04-14
Breaking change:
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2020 PostHog (part of Hiberly Inc)
Copyright (c) 2023 PostHog (part of Hiberly Inc)
Copyright (c) 2013 Segment Inc. friends@segment.com
+5
View File
@@ -400,6 +400,11 @@ def get_all_flags_and_payloads(
)
def feature_flag_definitions():
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
return _proxy("feature_flag_definitions")
def page(*args, **kwargs):
"""Send a page call."""
_proxy("page", *args, **kwargs)
+53 -15
View File
@@ -137,16 +137,6 @@ class Client(object):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlags"]
def _get_active_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
feature_variants = self.get_feature_variants(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return {
k: v for (k, v) in feature_variants.items() if v is not False
} # explicitly test for false to account for values that may seem falsy (ex: 0)
def get_feature_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
@@ -206,15 +196,29 @@ class Client(object):
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
extra_properties = {}
feature_variants = {}
if send_feature_flags:
try:
feature_variants = self._get_active_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
else:
for feature, variant in feature_variants.items():
msg["properties"]["$feature/{}".format(feature)] = variant
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
elif self.feature_flags:
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
feature_variants = self.get_all_flags(
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
)
for feature, variant in feature_variants.items():
extra_properties[f"$feature/{feature}"] = variant
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
if active_feature_flags:
extra_properties["$active_feature_flags"] = active_feature_flags
if extra_properties:
msg["properties"] = {**extra_properties, **msg["properties"]}
return self._enqueue(msg, disable_geoip)
@@ -535,6 +539,13 @@ class Client(object):
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.disabled:
return None
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
response = None
@@ -588,6 +599,7 @@ class Client(object):
"$feature_flag": key,
"$feature_flag_response": response,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
groups=groups,
disable_geoip=disable_geoip,
@@ -608,6 +620,9 @@ class Client(object):
send_feature_flag_events=True,
disable_geoip=None,
):
if self.disabled:
return None
if match_value is None:
match_value = self.get_feature_flag(
key,
@@ -675,6 +690,13 @@ class Client(object):
only_evaluate_locally=False,
disable_geoip=None,
):
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
@@ -730,6 +752,22 @@ class Client(object):
return flags, payloads, fallback_to_decide
def feature_flag_definitions(self):
return self.feature_flags
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
all_person_properties = {"$current_distinct_id": distinct_id, **(person_properties or {})}
all_group_properties = {}
if groups:
for group_name in groups:
all_group_properties[group_name] = {
"$group_key": groups[group_name],
**(group_properties.get(group_name) or {}),
}
return all_person_properties, all_group_properties
def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
+6 -5
View File
@@ -12,11 +12,12 @@ try:
except ImportError:
from Queue import Empty
MAX_MSG_SIZE = 32 << 10
# Our servers only accept batches less than 500KB. Here limit is set slightly
# lower to leave space for extra data that will be added later, eg. "sentAt".
BATCH_SIZE_LIMIT = 475000
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
# The maximum request body size is currently 20MiB, let's be conservative
# in case we want to lower it in the future.
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
class Consumer(Thread):
@@ -104,7 +105,7 @@ class Consumer(Thread):
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
if item_size > MAX_MSG_SIZE:
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
continue
items.append(item)
total_size += item_size
+84 -26
View File
@@ -2,8 +2,10 @@ import datetime
import hashlib
import logging
import re
from typing import Optional
from dateutil import parser
from dateutil.relativedelta import relativedelta
from posthog.utils import convert_to_datetime_aware, is_valid_regex
@@ -117,15 +119,17 @@ def match_property(property, property_values) -> bool:
override_value = property_values[key]
if operator == "exact":
if isinstance(value, list):
return override_value in value
return value == override_value
if operator in ("exact", "is_not"):
if operator == "is_not":
if isinstance(value, list):
return override_value not in value
return value != override_value
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).lower() in [str(val).lower() for val in value]
return str(value).lower() == str(override_value).lower()
if operator == "exact":
return compute_exact_match(value, override_value)
else:
return not compute_exact_match(value, override_value)
if operator == "is_set":
return key in property_values
@@ -142,33 +146,56 @@ def match_property(property, property_values) -> bool:
if operator == "not_regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
if operator == "gt":
return type(override_value) == type(value) and override_value > value
if operator in ("gt", "gte", "lt", "lte"):
# :TRICKY: We adjust comparison based on the override value passed in,
# to make sure we handle both numeric and string comparisons appropriately.
def compare(lhs, rhs, operator):
if operator == "gt":
return lhs > rhs
elif operator == "gte":
return lhs >= rhs
elif operator == "lt":
return lhs < rhs
elif operator == "lte":
return lhs <= rhs
else:
raise ValueError(f"Invalid operator: {operator}")
if operator == "gte":
return type(override_value) == type(value) and override_value >= value
if operator == "lt":
return type(override_value) == type(value) and override_value < value
if operator == "lte":
return type(override_value) == type(value) and override_value <= value
if operator in ["is_date_before", "is_date_after"]:
parsed_value = None
try:
parsed_date = parser.parse(value)
parsed_date = convert_to_datetime_aware(parsed_date)
parsed_value = float(value) # type: ignore
except Exception:
pass
if parsed_value is not None and override_value is not None:
if isinstance(override_value, str):
return compare(override_value, str(value), operator)
else:
return compare(override_value, parsed_value, operator)
else:
return compare(str(override_value), str(value), operator)
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
try:
if operator in ["is_relative_date_before", "is_relative_date_after"]:
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
else:
parsed_date = parser.parse(str(value))
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception as e:
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
if not parsed_date:
raise InconclusiveMatchError("The date set on the flag is not a valid format")
if isinstance(override_value, datetime.datetime):
override_date = convert_to_datetime_aware(override_value)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
elif isinstance(override_value, datetime.date):
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_value < parsed_date.date()
else:
return override_value > parsed_date.date()
@@ -176,7 +203,7 @@ def match_property(property, property_values) -> bool:
try:
override_date = parser.parse(override_value)
override_date = convert_to_datetime_aware(override_date)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
@@ -185,7 +212,8 @@ def match_property(property, property_values) -> bool:
else:
raise InconclusiveMatchError("The date provided must be a string or date object")
return False
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
def match_cohort(property, property_values, cohort_properties) -> bool:
@@ -271,3 +299,33 @@ def match_property_group(property_group, property_values, cohort_properties) ->
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
regex = r"^(?P<number>[0-9]+)(?P<interval>[a-z])$"
match = re.search(regex, value)
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
if match:
number = int(match.group("number"))
if number >= 10_000:
# Guard against overflow, disallow numbers greater than 10_000
return None
interval = match.group("interval")
if interval == "h":
parsed_dt = parsed_dt - relativedelta(hours=number)
elif interval == "d":
parsed_dt = parsed_dt - relativedelta(days=number)
elif interval == "w":
parsed_dt = parsed_dt - relativedelta(weeks=number)
elif interval == "m":
parsed_dt = parsed_dt - relativedelta(months=number)
elif interval == "y":
parsed_dt = parsed_dt - relativedelta(years=number)
else:
return None
return parsed_dt
else:
return None
+276 -19
View File
@@ -106,24 +106,187 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
@mock.patch("posthog.client.decide")
def test_get_active_feature_flags(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
variants = client._get_active_feature_variants("some_id", None, None, None, False)
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={},
person_properties=None,
group_properties=None,
disable_geoip=False,
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
},
{
"rollout_percentage": 50,
},
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
},
}
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": 300},
},
}
false_flag = {
"id": 1,
"name": "Beta Feature",
"key": "false-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {"true": 300},
},
}
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "third-variant")
self.assertEqual(msg["properties"]["$feature/false-flag"], False)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
assert "$feature/beta-feature" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
# test that flags are not evaluated without local evaluation
client.feature_flags = []
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/beta-feature-local" not in msg["properties"]
assert "$feature/false-flag" not in msg["properties"]
assert "$active_feature_flags" not in msg["properties"]
@mock.patch("posthog.client.decide")
def test_dont_override_capture_with_local_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
},
{
"rollout_percentage": 50,
},
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
},
}
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": 300},
},
}
client.feature_flags = [multivariate_flag, basic_flag]
success, msg = client.capture(
"distinct_id", "python test event", {"$feature/beta-feature-local": "my-custom-variant"}
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "my-custom-variant")
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/person-flag" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
@@ -537,6 +700,33 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg, "disabled")
@mock.patch("posthog.client.decide")
def test_disabled_with_feature_flags(self, patch_decide):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
response = client.get_feature_flag("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
response = client.feature_enabled("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
response = client.get_all_flags("12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
response = client.get_feature_flag_payload("key", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
response = client.get_all_flags_and_payloads("12345")
self.assertEqual(response, {"featureFlags": None, "featureFlagPayloads": None})
patch_decide.assert_not_called()
# no capture calls
self.assertTrue(client.queue.empty())
def test_enabled_to_disabled(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=False)
success, msg = client.capture("distinct_id", "python test event")
@@ -593,7 +783,7 @@ class TestClient(unittest.TestCase):
timeout=10,
distinct_id="some_id",
groups={},
person_properties={},
person_properties={"$current_distinct_id": "some_id"},
group_properties={},
disable_geoip=True,
)
@@ -605,7 +795,7 @@ class TestClient(unittest.TestCase):
timeout=10,
distinct_id="feature_enabled_distinct_id",
groups={},
person_properties={},
person_properties={"$current_distinct_id": "feature_enabled_distinct_id"},
group_properties={},
disable_geoip=True,
)
@@ -617,7 +807,7 @@ class TestClient(unittest.TestCase):
timeout=10,
distinct_id="all_flags_payloads_id",
groups={},
person_properties={},
person_properties={"$current_distinct_id": "all_flags_payloads_id"},
group_properties={},
disable_geoip=False,
)
@@ -633,3 +823,70 @@ class TestClient(unittest.TestCase):
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
self.assertFalse(client.feature_enabled("example", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_default_properties_get_added_properly(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
client.get_feature_flag(
"random_key",
"some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"x1": "y1"},
group_properties={"company": {"x": "y"}},
)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "some_id", "x1": "y1"},
group_properties={
"company": {"$group_key": "id:5", "x": "y"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
client.get_feature_flag(
"random_key",
"some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "override"},
group_properties={
"company": {
"$group_key": "group_override",
}
},
)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "override"},
group_properties={
"company": {"$group_key": "group_override"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
# test nones
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={},
person_properties={"$current_distinct_id": "some_id"},
group_properties={},
disable_geoip=False,
)
+9 -4
View File
@@ -145,15 +145,20 @@ class TestConsumer(unittest.TestCase):
def test_max_batch_size(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
properties = {}
for n in range(0, 500):
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
msg_size = len(json.dumps(track).encode())
# number of messages in a maximum-size batch
n_msgs = int(475000 / msg_size)
# Let's capture 8MB of data to trigger two batches
n_msgs = int(8_000_000 / msg_size)
def mock_post_fn(_, data, **kwargs):
res = mock.Mock()
res.status_code = 200
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
request_size = len(data.encode())
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
return res
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
+349 -8
View File
@@ -6,7 +6,7 @@ from dateutil import parser, tz
from freezegun import freeze_time
from posthog.client import Client
from posthog.feature_flags import InconclusiveMatchError, match_property
from posthog.feature_flags import InconclusiveMatchError, match_property, relative_date_parse_for_feature_flag_matching
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
@@ -1775,7 +1775,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_a, {"key": 0}))
self.assertFalse(match_property(property_a, {"key": -1}))
self.assertFalse(match_property(property_a, {"key": "23"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_a, {"key": "23"}))
property_b = self.property(key="key", value=1, operator="lt")
self.assertTrue(match_property(property_b, {"key": 0}))
@@ -1792,7 +1793,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_c, {"key": 0}))
self.assertFalse(match_property(property_c, {"key": -1}))
self.assertFalse(match_property(property_c, {"key": "3"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_c, {"key": "3"}))
property_d = self.property(key="key", value="43", operator="lte")
self.assertTrue(match_property(property_d, {"key": "41"}))
@@ -1801,6 +1803,21 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_d, {"key": "44"}))
self.assertFalse(match_property(property_d, {"key": 44}))
self.assertTrue(match_property(property_d, {"key": 42}))
property_e = self.property(key="key", value="30", operator="lt")
self.assertTrue(match_property(property_e, {"key": "29"}))
# depending on the type of override, we adjust type comparison
self.assertTrue(match_property(property_e, {"key": "100"}))
self.assertFalse(match_property(property_e, {"key": 100}))
property_f = self.property(key="key", value="123aloha", operator="gt")
self.assertFalse(match_property(property_f, {"key": "123"}))
self.assertFalse(match_property(property_f, {"key": 122}))
# this turns into a string comparison
self.assertTrue(match_property(property_f, {"key": 129}))
def test_match_property_date_operators(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_date_before")
@@ -1854,6 +1871,305 @@ class TestMatchProperties(unittest.TestCase):
self.assertTrue(match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"}))
@freeze_time("2022-05-01")
def test_match_property_relative_date_operators(self):
property_a = self.property(key="key", value="6h", operator="is_relative_date_before")
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
self.assertTrue(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)}))
# false because date comparison, instead of datetime, so reduces to same date
self.assertFalse(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
self.assertFalse(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}))
self.assertTrue(
match_property(
property_a,
{"key": datetime.datetime(2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid"))},
)
)
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
# Can't be a number
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": 1})
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": "abcdef"})
property_b = self.property(key="key", value="1h", operator="is_relative_date_after")
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_b, {"key": datetime.datetime(2022, 5, 30)}))
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
# Invalid flag property
property_c = self.property(key="key", value=1234, operator="is_relative_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_c, {"key": 1}))
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_c, {"key": "2022-05-30"}))
# # Timezone aware property
property_d = self.property(key="key", value="12d", operator="is_relative_date_before")
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"}))
# Try all possible relative dates
property_e = self.property(key="key", value="1h", operator="is_relative_date_before")
self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))
property_f = self.property(key="key", value="1d", operator="is_relative_date_before")
self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))
property_g = self.property(key="key", value="1w", operator="is_relative_date_before")
self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))
property_h = self.property(key="key", value="1m", operator="is_relative_date_before")
self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))
property_i = self.property(key="key", value="1y", operator="is_relative_date_before")
self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))
property_j = self.property(key="key", value="122h", operator="is_relative_date_after")
self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))
property_k = self.property(key="key", value="2d", operator="is_relative_date_after")
self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))
property_l = self.property(key="key", value="02w", operator="is_relative_date_after")
self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))
property_m = self.property(key="key", value="1m", operator="is_relative_date_after")
self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))
property_n = self.property(key="key", value="1y", operator="is_relative_date_after")
self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))
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": "non"}))
property_b = self.property(key="key", value=None, operator="is_set")
self.assertTrue(match_property(property_b, {"key": None}))
property_c = self.property(key="key", value="no", operator="icontains")
self.assertTrue(match_property(property_c, {"key": None}))
self.assertFalse(match_property(property_c, {"key": "smh"}))
property_d = self.property(key="key", value="No", operator="regex")
self.assertTrue(match_property(property_d, {"key": None}))
property_d_lower_case = self.property(key="key", value="no", operator="regex")
self.assertFalse(match_property(property_d_lower_case, {"key": None}))
property_e = self.property(key="key", value=1, operator="gt")
self.assertTrue(match_property(property_e, {"key": None}))
property_f = self.property(key="key", value=1, operator="lt")
self.assertFalse(match_property(property_f, {"key": None}))
property_g = self.property(key="key", value="xyz", operator="gte")
self.assertFalse(match_property(property_g, {"key": None}))
property_h = self.property(key="key", value="Oo", operator="lte")
self.assertTrue(match_property(property_h, {"key": None}))
property_i = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_i, {"key": None}))
property_j = self.property(key="key", value="2022-05-01", operator="is_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_j, {"key": None}))
property_k = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_k, {"key": "random"}))
def test_unknown_operator(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
with self.assertRaises(InconclusiveMatchError) as exception_context:
match_property(property_a, {"key": "random"})
self.assertEqual(str(exception_context.exception), "Unknown operator is_unknown")
class TestRelativeDateParsing(unittest.TestCase):
def test_invalid_input(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1") is None
assert relative_date_parse_for_feature_flag_matching("1x") is None
assert relative_date_parse_for_feature_flag_matching("1.2y") is None
assert relative_date_parse_for_feature_flag_matching("1z") is None
assert relative_date_parse_for_feature_flag_matching("1s") is None
assert relative_date_parse_for_feature_flag_matching("123344000.134m") is None
assert relative_date_parse_for_feature_flag_matching("bazinga") is None
assert relative_date_parse_for_feature_flag_matching("000bello") is None
assert relative_date_parse_for_feature_flag_matching("000hello") is None
assert relative_date_parse_for_feature_flag_matching("000h") is not None
assert relative_date_parse_for_feature_flag_matching("1000h") is not None
def test_overflow(self):
assert relative_date_parse_for_feature_flag_matching("1000000h") is None
assert relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
def test_hour_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1h") == datetime.datetime(
2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2h") == datetime.datetime(
2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("24h") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30h") == datetime.datetime(
2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("48h") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"24h"
) == relative_date_parse_for_feature_flag_matching("1d")
assert relative_date_parse_for_feature_flag_matching(
"48h"
) == relative_date_parse_for_feature_flag_matching("2d")
def test_day_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1d") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2d") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("14d") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30d") == datetime.datetime(
2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == relative_date_parse_for_feature_flag_matching(
"1w"
)
def test_week_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1w") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2w") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") == datetime.datetime(
2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8w") == datetime.datetime(
2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") != relative_date_parse_for_feature_flag_matching(
"1m"
)
def test_month_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
with freeze_time("2020-04-03T00:00:00"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
def test_year_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2y") == datetime.datetime(
2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4y") == datetime.datetime(
2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8y") == datetime.datetime(
2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@@ -1888,7 +2204,12 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
@@ -1913,7 +2234,12 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
@@ -1946,7 +2272,12 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{"$feature_flag": "decide-flag", "$feature_flag_response": "decide-value", "locally_evaluated": False},
{
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-value",
"locally_evaluated": False,
"$feature/decide-flag": "decide-value",
},
groups={"organization": "org1"},
disable_geoip=None,
)
@@ -1984,7 +2315,12 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=False,
)
@@ -2018,7 +2354,12 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
distinct_id,
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.0.0"
VERSION = "3.3.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+1 -1
View File
@@ -24,7 +24,7 @@ extras_require = {
"flake8-print",
"pre-commit",
],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest", "pytest-timeout"],
"sentry": ["sentry-sdk", "django"],
}