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
27 changes: 21 additions & 6 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,12 +840,22 @@ def relative_date_parse_for_feature_flag_matching(
return None


def _semver_numeric_identifier(part: str) -> int:
# Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
if not part or not part.isdigit():
raise ValueError(f"Invalid semver numeric identifier: '{part}'")
if len(part) > 1 and part[0] == "0":
raise ValueError(f"Semver numeric identifier has leading zero: '{part}'")
return int(part)


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)
- Rejects numeric identifiers with leading zeros per semver 2.0.0 §2
Raises ValueError if parsing fails.
"""
text = str(value).strip().lstrip("vV")
Expand All @@ -856,9 +866,9 @@ def parse_semver(value: str) -> tuple:
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
major = _semver_numeric_identifier(parts[0])
minor = _semver_numeric_identifier(parts[1]) if len(parts) > 1 and parts[1] else 0
patch = _semver_numeric_identifier(parts[2]) if len(parts) > 2 and parts[2] else 0

return (major, minor, patch)

Expand Down Expand Up @@ -902,11 +912,16 @@ def _wildcard_bounds(value: str) -> tuple:
raise ValueError("Invalid wildcard pattern")

if len(parts) == 1:
major = int(parts[0])
major = _semver_numeric_identifier(parts[0])
return (major, 0, 0), (major + 1, 0, 0)
elif len(parts) == 2:
major, minor = int(parts[0]), int(parts[1])
major, minor = (
_semver_numeric_identifier(parts[0]),
_semver_numeric_identifier(parts[1]),
)
return (major, minor, 0), (major, minor + 1, 0)
else:
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
major = _semver_numeric_identifier(parts[0])
minor = _semver_numeric_identifier(parts[1])
patch = _semver_numeric_identifier(parts[2])
return (major, minor, patch), (major, minor, patch + 1)
49 changes: 45 additions & 4 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -4495,7 +4495,7 @@ def test_match_properties_semver_with_prerelease(self):
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."""
"""Test semver parsing handles v-prefix, whitespace, 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"
Expand All @@ -4507,9 +4507,6 @@ def test_match_properties_semver_edge_cases(self):
# 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"}))
Expand Down Expand Up @@ -4570,6 +4567,50 @@ def test_match_properties_semver_invalid_values(self):
with self.assertRaises(InconclusiveMatchError):
match_property(prop_bad, {"version": "1.2.3"})

# Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
@parameterized.expand(
[
("major", "01.2.3"),
("minor", "1.02.3"),
("patch", "1.2.03"),
("all_components", "01.02.03"),
("two_digit_minor", "1.07.3"),
("triple_zero_major", "001.2.3"),
]
)
def test_match_properties_semver_rejects_leading_zero_override_value(
self, _name, bad_value
):
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": bad_value})

@parameterized.expand(
[
("zero_major", "0.1.0"),
("zero_patch", "1.0.0"),
("all_zero", "0.0.0"),
]
)
def test_match_properties_semver_literal_zero_components_match(self, _name, value):
prop = self.property(key="version", value=value, operator="semver_eq")
self.assertTrue(match_property(prop, {"version": value}))

@parameterized.expand(
[
("semver_gt", "01.2.3"),
("semver_caret", "1.07.0"),
("semver_tilde", "1.07.0"),
("semver_wildcard", "01.*"),
]
)
def test_match_properties_semver_rejects_leading_zero_flag_value(
self, operator, flag_value
):
prop = self.property(key="version", value=flag_value, operator=operator)
with self.assertRaises(InconclusiveMatchError):
match_property(prop, {"version": "1.2.0"})

def test_unknown_operator(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
Comment thread
dmarticus marked this conversation as resolved.
with self.assertRaises(InconclusiveMatchError) as exception_context:
Expand Down
Loading