diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 93b8119e..5a5bf8e9 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -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", + ): + 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}") @@ -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) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 1b0192ff..1d94c031 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -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: