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
145 changes: 145 additions & 0 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,79 @@ def compare(lhs, rhs, operator):
"The date provided must be a string or date object"
)

if operator in (
"semver_eq",
"semver_neq",
"semver_gt",
"semver_gte",
"semver_lt",
"semver_lte",
"semver_tilde",
"semver_caret",
"semver_wildcard",
):
Comment on lines +508 to +518
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is ugly, but the inline tuple approach matches the rest of the repo. I have refactored this in #448 fwiw.

try:
override_parsed = parse_semver(override_value)
except (ValueError, TypeError):
raise InconclusiveMatchError(
f"Person property value '{override_value}' is not a valid semver"
)

if operator in (
"semver_eq",
"semver_neq",
"semver_gt",
"semver_gte",
"semver_lt",
"semver_lte",
):
try:
flag_parsed = parse_semver(value)
except (ValueError, TypeError):
raise InconclusiveMatchError(
f"Flag semver value '{value}' is not a valid semver"
)

if operator == "semver_eq":
return override_parsed == flag_parsed
elif operator == "semver_neq":
return override_parsed != flag_parsed
elif operator == "semver_gt":
return override_parsed > flag_parsed
elif operator == "semver_gte":
return override_parsed >= flag_parsed
elif operator == "semver_lt":
return override_parsed < flag_parsed
elif operator == "semver_lte":
return override_parsed <= flag_parsed

elif operator == "semver_tilde":
try:
lower, upper = _tilde_bounds(str(value))
except (ValueError, TypeError):
raise InconclusiveMatchError(
f"Flag semver value '{value}' is not valid for tilde operator"
)
return lower <= override_parsed < upper

elif operator == "semver_caret":
try:
lower, upper = _caret_bounds(str(value))
except (ValueError, TypeError):
raise InconclusiveMatchError(
f"Flag semver value '{value}' is not valid for caret operator"
)
return lower <= override_parsed < upper

elif operator == "semver_wildcard":
try:
lower, upper = _wildcard_bounds(str(value))
except (ValueError, TypeError):
raise InconclusiveMatchError(
f"Flag semver value '{value}' is not valid for wildcard operator"
)
return lower <= override_parsed < upper

# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")

Expand Down Expand Up @@ -686,3 +759,75 @@ def relative_date_parse_for_feature_flag_matching(
return parsed_dt
else:
return None


def parse_semver(value: str) -> tuple:
"""Parse a semver string into a comparable (major, minor, patch) integer tuple.

Matches the behavior of the sortableSemver HogQL function:
- Handles v-prefix, whitespace, pre-release suffixes
- Defaults missing components to 0 (e.g., 1.2 -> 1.2.0)
Raises ValueError if parsing fails.
"""
text = str(value).strip().lstrip("vV")
# Strip pre-release/build metadata suffix
text = text.split("-")[0].split("+")[0]
parts = text.split(".")

if not parts or not parts[0]:
raise ValueError("Invalid semver format")

major = int(parts[0])
minor = int(parts[1]) if len(parts) > 1 and parts[1] else 0
patch = int(parts[2]) if len(parts) > 2 and parts[2] else 0

return (major, minor, patch)


def _tilde_bounds(value: str) -> tuple:
"""~1.2.3 means >=1.2.3 <1.3.0 (allows patch-level changes)."""
major, minor, patch = parse_semver(value)
return (major, minor, patch), (major, minor + 1, 0)


def _caret_bounds(value: str) -> tuple:
"""Caret follows semver spec:
^1.2.3 means >=1.2.3 <2.0.0
^0.2.3 means >=0.2.3 <0.3.0
^0.0.3 means >=0.0.3 <0.0.4
"""
major, minor, patch = parse_semver(value)
lower = (major, minor, patch)

if major > 0:
upper = (major + 1, 0, 0)
elif minor > 0:
upper = (0, minor + 1, 0)
else:
upper = (0, 0, patch + 1)

return lower, upper


def _wildcard_bounds(value: str) -> tuple:
"""Wildcard matching:
1.* means >=1.0.0 <2.0.0
1.2.* means >=1.2.0 <1.3.0
"""
cleaned = str(value).strip().lstrip("vV").replace("*", "").rstrip(".")
if not cleaned:
raise ValueError("Invalid wildcard pattern")

parts = [p for p in cleaned.split(".") if p]
if not parts:
raise ValueError("Invalid wildcard pattern")

if len(parts) == 1:
major = int(parts[0])
return (major, 0, 0), (major + 1, 0, 0)
elif len(parts) == 2:
major, minor = int(parts[0]), int(parts[1])
return (major, minor, 0), (major, minor + 1, 0)
else:
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
return (major, minor, patch), (major, minor, patch + 1)
201 changes: 201 additions & 0 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -4198,6 +4198,207 @@ def test_none_property_value_with_all_operators(self):
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_k, {"key": "random"}))

