Skip to content
Merged
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
51 changes: 51 additions & 0 deletions .github/scripts/check_crap_threshold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Fail when pytest-crap reports a CRAP score at or above a threshold."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

from coverage import CoverageData
from pytest_crap.calculator import calculate_crap


def covered_lines_for_file(coverage_file: Path, source_file: Path) -> set[int]:
data = CoverageData(basename=str(coverage_file))
data.read()

source_file = source_file.resolve()
for measured_file in data.measured_files():
if Path(measured_file).resolve() == source_file:
return set(data.lines(measured_file) or [])

raise SystemExit(f"No coverage data found for {source_file}")


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source_file", type=Path)
parser.add_argument("--coverage-file", type=Path, default=Path(".coverage"))
parser.add_argument("--max-crap", type=float, required=True)
args = parser.parse_args()

covered_lines = covered_lines_for_file(args.coverage_file, args.source_file)
scores = calculate_crap(str(args.source_file), covered_lines)
offenders = [score for score in scores if score.crap >= args.max_crap]

if not offenders:
print(f"All CRAP scores are below {args.max_crap:g} for {args.source_file}")
return 0

print(f"CRAP scores must be below {args.max_crap:g} for {args.source_file}")
for score in sorted(offenders, key=lambda item: item.crap, reverse=True):
print(
f"{score.name}: CRAP={score.crap:.2f}, "
f"CC={score.cc}, coverage={score.coverage_percent:.1f}%"
)
return 1


if __name__ == "__main__":
sys.exit(main())
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,18 @@ jobs:
with:
enable-cache: true

- name: Check utils CRAP score
if: steps.changes.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov pytest posthog/test/test_utils.py posthog/test/test_size_limited_dict.py --timeout=30 --cov=posthog.utils --crap --crap-threshold=10 --crap-top-n=40 -q
UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov python .github/scripts/check_crap_threshold.py posthog/utils.py --max-crap 10

- name: Restore mutmut cache
id: mutmut-cache
if: steps.changes.outputs.changed == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: mutants
key: mutmut-${{ runner.os }}-py311-${{ hashFiles('posthog/utils.py', 'posthog/test/test_utils.py', 'posthog/test/test_size_limited_dict.py') }}
Expand Down
30 changes: 30 additions & 0 deletions posthog/test/features/utils.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Feature: SDK utility behavior
The SDK utility module prepares user data, cache entries, and runtime metadata
for higher-level SDK flows.

Scenario Outline: Clean SDK payload values before capture
Given an SDK payload value of type <value_type>
When the SDK cleans the event payload
Then the cleaned payload value equals <expected_json>

Examples:
| value_type | expected_json |
| uuid | "12345678-1234-5678-1234-567812345678" |
| decimal | 12.34 |
| dataclass | {"source":"checkout","sample_rate":0.5} |
| tuple | ["paid","beta"] |
| bytes | "hello" |
| unsupported | null |

Scenario: Reuse cached feature flag evaluations safely
Given a cached feature flag evaluation for a user
When the SDK reads the cached flag for current and newer definitions
Then the current flag definition uses the cached evaluation
And the newer flag definition misses the cache
When the old flag definition is invalidated
Then the cached evaluation is removed

Scenario: Build runtime system context
Given the SDK is running on a Linux host with distribution metadata
When the SDK builds system context
Then the context includes Python runtime and Linux metadata
119 changes: 119 additions & 0 deletions posthog/test/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,74 @@ def test_get_os_info_branches(
stack.enter_context(patch)
assert utils.get_os_info() == expected

@parameterized.expand(
[
("version", ("11", "", "", ""), ("Windows", "11", "")),
("empty_version", ("", "", "", ""), ("Windows", "", "")),
("missing_win32_ver", None, ("Windows", "", "")),
]
)
def test_get_windows_os_info(self, _name, win32_ver, expected):
if win32_ver is None:
with mock.patch.object(utils.platform, "win32_ver", create=True):
delattr(utils.platform, "win32_ver")
assert utils._get_windows_os_info() == expected
return

with mock.patch.object(utils.platform, "win32_ver", return_value=win32_ver):
assert utils._get_windows_os_info() == expected

@parameterized.expand(
[
("version", ("14.4", ("", "", ""), ""), ("Mac OS X", "14.4", "")),
("empty_version", ("", ("", "", ""), ""), ("Mac OS X", "", "")),
("missing_mac_ver", None, ("Mac OS X", "", "")),
]
)
def test_get_macos_info(self, _name, mac_ver, expected):
if mac_ver is None:
with mock.patch.object(utils.platform, "mac_ver", create=True):
delattr(utils.platform, "mac_ver")
assert utils._get_macos_info() == expected
return

with mock.patch.object(utils.platform, "mac_ver", return_value=mac_ver):
assert utils._get_macos_info() == expected

@parameterized.expand(
[
(
"version_and_distro",
{"version": "24.04"},
"Ubuntu",
("Linux", "24.04", "Ubuntu"),
),
("empty_version_and_distro", {"version": ""}, "", ("Linux", "", "")),
]
)
def test_get_linux_os_info(self, _name, distro_info, distro_name, expected):
with (
mock.patch.object(utils.distro, "info", return_value=distro_info),
mock.patch.object(utils.distro, "name", return_value=distro_name),
):
assert utils._get_linux_os_info() == expected

@parameterized.expand(
[
("release", "13.2", "13.2"),
("missing_release", None, ""),
]
)
def test_platform_release(self, _name, release, expected):
if release is None:
with mock.patch.object(utils.platform, "release", create=True):
delattr(utils.platform, "release")
assert utils._platform_release() == expected
return

with mock.patch.object(utils.platform, "release", return_value=release):
assert utils._platform_release() == expected

def test_system_context(self):
with (
mock.patch.object(
Expand Down Expand Up @@ -542,6 +610,15 @@ def test_cache_misses_when_user_exists_without_flag(self):

assert self.cache.get_cached_flag("user123", "missing-flag", 1) is None

def test_remove_missing_user_does_not_raise(self):
self.cache.cache["existing-user"] = {}
self.cache.access_times["existing-user"] = 123

self.cache._remove_user("missing-user")

assert self.cache.cache == {"existing-user": {}}
assert self.cache.access_times == {"existing-user": 123}

def test_stale_cache_misses(self):
assert self.cache.get_stale_cached_flag("missing-user", "test-flag") is None

Expand Down Expand Up @@ -728,6 +805,48 @@ def test_redis_errors_fall_back_to_miss(self):
failing_cache.invalidate_version(1)
failing_cache.clear()

@parameterized.expand(
[
("string", "test:flags:user123:beta", "test:flags:user123:beta"),
("bytes", b"test:flags:user123:beta", "test:flags:user123:beta"),
]
)
def test_redis_key_to_string(self, _name, key, expected):
assert self.cache._redis_key_to_string(key) == expected

def test_key_has_version_returns_false_when_missing(self):
key = self.cache._get_cache_key("user123", "missing")

assert self.cache._key_has_version(key, 1) is False

@parameterized.expand(
[
("matching_version", 1, True),
("different_version", 2, False),
]
)
def test_key_has_version_checks_flag_version(self, _name, old_version, expected):
key = self.cache._get_cache_key("user123", "beta")
self.redis.store[key] = self.cache._serialize_entry(True, 1, timestamp=100)

assert self.cache._key_has_version(key, old_version) is expected

def test_key_has_version_does_not_delete_corrupt_json(self):
invalid_key = self.cache._get_cache_key("user123", "invalid")
self.redis.store[invalid_key] = "not json"

with self.assertRaises(json.JSONDecodeError):
self.cache._key_has_version(invalid_key, 1)
assert invalid_key in self.redis.store

def test_delete_keys_with_version_deletes_corrupt_json(self):
invalid_key = self.cache._get_cache_key("user123", "invalid")
self.redis.store[invalid_key] = "not json"

self.cache._delete_keys_with_version([invalid_key], 1)

assert invalid_key not in self.redis.store

def test_invalidate_version(self):
old_key = self.cache._get_cache_key("user123", "old")
new_key = self.cache._get_cache_key("user123", "new")
Expand Down
151 changes: 151 additions & 0 deletions posthog/test/test_utils_acceptance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import json
import sys
from contextlib import ExitStack
from dataclasses import dataclass
from decimal import Decimal
from uuid import UUID
from unittest import mock

from pytest_bdd import given, parsers, scenarios, then, when

from posthog import utils
from posthog.types import FeatureFlagResult

scenarios("features/utils.feature")


@dataclass
class AcceptancePayloadMetadata:
source: str
sample_rate: Decimal


PAYLOAD_VALUE_FACTORIES = {
"uuid": lambda: UUID("12345678123456781234567812345678"),
"decimal": lambda: Decimal("12.34"),
"dataclass": lambda: AcceptancePayloadMetadata(
source="checkout",
sample_rate=Decimal("0.5"),
),
"tuple": lambda: ("paid", "beta"),
"bytes": lambda: b"hello",
"unsupported": lambda: lambda: None,
}


@given(
parsers.parse("an SDK payload value of type {value_type}"), target_fixture="payload"
)
def sdk_event_payload(value_type):
return {
"event": "plan upgraded",
"properties": {"value": PAYLOAD_VALUE_FACTORIES[value_type]()},
}


@when("the SDK cleans the event payload", target_fixture="cleaned_payload")
def clean_event_payload(payload):
return utils.clean(payload)


@then(parsers.parse("the cleaned payload value equals {expected_json}"))
def cleaned_payload_value_equals(cleaned_payload, expected_json):
assert cleaned_payload["properties"]["value"] == json.loads(expected_json)
json.dumps(cleaned_payload)


@given("a cached feature flag evaluation for a user", target_fixture="flag_cache_state")
def cached_feature_flag_evaluation():
cache = utils.FlagCache(max_size=10, default_ttl=60)
flag_result = FeatureFlagResult.from_value_and_payload(
"checkout-redesign",
True,
{"variant": "test"},
)
cache.set_cached_flag(
"user-123",
"checkout-redesign",
flag_result,
flag_definition_version=1,
)
return {"cache": cache, "flag_result": flag_result}


@when("the SDK reads the cached flag for current and newer definitions")
def read_cached_flag_versions(flag_cache_state):
cache = flag_cache_state["cache"]
flag_cache_state["current_result"] = cache.get_cached_flag(
"user-123",
"checkout-redesign",
current_flag_version=1,
)
flag_cache_state["newer_result"] = cache.get_cached_flag(
"user-123",
"checkout-redesign",
current_flag_version=2,
)


@then("the current flag definition uses the cached evaluation")
def current_definition_uses_cached_evaluation(flag_cache_state):
current_result = flag_cache_state["current_result"]
assert current_result is flag_cache_state["flag_result"]
assert current_result.get_value() is True
assert current_result.payload == {"variant": "test"}


@then("the newer flag definition misses the cache")
def newer_definition_misses_cache(flag_cache_state):
assert flag_cache_state["newer_result"] is None


@when("the old flag definition is invalidated")
def invalidate_old_flag_definition(flag_cache_state):
flag_cache_state["cache"].invalidate_version(1)


@then("the cached evaluation is removed")
def cached_evaluation_is_removed(flag_cache_state):
assert (
flag_cache_state["cache"].get_cached_flag(
"user-123",
"checkout-redesign",
current_flag_version=1,
)
is None
)


@given(
"the SDK is running on a Linux host with distribution metadata",
target_fixture="linux_host_context",
)
def linux_host_with_distribution_metadata():
patches = [
mock.patch.object(utils.sys, "platform", "linux"),
mock.patch.object(
utils.platform, "python_implementation", return_value="CPython"
),
mock.patch.object(utils.distro, "info", return_value={"version": "24.04"}),
mock.patch.object(utils.distro, "name", return_value="Ubuntu"),
]
with ExitStack() as stack:
for patch in patches:
stack.enter_context(patch)
yield


@when("the SDK builds system context", target_fixture="system_context")
def build_system_context(linux_host_context):
return utils.system_context()


@then("the context includes Python runtime and Linux metadata")
def context_includes_python_runtime_and_linux_metadata(system_context):
assert system_context == {
"$python_runtime": "CPython",
"$python_version": f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}",
"$os": "Linux",
"$os_version": "24.04",
"$os_distro": "Ubuntu",
}
Loading
Loading