From a2c73d05367561e617875fd9c26345b3c76454ee Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 9 Jan 2024 11:22:18 +0000 Subject: [PATCH] feat(flags): Add relative date operators, fix numeric ops (#105) --- CHANGELOG.md | 5 + posthog/feature_flags.py | 108 +++++++--- posthog/test/test_feature_flags.py | 322 ++++++++++++++++++++++++++++- posthog/version.py | 2 +- 4 files changed, 408 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887b21c..4647245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 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 diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 17ccdae..3c87d85 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -2,8 +2,10 @@ 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) is type(value) and override_value > value - - if operator == "gte": - return type(override_value) is 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 == "lt": - return type(override_value) is type(value) and override_value < value + parsed_value = None + try: + parsed_value = float(value) # type: ignore + except Exception: + pass - if operator == "lte": - return type(override_value) is type(value) and override_value <= value + 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"]: + if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]: try: - parsed_date = parser.parse(value) - parsed_date = convert_to_datetime_aware(parsed_date) - except Exception: + 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[0-9]+)(?P[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 diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 650a405..42c57b5 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -6,7 +6,7 @@ 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 @@ def test_match_properties_math_operators(self): 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 @@ def test_match_properties_math_operators(self): 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 @@ def test_match_properties_math_operators(self): 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 @@ def test_match_property_date_operators(self): 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") diff --git a/posthog/version.py b/posthog/version.py index 51aac19..21d8fec 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "3.1.0" +VERSION = "3.2.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201