Skip to content

Commit a2c73d0

Browse files
authored
feat(flags): Add relative date operators, fix numeric ops (#105)
1 parent 33ba5d6 commit a2c73d0

File tree

4 files changed

+408
-29
lines changed

4 files changed

+408
-29
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 3.2.0 - 2024-01-09
2+
3+
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.
4+
2. Add support for relative date operators for local evaluation.
5+
16
## 3.1.0 - 2023-12-04
27

38
1. Increase maximum event size and batch size

posthog/feature_flags.py

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import hashlib
33
import logging
44
import re
5+
from typing import Optional
56

67
from dateutil import parser
8+
from dateutil.relativedelta import relativedelta
79

810
from posthog.utils import convert_to_datetime_aware, is_valid_regex
911

@@ -117,15 +119,17 @@ def match_property(property, property_values) -> bool:
117119

118120
override_value = property_values[key]
119121

120-
if operator == "exact":
121-
if isinstance(value, list):
122-
return override_value in value
123-
return value == override_value
122+
if operator in ("exact", "is_not"):
124123

125-
if operator == "is_not":
126-
if isinstance(value, list):
127-
return override_value not in value
128-
return value != override_value
124+
def compute_exact_match(value, override_value):
125+
if isinstance(value, list):
126+
return str(override_value).lower() in [str(val).lower() for val in value]
127+
return str(value).lower() == str(override_value).lower()
128+
129+
if operator == "exact":
130+
return compute_exact_match(value, override_value)
131+
else:
132+
return not compute_exact_match(value, override_value)
129133

130134
if operator == "is_set":
131135
return key in property_values
@@ -142,41 +146,64 @@ def match_property(property, property_values) -> bool:
142146
if operator == "not_regex":
143147
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
144148

145-
if operator == "gt":
146-
return type(override_value) is type(value) and override_value > value
147-
148-
if operator == "gte":
149-
return type(override_value) is type(value) and override_value >= value
149+
if operator in ("gt", "gte", "lt", "lte"):
150+
# :TRICKY: We adjust comparison based on the override value passed in,
151+
# to make sure we handle both numeric and string comparisons appropriately.
152+
def compare(lhs, rhs, operator):
153+
if operator == "gt":
154+
return lhs > rhs
155+
elif operator == "gte":
156+
return lhs >= rhs
157+
elif operator == "lt":
158+
return lhs < rhs
159+
elif operator == "lte":
160+
return lhs <= rhs
161+
else:
162+
raise ValueError(f"Invalid operator: {operator}")
150163

151-
if operator == "lt":
152-
return type(override_value) is type(value) and override_value < value
164+
parsed_value = None
165+
try:
166+
parsed_value = float(value) # type: ignore
167+
except Exception:
168+
pass
153169

154-
if operator == "lte":
155-
return type(override_value) is type(value) and override_value <= value
170+
if parsed_value is not None and override_value is not None:
171+
if isinstance(override_value, str):
172+
return compare(override_value, str(value), operator)
173+
else:
174+
return compare(override_value, parsed_value, operator)
175+
else:
176+
return compare(str(override_value), str(value), operator)
156177

157-
if operator in ["is_date_before", "is_date_after"]:
178+
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
158179
try:
159-
parsed_date = parser.parse(value)
160-
parsed_date = convert_to_datetime_aware(parsed_date)
161-
except Exception:
180+
if operator in ["is_relative_date_before", "is_relative_date_after"]:
181+
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
182+
else:
183+
parsed_date = parser.parse(str(value))
184+
parsed_date = convert_to_datetime_aware(parsed_date)
185+
except Exception as e:
186+
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
187+
188+
if not parsed_date:
162189
raise InconclusiveMatchError("The date set on the flag is not a valid format")
163190

164191
if isinstance(override_value, datetime.datetime):
165192
override_date = convert_to_datetime_aware(override_value)
166-
if operator == "is_date_before":
193+
if operator in ("is_date_before", "is_relative_date_before"):
167194
return override_date < parsed_date
168195
else:
169196
return override_date > parsed_date
170197
elif isinstance(override_value, datetime.date):
171-
if operator == "is_date_before":
198+
if operator in ("is_date_before", "is_relative_date_before"):
172199
return override_value < parsed_date.date()
173200
else:
174201
return override_value > parsed_date.date()
175202
elif isinstance(override_value, str):
176203
try:
177204
override_date = parser.parse(override_value)
178205
override_date = convert_to_datetime_aware(override_date)
179-
if operator == "is_date_before":
206+
if operator in ("is_date_before", "is_relative_date_before"):
180207
return override_date < parsed_date
181208
else:
182209
return override_date > parsed_date
@@ -185,7 +212,8 @@ def match_property(property, property_values) -> bool:
185212
else:
186213
raise InconclusiveMatchError("The date provided must be a string or date object")
187214

188-
return False
215+
# if we get here, we don't know how to handle the operator
216+
raise InconclusiveMatchError(f"Unknown operator {operator}")
189217

190218

191219
def match_cohort(property, property_values, cohort_properties) -> bool:
@@ -271,3 +299,33 @@ def match_property_group(property_group, property_values, cohort_properties) ->
271299

272300
# if we get here, all matched in AND case, or none matched in OR case
273301
return property_group_type == "AND"
302+
303+
304+
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
305+
regex = r"^(?P<number>[0-9]+)(?P<interval>[a-z])$"
306+
match = re.search(regex, value)
307+
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
308+
if match:
309+
number = int(match.group("number"))
310+
311+
if number >= 10_000:
312+
# Guard against overflow, disallow numbers greater than 10_000
313+
return None
314+
315+
interval = match.group("interval")
316+
if interval == "h":
317+
parsed_dt = parsed_dt - relativedelta(hours=number)
318+
elif interval == "d":
319+
parsed_dt = parsed_dt - relativedelta(days=number)
320+
elif interval == "w":
321+
parsed_dt = parsed_dt - relativedelta(weeks=number)
322+
elif interval == "m":
323+
parsed_dt = parsed_dt - relativedelta(months=number)
324+
elif interval == "y":
325+
parsed_dt = parsed_dt - relativedelta(years=number)
326+
else:
327+
return None
328+
329+
return parsed_dt
330+
else:
331+
return None

0 commit comments

Comments
 (0)