def test_match_properties_semver_eq(self):
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))

# Pre-release suffix is stripped for comparison
self.assertTrue(match_property(prop, {"version": "1.2.3-alpha.1"}))

# Partial versions default missing parts to 0
prop_partial = self.property(key="version", value="1.2", operator="semver_eq")
self.assertTrue(match_property(prop_partial, {"version": "1.2.0"}))
self.assertFalse(match_property(prop_partial, {"version": "1.2.1"}))

def test_match_properties_semver_neq(self):
prop = self.property(key="version", value="1.2.3", operator="semver_neq")
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
self.assertTrue(match_property(prop, {"version": "2.0.0"}))

def test_match_properties_semver_gt(self):
prop = self.property(key="version", value="1.2.3", operator="semver_gt")
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
self.assertTrue(match_property(prop, {"version": "1.3.0"}))
self.assertTrue(match_property(prop, {"version": "2.0.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
self.assertFalse(match_property(prop, {"version": "0.9.0"}))

def test_match_properties_semver_gte(self):
prop = self.property(key="version", value="1.2.3", operator="semver_gte")
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
self.assertTrue(match_property(prop, {"version": "1.2.4"}))
self.assertTrue(match_property(prop, {"version": "2.0.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
self.assertFalse(match_property(prop, {"version": "0.9.0"}))

def test_match_properties_semver_lt(self):
prop = self.property(key="version", value="1.2.3", operator="semver_lt")
self.assertTrue(match_property(prop, {"version": "1.2.2"}))
self.assertTrue(match_property(prop, {"version": "1.1.0"}))
self.assertTrue(match_property(prop, {"version": "0.9.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.3"}))
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))

def test_match_properties_semver_lte(self):
prop = self.property(key="version", value="1.2.3", operator="semver_lte")
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
self.assertTrue(match_property(prop, {"version": "1.2.2"}))
self.assertTrue(match_property(prop, {"version": "0.9.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.4"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))

def test_match_properties_semver_tilde(self):
# ~1.2.3 means >=1.2.3 <1.3.0
prop = self.property(key="version", value="1.2.3", operator="semver_tilde")
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
self.assertTrue(match_property(prop, {"version": "1.2.5"}))
self.assertTrue(match_property(prop, {"version": "1.2.99"}))
self.assertFalse(match_property(prop, {"version": "1.3.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))

def test_match_properties_semver_caret(self):
# ^1.2.3 means >=1.2.3 <2.0.0
prop = self.property(key="version", value="1.2.3", operator="semver_caret")
self.assertTrue(match_property(prop, {"version": "1.2.3"}))
self.assertTrue(match_property(prop, {"version": "1.9.0"}))
self.assertTrue(match_property(prop, {"version": "1.99.99"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))
self.assertFalse(match_property(prop, {"version": "1.2.2"}))
self.assertFalse(match_property(prop, {"version": "0.9.0"}))

# ^0.2.3 means >=0.2.3 <0.3.0 (leftmost non-zero is minor)
prop_zero_major = self.property(
key="version", value="0.2.3", operator="semver_caret"
)
self.assertTrue(match_property(prop_zero_major, {"version": "0.2.3"}))
self.assertTrue(match_property(prop_zero_major, {"version": "0.2.9"}))
self.assertFalse(match_property(prop_zero_major, {"version": "0.3.0"}))
self.assertFalse(match_property(prop_zero_major, {"version": "1.0.0"}))

# ^0.0.3 means >=0.0.3 <0.0.4 (leftmost non-zero is patch)
prop_zero_minor = self.property(
key="version", value="0.0.3", operator="semver_caret"
)
self.assertTrue(match_property(prop_zero_minor, {"version": "0.0.3"}))
self.assertFalse(match_property(prop_zero_minor, {"version": "0.0.4"}))
self.assertFalse(match_property(prop_zero_minor, {"version": "0.1.0"}))

def test_match_properties_semver_wildcard(self):
# 1.2.* means >=1.2.0 <1.3.0
prop = self.property(key="version", value="1.2.*", operator="semver_wildcard")
self.assertTrue(match_property(prop, {"version": "1.2.0"}))
self.assertTrue(match_property(prop, {"version": "1.2.5"}))
self.assertTrue(match_property(prop, {"version": "1.2.99"}))
self.assertFalse(match_property(prop, {"version": "1.3.0"}))
self.assertFalse(match_property(prop, {"version": "1.1.9"}))
self.assertFalse(match_property(prop, {"version": "2.0.0"}))

# 1.* means >=1.0.0 <2.0.0
prop_major = self.property(
key="version", value="1.*", operator="semver_wildcard"
)
self.assertTrue(match_property(prop_major, {"version": "1.0.0"}))
self.assertTrue(match_property(prop_major, {"version": "1.99.99"}))
self.assertFalse(match_property(prop_major, {"version": "2.0.0"}))
self.assertFalse(match_property(prop_major, {"version": "0.9.0"}))

def test_match_properties_semver_with_prerelease(self):
# Pre-release suffixes are stripped before comparison
prop = self.property(key="version", value="1.2.3", operator="semver_gt")
self.assertTrue(match_property(prop, {"version": "1.3.0-beta.1"}))
self.assertFalse(match_property(prop, {"version": "1.2.2-rc.1"}))

# Flag value can also have pre-release suffix
prop_pre = self.property(
key="version", value="1.2.3-alpha", operator="semver_gte"
)
self.assertTrue(match_property(prop_pre, {"version": "1.2.3"}))
self.assertTrue(match_property(prop_pre, {"version": "2.0.0"}))
self.assertFalse(match_property(prop_pre, {"version": "1.2.2"}))

def test_match_properties_semver_edge_cases(self):
"""Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats."""
prop = self.property(key="version", value="1.2.3", operator="semver_eq")

# v-prefix: "v1.2.3" -> extracts "1.2.3"
self.assertTrue(match_property(prop, {"version": "v1.2.3"}))

# Leading space: " 1.2.3" -> extracts "1.2.3"
self.assertTrue(match_property(prop, {"version": " 1.2.3"}))

# Trailing space: "1.2.3 " -> extracts "1.2.3"
self.assertTrue(match_property(prop, {"version": "1.2.3 "}))

# Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3
self.assertTrue(match_property(prop, {"version": "01.02.03"}))

# Flag value with v-prefix
prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq")
self.assertTrue(match_property(prop_v, {"version": "1.2.3"}))

# 0.0.0 minimal version
prop_min = self.property(key="version", value="0.0.0", operator="semver_eq")
self.assertTrue(match_property(prop_min, {"version": "0.0.0"}))

prop_gt_min = self.property(key="version", value="0.0.0", operator="semver_gt")
self.assertTrue(match_property(prop_gt_min, {"version": "0.0.1"}))
self.assertFalse(match_property(prop_gt_min, {"version": "0.0.0"}))

# 4-part version: regex extracts "1.2.3.4" -> takes first 3 parts
prop_four = self.property(key="version", value="1.2.3", operator="semver_eq")
self.assertTrue(match_property(prop_four, {"version": "1.2.3.4"}))

# Truly invalid values raise InconclusiveMatchError
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": "abc"})

with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": ""})

# Leading dot: ".1.2.3" -> invalid, empty first component
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": ".1.2.3"})

# Caret with v-prefix in flag value
prop_caret_v = self.property(
key="version", value="v1.2.3", operator="semver_caret"
)
self.assertTrue(match_property(prop_caret_v, {"version": "1.5.0"}))
self.assertFalse(match_property(prop_caret_v, {"version": "2.0.0"}))

# Wildcard with v-prefix in property value
prop_wild = self.property(
key="version", value="1.2.*", operator="semver_wildcard"
)
self.assertTrue(match_property(prop_wild, {"version": "v1.2.5"}))
self.assertFalse(match_property(prop_wild, {"version": "v1.3.0"}))

def test_match_properties_semver_invalid_values(self):
prop = self.property(key="version", value="1.2.3", operator="semver_eq")

# Invalid person property value
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": "not-a-version"})

# Missing key
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"other_key": "1.2.3"})

# None override value returns False (handled before semver logic)
self.assertFalse(match_property(prop, {"version": None}))

# Invalid flag value
prop_bad = self.property(key="version", value="not-valid", operator="semver_gt")
with self.assertRaises(InconclusiveMatchError):
match_property(prop_bad, {"version": "1.2.3"})

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:
Expand Down
Loading