Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e180231f | ||
|
|
8d83315b67 | ||
|
|
1e1e566fa5 | ||
|
|
830244bd40 | ||
|
|
60001f1829 | ||
|
|
a68a6a6d04 | ||
|
|
150e24ba6a | ||
|
|
a8b5529baf |
@@ -1,5 +1,23 @@
|
||||
# posthog
|
||||
|
||||
## 7.9.6 — 2026-03-02
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [8d83315](https://github.com/posthog/posthog-python/commit/8d83315b67c21eb9e7d6c17bae27ada98ca2643d) add PROPERTY_OPERATORS constant for match_property — Thanks @dmarticus!
|
||||
|
||||
## 7.9.5 — 2026-03-02
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [830244b](https://github.com/posthog/posthog-python/commit/830244bd409b1992ae2e49610f8f87d2cdfc8096) add semver targeting support to local evaluation — Thanks @dmarticus!
|
||||
|
||||
## 7.9.4 — 2026-02-25
|
||||
|
||||
### Patch changes
|
||||
|
||||
- [a68a6a6](https://github.com/posthog/posthog-python/commit/a68a6a6d045072c88eeee7acac441536919b5954) feat(llma): add `$ai_tokens_source` property ("sdk" or "passthrough") to all `$ai_generation` events to detect when token values are externally overridden via `posthog_properties` — Thanks @carlos-marchal-ph!
|
||||
|
||||
## 7.9.3 — 2026-02-18
|
||||
|
||||
### Patch changes
|
||||
|
||||
+51
-10
@@ -13,6 +13,28 @@ from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
_TOKEN_PROPERTY_KEYS = frozenset(
|
||||
{
|
||||
"$ai_input_tokens",
|
||||
"$ai_output_tokens",
|
||||
"$ai_cache_read_input_tokens",
|
||||
"$ai_cache_creation_input_tokens",
|
||||
"$ai_total_tokens",
|
||||
"$ai_reasoning_tokens",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_tokens_source(
|
||||
sdk_tags: Dict[str, Any], posthog_properties: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
if posthog_properties and any(
|
||||
key in posthog_properties for key in _TOKEN_PROPERTY_KEYS
|
||||
):
|
||||
return "passthrough"
|
||||
return "sdk"
|
||||
|
||||
|
||||
def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Convert raw provider usage objects to JSON-serializable dicts.
|
||||
@@ -413,14 +435,19 @@ def call_llm_and_track_usage(
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
sdk_tags = get_tags()
|
||||
merged_properties = {
|
||||
**sdk_tags,
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
merged_properties["$ai_tokens_source"] = _get_tokens_source(
|
||||
sdk_tags, posthog_properties
|
||||
)
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties={
|
||||
**get_tags(),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
},
|
||||
properties=merged_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
@@ -543,14 +570,19 @@ async def call_llm_and_track_usage_async(
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
sdk_tags = get_tags()
|
||||
merged_properties = {
|
||||
**sdk_tags,
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
merged_properties["$ai_tokens_source"] = _get_tokens_source(
|
||||
sdk_tags, posthog_properties
|
||||
)
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties={
|
||||
**get_tags(),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
},
|
||||
properties=merged_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
@@ -627,6 +659,15 @@ def capture_streaming_event(
|
||||
**(event_data.get("properties") or {}),
|
||||
}
|
||||
|
||||
# Determine token source: SDK-computed vs externally overridden
|
||||
sdk_token_tags = {
|
||||
"$ai_input_tokens": event_data["usage_stats"].get("input_tokens", 0),
|
||||
"$ai_output_tokens": event_data["usage_stats"].get("output_tokens", 0),
|
||||
}
|
||||
event_properties["$ai_tokens_source"] = _get_tokens_source(
|
||||
sdk_token_tags, event_data.get("properties")
|
||||
)
|
||||
|
||||
# Extract and add tools based on provider
|
||||
available_tools = extract_available_tool_calls(
|
||||
event_data["provider"],
|
||||
|
||||
+157
-1
@@ -18,6 +18,30 @@ log = logging.getLogger("posthog")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
# All operators supported by match_property, grouped by category.
|
||||
EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set")
|
||||
STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex")
|
||||
NUMERIC_OPERATORS = ("gt", "gte", "lt", "lte")
|
||||
DATE_OPERATORS = ("is_date_before", "is_date_after")
|
||||
SEMVER_COMPARISON_OPERATORS = (
|
||||
"semver_eq",
|
||||
"semver_neq",
|
||||
"semver_gt",
|
||||
"semver_gte",
|
||||
"semver_lt",
|
||||
"semver_lte",
|
||||
)
|
||||
SEMVER_RANGE_OPERATORS = ("semver_tilde", "semver_caret", "semver_wildcard")
|
||||
SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + SEMVER_RANGE_OPERATORS
|
||||
|
||||
PROPERTY_OPERATORS = (
|
||||
EQUALITY_OPERATORS
|
||||
+ STRING_OPERATORS
|
||||
+ NUMERIC_OPERATORS
|
||||
+ DATE_OPERATORS
|
||||
+ SEMVER_OPERATORS
|
||||
)
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
@@ -385,6 +409,9 @@ def match_property(property, property_values) -> bool:
|
||||
operator = property.get("operator") or "exact"
|
||||
value = property.get("value")
|
||||
|
||||
if operator not in PROPERTY_OPERATORS:
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match properties without a given property value"
|
||||
@@ -505,7 +532,64 @@ def match_property(property, property_values) -> bool:
|
||||
"The date provided must be a string or date object"
|
||||
)
|
||||
|
||||
# if we get here, we don't know how to handle the operator
|
||||
if operator in SEMVER_OPERATORS:
|
||||
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_COMPARISON_OPERATORS:
|
||||
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
|
||||
|
||||
# Unreachable: all operators in PROPERTY_OPERATORS are handled above,
|
||||
# and unknown operators are rejected at the top of this function.
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
@@ -686,3 +770,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)
|
||||
|
||||
@@ -155,7 +155,7 @@ class PosthogContextMiddleware:
|
||||
# Extract IP address
|
||||
ip_address = request.headers.get("X-Forwarded-For")
|
||||
if ip_address:
|
||||
tags["$ip_address"] = ip_address
|
||||
tags["$ip"] = ip_address
|
||||
|
||||
# Extract user agent
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
|
||||
@@ -306,6 +306,7 @@ def test_basic_completion(mock_client, mock_anthropic_response):
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert props["$ai_tokens_source"] == "sdk"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
# Verify raw usage metadata is passed for backend processing
|
||||
assert "$ai_usage" in props
|
||||
@@ -318,6 +319,23 @@ def test_basic_completion(mock_client, mock_anthropic_response):
|
||||
assert "output_tokens" in props["$ai_usage"]
|
||||
|
||||
|
||||
def test_tokens_source_passthrough(mock_client, mock_anthropic_response):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"$ai_input_tokens": 99999},
|
||||
)
|
||||
|
||||
props = mock_client.capture.call_args[1]["properties"]
|
||||
assert props["$ai_tokens_source"] == "passthrough"
|
||||
assert props["$ai_input_tokens"] == 99999
|
||||
|
||||
|
||||
def test_groups(mock_client, mock_anthropic_response):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
|
||||
@@ -927,6 +945,7 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
|
||||
assert props["$ai_output_tokens"] == 25
|
||||
assert props["$ai_cache_read_input_tokens"] == 5
|
||||
assert props["$ai_cache_creation_input_tokens"] == 0
|
||||
assert props["$ai_tokens_source"] == "sdk"
|
||||
|
||||
# Verify raw usage is captured in streaming mode (merged from events)
|
||||
assert "$ai_usage" in props
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.ai.utils import _get_tokens_source
|
||||
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("no_posthog_properties", {"$ai_input_tokens": 100}, None, "sdk"),
|
||||
("empty_posthog_properties", {"$ai_input_tokens": 100}, {}, "sdk"),
|
||||
(
|
||||
"unrelated_posthog_properties",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"foo": "bar"},
|
||||
"sdk",
|
||||
),
|
||||
(
|
||||
"override_input_tokens",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_input_tokens": 999},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"override_output_tokens",
|
||||
{"$ai_output_tokens": 50},
|
||||
{"$ai_output_tokens": 999},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"override_total_tokens",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_total_tokens": 999},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"override_cache_read",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_cache_read_input_tokens": 500},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"override_cache_creation",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_cache_creation_input_tokens": 200},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"override_reasoning_tokens",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_reasoning_tokens": 300},
|
||||
"passthrough",
|
||||
),
|
||||
(
|
||||
"mixed_override_and_custom",
|
||||
{"$ai_input_tokens": 100},
|
||||
{"$ai_input_tokens": 999, "custom_key": "value"},
|
||||
"passthrough",
|
||||
),
|
||||
]
|
||||
)
|
||||
def test_get_tokens_source(name, sdk_tags, posthog_properties, expected):
|
||||
result = _get_tokens_source(sdk_tags, posthog_properties)
|
||||
assert result == expected
|
||||
@@ -4198,6 +4198,207 @@ class TestMatchProperties(unittest.TestCase):
|
||||
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:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
VERSION = "7.9.3"
|
||||
VERSION = "7.9.6"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "posthog"
|
||||
version = "7.9.3"
|
||||
version = "7.9.6"
|
||||
description = "Integrate PostHog into any python application."
|
||||
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
|
||||
Reference in New Issue
Block a user