Compare commits

..
4 Commits
7 changed files with 329 additions and 8 deletions
+17
View File
@@ -1,3 +1,20 @@
## 2.2.0 - 2022-11-14
Changes:
1. Add support for feature flag variant overrides with local evaluation
## 2.1.2 - 2022-09-15
Changes:
1. Fixes issues with date comparison.
## 2.1.1 - 2022-09-14
Changes:
1. Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
## 2.1.0 - 2022-08-11
Changes:
+1 -1
View File
@@ -39,7 +39,7 @@ Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an eve
### Releasing Versions
Updated are released using GitHub Actions: after bumping `version.py` in `master`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
## Questions?
+18 -2
View File
@@ -8,14 +8,30 @@ import posthog
posthog.debug = True
# You can find this key on the /setup page in PostHog
posthog.project_api_key = ""
posthog.personal_api_key = ""
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = "http://localhost:8000"
posthog.poll_interval = 10
print(
posthog.feature_enabled(
"person-on-events-enabled",
"12345",
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
group_properties={
"organization": {
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
"created_at": "2022-06-30 11:44:52.984121+00:00",
}
},
only_evaluate_locally=True,
)
)
exit()
# Capture an event
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
+51 -3
View File
@@ -1,7 +1,10 @@
import datetime
import hashlib
import re
from posthog.utils import is_valid_regex
from dateutil import parser
from posthog.utils import convert_to_datetime_aware, is_valid_regex
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
@@ -43,12 +46,26 @@ def match_feature_flag_properties(flag, distinct_id, properties):
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
for condition in flag_conditions:
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
# evaluated first, and the variant override is applied to the first matching condition.
sorted_flag_conditions = sorted(
flag_conditions,
key=lambda condition: 0 if condition.get("variant") else 1,
)
for condition in sorted_flag_conditions:
try:
# if any one condition resolves to True, we can shortcircuit and return
# the matching variant
if is_condition_match(flag, distinct_id, condition, properties):
return get_matching_variant(flag, distinct_id) or True
variant_override = condition.get("variant")
# Some filters can be explicitly set to null, which require accessing variants like so
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
variant = variant_override
else:
variant = get_matching_variant(flag, distinct_id)
return variant or True
except InconclusiveMatchError:
is_inconclusive = True
@@ -126,4 +143,35 @@ def match_property(property, property_values) -> bool:
if operator == "lte":
return type(override_value) == type(value) and override_value <= value
if operator in ["is_date_before", "is_date_after"]:
try:
parsed_date = parser.parse(value)
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception:
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":
return override_date < parsed_date
else:
return override_date > parsed_date
elif isinstance(override_value, datetime.date):
if operator == "is_date_before":
return override_value < parsed_date.date()
else:
return override_value > parsed_date.date()
elif isinstance(override_value, str):
try:
override_date = parser.parse(override_value)
override_date = convert_to_datetime_aware(override_date)
if operator == "is_date_before":
return override_date < parsed_date
else:
return override_date > parsed_date
except Exception:
raise InconclusiveMatchError("The date provided is not a valid format")
else:
raise InconclusiveMatchError("The date provided must be a string or date object")
return False
+234
View File
@@ -1,6 +1,8 @@
import datetime
import unittest
import mock
from dateutil import parser, tz
from freezegun import freeze_time
from posthog.client import Client
@@ -967,6 +969,186 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_get_feature_flag_with_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"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,
"variant": "second-variant",
},
{"rollout_percentage": 50, "variant": "first-variant"},
],
"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},
]
},
},
}
]
self.assertEqual(
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}),
"second-variant",
)
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "first-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_clashing_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"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,
"variant": "second-variant",
},
# since second-variant comes first in the list, it will be the one that gets picked
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
"variant": "first-variant",
},
{"rollout_percentage": 50, "variant": "first-variant"},
],
"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},
]
},
},
}
]
self.assertEqual(
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}),
"second-variant",
)
self.assertEqual(
client.get_feature_flag("beta-feature", "example_id", person_properties={"email": "test@posthog.com"}),
"second-variant",
)
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_invalid_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"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,
"variant": "second???",
},
{"rollout_percentage": 50, "variant": "first??"},
],
"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},
]
},
},
}
]
self.assertEqual(
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}),
"third-variant",
)
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "second-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_multiple_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"rollout_percentage": 100,
# The override applies even if the first condition matches all and gives everyone their default group
},
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
"variant": "second-variant",
},
{"rollout_percentage": 50, "variant": "third-variant"},
],
"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},
]
},
},
}
]
self.assertEqual(
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}),
"second-variant",
)
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "third-variant")
self.assertEqual(client.get_feature_flag("beta-feature", "another_id"), "second-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
class TestMatchProperties(unittest.TestCase):
def property(self, key, value, operator=None):
@@ -1118,6 +1300,58 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_d, {"key": "44"}))
self.assertFalse(match_property(property_d, {"key": 44}))
def test_match_property_date_operators(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_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.date(2022, 4, 30)}))
self.assertTrue(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 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="2022-05-01", operator="is_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):
match_property(property_b, {"key": "abcdef"})
# Invalid flag property
property_c = self.property(key="key", value=1234, operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
match_property(property_c, {"key": 1})
# Timezone aware property
property_d = self.property(key="key", value="2022-04-05 12:34:12 +01:00", operator="is_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-05 12:34:11 +01:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-05 12:34:13 +01:00"}))
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"}))
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
+7 -1
View File
@@ -2,7 +2,7 @@ import logging
import numbers
import re
from collections import defaultdict
from datetime import date, datetime
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
@@ -109,3 +109,9 @@ class SizeLimitedDict(defaultdict):
self.clear()
super().__setitem__(key, value)
def convert_to_datetime_aware(date_obj):
if date_obj.tzinfo is None:
date_obj = date_obj.replace(tzinfo=timezone.utc)
return date_obj
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "2.1.0"
VERSION = "2.2.0"
if __name__ == "__main__":
print(VERSION, end="")