Compare commits

...
8 Commits
Author SHA1 Message Date
github-actions[bot] 16e180231f chore: Release v7.9.6 2026-03-02 21:28:45 +00:00
8d83315b67 refactor: add PROPERTY_OPERATORS constant for match_property (#448)
* feat: add semver targeting support to local flag evaluation

Implement 9 semver comparison operators (semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, semver_wildcard) for feature flag local evaluation. Uses regex-based parsing that matches the server-side sortableSemver behavior to handle v-prefix, whitespace, pre-release suffixes, and non-standard version formats.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: guard against ReDoS in semver regex parsing

Add input length limit before regex search to prevent polynomial
backtracking on adversarial input (CodeQL py/polynomial-redos).

* fix: replace regex with string parsing to resolve ReDoS warning

Replace SEMVER_EXTRACT_RE regex with simple string splitting to
eliminate nested quantifiers that CodeQL flagged as polynomial-redos.

* refactor: inline semver operator tuple to match existing patterns

* refactor: add PROPERTY_OPERATORS constant for match_property

Extract all operator strings into a single source-of-truth tuple and
validate against it early in match_property, replacing the fallthrough
at the end of the function.

* refactor: split PROPERTY_OPERATORS into composable sub-groups

Break the flat tuple into category-specific tuples (EQUALITY_OPERATORS,
STRING_OPERATORS, etc.) that compose into PROPERTY_OPERATORS via
concatenation. The semver dispatch code now references
SEMVER_OPERATORS and SEMVER_COMPARISON_OPERATORS instead of
repeating the full operator lists inline.

* fix: add unreachable fallthrough to satisfy mypy return check

* add release

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 21:12:09 +00:00
github-actions[bot] 1e1e566fa5 chore: Release v7.9.5 2026-03-02 20:53:53 +00:00
830244bd40 feat: add semver targeting support to local flag evaluation (#447)
* feat: add semver targeting support to local flag evaluation

Implement 9 semver comparison operators (semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, semver_wildcard) for feature flag local evaluation. Uses regex-based parsing that matches the server-side sortableSemver behavior to handle v-prefix, whitespace, pre-release suffixes, and non-standard version formats.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: guard against ReDoS in semver regex parsing

Add input length limit before regex search to prevent polynomial
backtracking on adversarial input (CodeQL py/polynomial-redos).

* fix: replace regex with string parsing to resolve ReDoS warning

Replace SEMVER_EXTRACT_RE regex with simple string splitting to
eliminate nested quantifiers that CodeQL flagged as polynomial-redos.

* refactor: inline semver operator tuple to match existing patterns

* add changeset

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 20:52:37 +00:00
github-actions[bot] 60001f1829 chore: Release v7.9.4 2026-02-25 15:28:29 +00:00
Carlos MarchalandGitHub a68a6a6d04 fix: revert manual release and add sampo changeset for ai_tokens_source (#445) 2026-02-25 16:25:53 +01:00
Andrew MaguireandGitHub 150e24ba6a feat(llma): add $ai_tokens_source property to detect token value overrides (#444)
* feat: add $ai_tokens_source property to detect token value overrides

When users pass token properties (e.g. $ai_input_tokens) via
posthog_properties, these override the SDK-computed values. This new
$ai_tokens_source property ("sdk" or "passthrough") lets us distinguish
whether token values came from the SDK or were externally injected,
which is critical for diagnosing cost calculation discrepancies.

* chore: bump version to 7.9.4

* chore: add changelog entry for 7.9.4

* chore: fix ruff formatting

* chore: remove unused pytest import
2026-02-25 13:38:53 +00:00
Michael BiancoandGitHub a8b5529baf fix: use $ip not $ip_addess (#356) 2026-02-20 07:46:13 +01:00
10 changed files with 512 additions and 15 deletions
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+62
View File
@@ -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
+201
View File
@@ -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
View File
@@ -1 +1 @@
VERSION = "7.9.3"
VERSION = "7.9.6"
+1 -1
View File
@@ -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" }]
Generated
+1 -1
View File
@@ -2129,7 +2129,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.9.3"
version = "7.9.6"
source = { editable = "." }
dependencies = [
{ name = "backoff" },