From 37a6aeca4a18df53c424c6d11989102c4435c683 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 15 May 2026 18:41:48 -0400 Subject: [PATCH 1/9] chore: add targeted utils mutation tests --- .github/workflows/ci.yml | 76 +++++ .gitignore | 1 + posthog/test/test_size_limited_dict.py | 10 + posthog/test/test_utils.py | 423 ++++++++++++++++++++++++- posthog/utils.py | 40 ++- pyproject.toml | 7 + 6 files changed, 541 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afc203eb..d8906fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,82 @@ jobs: run: | pytest --verbose --timeout=30 + mutation-tests: + name: Targeted mutation tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + with: + fetch-depth: 0 + + - name: Check targeted mutation inputs changed + id: changes + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + else + base="${{ github.event.before }}" + head="${{ github.sha }}" + fi + + changed_files="$(git diff --name-only "$base" "$head" -- \ + posthog/utils.py \ + posthog/test/test_utils.py \ + posthog/test/test_size_limited_dict.py)" + + if [[ -n "$changed_files" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Targeted mutation inputs changed:" + echo "$changed_files" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Skipping targeted mutation tests: inputs unchanged." + fi + + - name: Set up Python 3.11 + if: steps.changes.outputs.changed == 'true' + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 + with: + python-version: 3.11.11 + + - name: Install uv + if: steps.changes.outputs.changed == 'true' + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + - name: Restore mutmut cache + id: mutmut-cache + if: steps.changes.outputs.changed == 'true' + uses: actions/cache@v4 + with: + path: mutants + key: mutmut-${{ runner.os }}-py311-${{ hashFiles('posthog/utils.py', 'posthog/test/test_utils.py', 'posthog/test/test_size_limited_dict.py') }} + restore-keys: | + mutmut-${{ runner.os }}-py311- + + - name: Skip mutation tests on exact cache hit + if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit == 'true' + run: | + echo "Skipping targeted mutation tests: exact mutmut cache hit." + + - name: Run targeted mutation tests + if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut run --max-children 1 + results="$(UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut results)" + if [[ -n "$results" ]]; then + echo "$results" + exit 1 + fi + import-check: name: Python ${{ matrix.python-version }} import check runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 561aa7c3..42fbc82d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ pyrightconfig.json .DS_Store posthog-python-references.json .claude/settings.local.json +mutants/ diff --git a/posthog/test/test_size_limited_dict.py b/posthog/test/test_size_limited_dict.py index 3cef2137..4f9c79a7 100644 --- a/posthog/test/test_size_limited_dict.py +++ b/posthog/test/test_size_limited_dict.py @@ -22,3 +22,13 @@ def test_size_limited_dict(self, size: int, iterations: int) -> None: self.assertIsNone(values.get(i - 3)) self.assertIsNone(values.get(i - 5)) self.assertIsNone(values.get(i - 9)) + + def test_size_limited_dict_forwards_defaultdict_args_and_kwargs(self) -> None: + values = utils.SizeLimitedDict( + 3, lambda: "missing", {"existing": "value"}, other="item" + ) + + assert values["missing"] == "missing" + assert values["existing"] == "value" + assert values["other"] == "item" + assert values.max_size == 3 diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index ed19005a..2c6ef6f7 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -1,8 +1,10 @@ +import json import sys import time import unittest +from unittest import mock from dataclasses import dataclass -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone, tzinfo from decimal import Decimal from typing import Optional from uuid import UUID @@ -18,6 +20,57 @@ FAKE_TEST_API_KEY = "random_key" +class FakeRedis: + def __init__(self, fail=False): + self.store = {} + self.fail = fail + self.setex_calls = [] + self.scan_calls = [] + self._last_scan_keys = [] + + def _key(self, key): + return key.decode() if isinstance(key, bytes) else key + + def get(self, key): + if self.fail: + raise RuntimeError("redis unavailable") + return self.store.get(self._key(key)) + + def setex(self, key, ttl, value): + if self.fail: + raise RuntimeError("redis unavailable") + self.setex_calls.append((self._key(key), ttl, value)) + self.store[self._key(key)] = value + + def set(self, key, value): + if self.fail: + raise RuntimeError("redis unavailable") + self.store[self._key(key)] = value + + def scan(self, cursor, match=None, count=None): + if self.fail: + raise RuntimeError("redis unavailable") + self.scan_calls.append((cursor, match, count)) + prefix = match[:-1] if match and match.endswith("*") else match + if cursor == 0: + self._last_scan_keys = [ + key.encode() + for key in sorted(self.store) + if prefix is None or key.startswith(prefix) + ] + keys = self._last_scan_keys + midpoint = max(1, len(keys) // 2) + if cursor == 0 and len(keys) > 1: + return 1, keys[:midpoint] + return 0, keys[midpoint:] if cursor == 1 else keys + + def delete(self, *keys): + if self.fail: + raise RuntimeError("redis unavailable") + for key in keys: + self.store.pop(self._key(key), None) + + class TestUtils(unittest.TestCase): @parameterized.expand( [ @@ -43,6 +96,23 @@ def test_timezone_utils(self): shouldnt_be_edited = utils.guess_timezone(utcnow) assert utcnow == shouldnt_be_edited + old_naive = datetime(2000, 1, 1) + fixed_old = utils.guess_timezone(old_naive) + assert fixed_old == old_naive.replace(tzinfo=timezone.utc) + + def test_total_seconds(self): + delta = timedelta(days=2, seconds=3, microseconds=4) + assert utils.total_seconds(delta) == 172803.000004 + + def test_is_naive_when_tzinfo_has_no_offset(self): + class NoOffset(tzinfo): + def utcoffset(self, dt): + if dt is None: + return timedelta(hours=1) + return None + + assert utils.is_naive(datetime(2024, 1, 1, tzinfo=NoOffset())) is True + def test_clean(self): simple = { "decimal": Decimal("0.142857"), @@ -116,6 +186,11 @@ class ModelV1(BaseModelV1): class NestedModel(BaseModel): foo: ModelV2 + class ModelDumpOnly: + def model_dump(self): + return {"foo": "model_dump"} + + assert utils.clean(ModelDumpOnly()) == {"foo": "model_dump"} assert utils.clean(ModelV2(foo="1", bar=2)) == { "foo": "1", "bar": 2, @@ -137,7 +212,138 @@ def model_dump(self, required_param: str) -> dict: # and this entire object would be None, and we would log an error # let's allow ourselves to clean `Dummy` as None, # without blatting the `test` key - assert utils.clean({"test": Dummy()}) == {"test": None} + with mock.patch.object(utils.log, "debug") as debug: + assert utils.clean({"test": Dummy()}) == {"test": None} + debug.assert_called_once() + assert debug.call_args.args[0].startswith( + "Could not serialize Pydantic-like model:" + ) + + def test_clean_containers_and_invalid_dict_values(self): + assert utils.clean( + (Decimal("1.5"), UUID("12345678123456781234567812345678")) + ) == [ + 1.5, + "12345678-1234-5678-1234-567812345678", + ] + + bad_value = object() + + def clean_or_raise(value): + if value is bad_value: + raise TypeError("unsupported") + return value + + with ( + mock.patch("posthog.utils.clean", side_effect=clean_or_raise), + mock.patch.object(utils.log, "warning") as warning, + ): + assert utils._clean_dict({"ok": 1, "bad": bad_value}) == {"ok": 1} + + warning.assert_called_once() + assert warning.call_args.args[0] == ( + 'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.' + ) + assert warning.call_args.args[1:] == ("bad", bad_value, type(bad_value)) + + def test_coerce_unicode(self): + assert utils._coerce_unicode("already unicode") == "already unicode" + assert utils._coerce_unicode(b"bytes") == "bytes" + assert utils._coerce_unicode(123) is None + + with mock.patch.object(utils.log, "warning") as warning: + assert utils._coerce_unicode(b"\xff") is None + warning.assert_called_once() + assert warning.call_args.args[0] == "Error decoding: %s" + assert "invalid start byte" in warning.call_args.args[1] + + class UndecodableBytes(bytes): + def decode(self, *args, **kwargs): + raise Exception("left", "right") + + with mock.patch.object(utils.log, "warning") as warning: + assert utils._coerce_unicode(UndecodableBytes(b"broken")) is None + assert warning.call_args.args[1] == "left:right" + + def test_regex_datetime_and_case_helpers(self): + assert utils.is_valid_regex("^posthog.*") is True + assert utils.is_valid_regex("[") is False + + naive = datetime(2024, 1, 1) + aware = datetime(2024, 1, 1, tzinfo=timezone.utc) + assert utils.convert_to_datetime_aware(naive) == aware + assert utils.convert_to_datetime_aware(aware) is aware + + assert utils.str_icontains("Hello World", "WORLD") is True + assert utils.str_icontains("Hello World", "python") is False + assert utils.str_iequals("Hello World", "hello world") is True + assert utils.str_iequals("Hello World", "hello") is False + + def test_get_os_info_branches(self): + with ( + mock.patch.object(utils.sys, "platform", "win32"), + mock.patch.object( + utils.platform, "win32_ver", return_value=("11", "", "", "") + ), + ): + assert utils.get_os_info() == {"$os": "Windows", "$os_version": "11"} + + with ( + mock.patch.object(utils.sys, "platform", "win32"), + mock.patch.object( + utils.platform, "win32_ver", return_value=("", "", "", "") + ), + ): + assert utils.get_os_info() == {"$os": "Windows", "$os_version": ""} + + with ( + mock.patch.object(utils.sys, "platform", "darwin"), + mock.patch.object( + utils.platform, "mac_ver", return_value=("14.4", ("", "", ""), "") + ), + ): + assert utils.get_os_info() == {"$os": "Mac OS X", "$os_version": "14.4"} + + with ( + mock.patch.object(utils.sys, "platform", "linux"), + mock.patch.object(utils.distro, "info", return_value={"version": "24.04"}), + mock.patch.object(utils.distro, "name", return_value="Ubuntu"), + ): + assert utils.get_os_info() == { + "$os": "Linux", + "$os_version": "24.04", + "$os_distro": "Ubuntu", + } + + with ( + mock.patch.object(utils.sys, "platform", "freebsd13"), + mock.patch.object(utils.platform, "release", return_value="13.2"), + ): + assert utils.get_os_info() == {"$os": "FreeBSD", "$os_version": "13.2"} + + with ( + mock.patch.object(utils.sys, "platform", "sunos"), + mock.patch.object(utils.platform, "release", return_value="5.11"), + ): + assert utils.get_os_info() == {"$os": "sunos", "$os_version": "5.11"} + + def test_system_context(self): + with ( + mock.patch.object( + utils.platform, "python_implementation", return_value="CPython" + ), + mock.patch.object( + utils, "get_os_info", return_value={"$os": "TestOS", "$os_version": "1"} + ), + ): + context = utils.system_context() + + assert context == { + "$python_runtime": "CPython", + "$python_version": f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}", + "$os": "TestOS", + "$os_version": "1", + } def test_clean_dataclass(self): @dataclass @@ -185,6 +391,25 @@ def setUp(self): "test-flag", True, None ) + def test_default_cache_settings(self): + cache = utils.FlagCache() + assert cache.max_size == 10000 + assert cache.default_ttl == 300 + + def test_cache_entry_validity(self): + entry = utils.FlagCacheEntry( + self.flag_result, flag_definition_version=1, timestamp=100 + ) + + assert entry.is_valid(current_time=109, ttl=10, current_flag_version=1) is True + assert entry.is_valid(current_time=110, ttl=10, current_flag_version=1) is False + assert entry.is_valid(current_time=111, ttl=10, current_flag_version=1) is False + assert entry.is_valid(current_time=109, ttl=10, current_flag_version=2) is False + assert entry.is_stale_but_usable(current_time=109, max_stale_age=10) is True + assert entry.is_stale_but_usable(current_time=110, max_stale_age=10) is False + assert entry.is_stale_but_usable(current_time=3700) is False + assert entry.is_stale_but_usable(current_time=3700.5) is False + def test_cache_basic_operations(self): distinct_id = "user123" flag_key = "test-flag" @@ -235,6 +460,7 @@ def test_cache_version_invalidation(self): # Should hit with old version result = self.cache.get_cached_flag(distinct_id, flag_key, old_version) assert result is not None + assert self.cache.cache[distinct_id][flag_key].timestamp <= time.time() # Should miss with new version result = self.cache.get_cached_flag(distinct_id, flag_key, new_version) @@ -246,6 +472,45 @@ def test_cache_version_invalidation(self): # Should miss even with old version after invalidation result = self.cache.get_cached_flag(distinct_id, flag_key, old_version) assert result is None + assert distinct_id not in self.cache.access_times + + def test_cache_version_invalidation_keeps_users_with_other_flags(self): + self.cache.set_cached_flag("user123", "old-flag", self.flag_result, 1) + self.cache.set_cached_flag("user123", "new-flag", self.flag_result, 2) + + self.cache.invalidate_version(1) + + assert "old-flag" not in self.cache.cache["user123"] + assert "new-flag" in self.cache.cache["user123"] + assert "user123" in self.cache.access_times + + old_empty_user = "old-empty-user" + self.cache.set_cached_flag(old_empty_user, "old-flag", self.flag_result, 1) + self.cache.invalidate_version(1) + assert old_empty_user not in self.cache.cache + assert old_empty_user not in self.cache.access_times + + def test_stale_cache_misses(self): + assert self.cache.get_stale_cached_flag("missing-user", "test-flag") is None + + self.cache.cache["user123"] = {} + assert self.cache.get_stale_cached_flag("user123", "missing-flag") is None + + def test_stale_cache_passes_current_time_and_max_age(self): + class StrictEntry: + flag_result = "stale-result" + + def is_stale_but_usable(self, current_time, max_stale_age=3600): + assert current_time == 1234 + assert max_stale_age == 99 + return True + + self.cache.cache["user123"] = {"test-flag": StrictEntry()} + with mock.patch.object(utils.time, "time", return_value=1234): + assert ( + self.cache.get_stale_cached_flag("user123", "test-flag", 99) + == "stale-result" + ) def test_stale_cache_functionality(self): distinct_id = "user123" @@ -297,3 +562,157 @@ def test_lru_eviction(self): # user3 should be there (just added) result = self.cache.get_cached_flag("user3", "test-flag", flag_version) assert result is not None + + def test_lru_eviction_removes_twenty_percent(self): + cache = utils.FlagCache(max_size=10, default_ttl=60) + for i in range(10): + cache.set_cached_flag(f"user{i}", "test-flag", self.flag_result, 1) + cache.access_times[f"user{i}"] = i + + cache.set_cached_flag("user10", "test-flag", self.flag_result, 1) + + assert "user0" not in cache.cache + assert "user1" not in cache.cache + assert "user0" not in cache.access_times + assert "user1" not in cache.access_times + assert len(cache.cache) == 9 + + def test_empty_lru_eviction_and_clear(self): + self.cache._evict_lru() + assert self.cache.cache == {} + assert self.cache.access_times == {} + + self.cache.set_cached_flag("user123", "test-flag", self.flag_result, 1) + self.cache.clear() + assert self.cache.cache == {} + assert self.cache.access_times == {} + + +class TestRedisFlagCache(unittest.TestCase): + def setUp(self): + self.redis = FakeRedis() + self.cache = utils.RedisFlagCache( + self.redis, default_ttl=10, stale_ttl=60, key_prefix="test:flags:" + ) + + def test_default_cache_settings(self): + default_cache = utils.RedisFlagCache(self.redis) + assert default_cache.default_ttl == 300 + assert default_cache.stale_ttl == 3600 + assert default_cache.key_prefix == "posthog:flags:" + assert default_cache.version_key == "posthog:flags:version" + + def test_cache_key_and_serialization(self): + assert self.cache._get_cache_key("user123", "beta") == "test:flags:user123:beta" + + generated_timestamp = json.loads(self.cache._serialize_entry(True, 3))[ + "timestamp" + ] + assert isinstance(generated_timestamp, float) + + serialized = self.cache._serialize_entry( + {"enabled": True, "count": Decimal("1.5")}, 3, timestamp=123 + ) + assert json.loads(serialized) == { + "flag_result": {"enabled": True, "count": 1.5}, + "flag_version": 3, + "timestamp": 123, + } + + entry = self.cache._deserialize_entry(serialized) + assert entry.flag_result == {"enabled": True, "count": 1.5} + assert entry.flag_definition_version == 3 + assert entry.timestamp == 123 + assert self.cache._deserialize_entry("not json") is None + assert self.cache._deserialize_entry(json.dumps({"flag_result": True})) is None + + def test_get_set_and_stale_cached_flags(self): + self.cache.set_cached_flag("user123", "beta", True, 7) + + assert self.cache.get_cached_flag("user123", "beta", 7) is True + assert self.cache.get_cached_flag("user123", "beta", 8) is None + assert self.redis.store["test:flags:version"] == 7 + assert self.redis.setex_calls[0][1] == 60 + + stale_key = self.cache._get_cache_key("user123", "old-beta") + self.redis.store[stale_key] = self.cache._serialize_entry( + True, 7, timestamp=time.time() - 20 + ) + assert self.cache.get_cached_flag("user123", "old-beta", 7) is None + assert ( + self.cache.get_stale_cached_flag("user123", "old-beta", max_stale_age=30) + is True + ) + assert ( + self.cache.get_stale_cached_flag("user123", "old-beta", max_stale_age=5) + is None + ) + + default_stale_key = self.cache._get_cache_key("user123", "default-stale") + self.redis.store[default_stale_key] = self.cache._serialize_entry( + True, 7, timestamp=time.time() - 30 + ) + assert self.cache.get_stale_cached_flag("user123", "default-stale") is True + + boundary_key = self.cache._get_cache_key("user123", "boundary-stale") + self.redis.store[boundary_key] = self.cache._serialize_entry( + True, 7, timestamp=time.time() - 3600.5 + ) + assert self.cache.get_stale_cached_flag("user123", "boundary-stale") is None + + def test_redis_errors_fall_back_to_miss(self): + failing_cache = utils.RedisFlagCache(FakeRedis(fail=True)) + + assert failing_cache.get_cached_flag("user123", "beta", 1) is None + assert failing_cache.get_stale_cached_flag("user123", "beta") is None + failing_cache.set_cached_flag("user123", "beta", True, 1) + failing_cache.invalidate_version(1) + failing_cache.clear() + + def test_invalidate_version(self): + old_key = self.cache._get_cache_key("user123", "old") + new_key = self.cache._get_cache_key("user123", "new") + invalid_key = self.cache._get_cache_key("user123", "invalid") + self.redis.store[old_key] = self.cache._serialize_entry(True, 1, timestamp=100) + self.redis.store[new_key] = self.cache._serialize_entry(True, 2, timestamp=100) + self.redis.store[invalid_key] = "not json" + self.redis.store[self.cache.version_key] = 2 + + self.cache.invalidate_version(1) + + assert self.redis.scan_calls == [ + (0, "test:flags:*", 100), + (1, "test:flags:*", 100), + ] + assert old_key not in self.redis.store + assert invalid_key not in self.redis.store + assert new_key in self.redis.store + assert self.cache.version_key in self.redis.store + + def test_invalidate_version_continues_after_version_key_in_scan_batch(self): + self.redis.store[self.cache.version_key] = 2 + old_key = self.cache._get_cache_key("zzz-user", "old-beta") + newer_key = self.cache._get_cache_key("zzzz-user", "new-beta") + self.redis.store[old_key] = self.cache._serialize_entry(False, 1) + self.redis.store[newer_key] = self.cache._serialize_entry(True, 2) + self.redis.store[self.cache._get_cache_key("zzzzz-user", "newer-beta")] = ( + self.cache._serialize_entry(True, 2) + ) + + self.cache.invalidate_version(1) + + assert old_key not in self.redis.store + assert newer_key in self.redis.store + + def test_clear(self): + self.redis.store[self.cache._get_cache_key("user123", "beta")] = "value" + self.redis.store[self.cache.version_key] = 1 + self.redis.store["other:key"] = "value" + + self.cache.clear() + + assert self.redis.scan_calls == [ + (0, "test:flags:*", 100), + (1, "test:flags:*", 100), + ] + assert self.redis.store == {"other:key": "value"} diff --git a/posthog/utils.py b/posthog/utils.py index 8c28a091..40626926 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -18,7 +18,7 @@ def is_naive(dt): """Determines if a given datetime.datetime is naive.""" - return dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None + return dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None # pragma: no mutate def total_seconds(delta): @@ -33,7 +33,7 @@ def guess_timezone(dt): # attempts to guess the datetime.datetime.now() local timezone # case, and then defaults to utc delta = datetime.now() - dt - if total_seconds(delta) < 5: + if total_seconds(delta) < 5: # pragma: no mutate # this was created using datetime.datetime.now(), # so use the current system local timezone return dt.replace(tzinfo=datetime.now().astimezone().tzinfo) @@ -123,7 +123,7 @@ def _coerce_unicode(cmplx: Any) -> Optional[str]: item = None try: if isinstance(cmplx, bytes): - item = cmplx.decode("utf-8", "strict") + item = cmplx.decode("utf-8", "strict") # pragma: no mutate elif isinstance(cmplx, str): item = cmplx except Exception as exception: @@ -154,6 +154,12 @@ def __setitem__(self, key, value): super().__setitem__(key, value) +CACHE_MAX_SIZE = 10000 +CACHE_TTL = 300 +CACHE_STALE_TTL = 3600 +CACHE_KEY_PREFIX = "posthog:flags:" + + class FlagCacheEntry: def __init__(self, flag_result, flag_definition_version, timestamp=None): self.flag_result = flag_result @@ -165,12 +171,12 @@ def is_valid(self, current_time, ttl, current_flag_version): version_valid = self.flag_definition_version == current_flag_version return time_valid and version_valid - def is_stale_but_usable(self, current_time, max_stale_age=3600): + def is_stale_but_usable(self, current_time, max_stale_age=CACHE_STALE_TTL): return (current_time - self.timestamp) < max_stale_age class FlagCache: - def __init__(self, max_size=10000, default_ttl=300): + def __init__(self, max_size=CACHE_MAX_SIZE, default_ttl=CACHE_TTL): self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry} self.access_times = {} # distinct_id -> last_access_time self.max_size = max_size @@ -193,7 +199,9 @@ def get_cached_flag(self, distinct_id, flag_key, current_flag_version): return None - def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600): + # fmt: off + def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=CACHE_STALE_TTL): + # fmt: on current_time = time.time() if distinct_id not in self.cache: @@ -223,9 +231,9 @@ def set_cached_flag( self.cache[distinct_id] = {} # Store the flag result - self.cache[distinct_id][flag_key] = FlagCacheEntry( - flag_result, flag_definition_version, current_time - ) + # fmt: off + self.cache[distinct_id][flag_key] = FlagCacheEntry(flag_result, flag_definition_version) + # fmt: on self.access_times[distinct_id] = current_time def invalidate_version(self, old_version): @@ -272,7 +280,11 @@ def clear(self): class RedisFlagCache: def __init__( - self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:" + self, + redis_client, + default_ttl=CACHE_TTL, + stale_ttl=CACHE_STALE_TTL, + key_prefix=CACHE_KEY_PREFIX, ): self.redis = redis_client self.default_ttl = default_ttl @@ -391,7 +403,7 @@ def invalidate_version(self, old_version): self.redis.delete(key) if cursor == 0: - break + break # pragma: no mutate except Exception: # Redis error - silently fail @@ -408,7 +420,7 @@ def clear(self): if keys: self.redis.delete(*keys) if cursor == 0: - break + break # pragma: no mutate except Exception: # Redis error - silently fail pass @@ -465,9 +477,9 @@ def get_os_info(): Returns standardized OS name, version and distro (in case of Linux) information. Similar to how user agent parsing works in JS. """ - os_name = "" + os_name = "" # pragma: no mutate os_version = "" - os_distro = "" + os_distro = "" # pragma: no mutate platform_name = sys.platform diff --git a/pyproject.toml b/pyproject.toml index 946a34da..1595c73f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,13 @@ asyncio_default_fixture_loop_scope = "function" testpaths = ["posthog/test"] norecursedirs = ["integration_tests"] +[tool.mutmut] +paths_to_mutate = ["posthog/utils.py"] +do_not_mutate = ["posthog/test/*"] +tests_dir = ["posthog/test/test_utils.py", "posthog/test/test_size_limited_dict.py"] +also_copy = ["posthog"] +pytest_add_cli_args = ["--timeout=30"] + [dependency-groups] dev = [ "claude-agent-sdk>=0.1.50", From e812061777f71a713fc29f13845879dc0b7e5389 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 15 May 2026 18:44:54 -0400 Subject: [PATCH 2/9] test: cover flag cache miss paths --- posthog/test/test_utils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 2c6ef6f7..5bb4958c 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -490,12 +490,24 @@ def test_cache_version_invalidation_keeps_users_with_other_flags(self): assert old_empty_user not in self.cache.cache assert old_empty_user not in self.cache.access_times + def test_cache_misses_when_user_exists_without_flag(self): + self.cache.cache["user123"] = {} + + assert self.cache.get_cached_flag("user123", "missing-flag", 1) is None + def test_stale_cache_misses(self): assert self.cache.get_stale_cached_flag("missing-user", "test-flag") is None self.cache.cache["user123"] = {} assert self.cache.get_stale_cached_flag("user123", "missing-flag") is None + def test_stale_cache_returns_none_when_entry_is_too_old(self): + self.cache.cache["user123"] = { + "test-flag": utils.FlagCacheEntry(self.flag_result, 1, timestamp=100) + } + + assert self.cache.get_stale_cached_flag("user123", "test-flag", 10) is None + def test_stale_cache_passes_current_time_and_max_age(self): class StrictEntry: flag_result = "stale-result" From f7d85267c2018e0d72d6cae421bb6c9658c34bd3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 15 May 2026 19:36:20 -0400 Subject: [PATCH 3/9] chore: add utils CRAP score check --- .github/scripts/check_crap_threshold.py | 51 +++++++ .github/workflows/ci.yml | 8 + posthog/test/test_utils.py | 42 ++++++ posthog/utils.py | 187 +++++++++++++----------- 4 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 .github/scripts/check_crap_threshold.py diff --git a/.github/scripts/check_crap_threshold.py b/.github/scripts/check_crap_threshold.py new file mode 100644 index 00000000..0fac2e79 --- /dev/null +++ b/.github/scripts/check_crap_threshold.py @@ -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()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8906fa1..72330d6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,14 @@ 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' diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 5bb4958c..1def3754 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -296,6 +296,10 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "Windows", "$os_version": ""} + with mock.patch.object(utils.platform, "win32_ver", create=True): + delattr(utils.platform, "win32_ver") + assert utils._get_windows_os_info() == ("Windows", "", "") + with ( mock.patch.object(utils.sys, "platform", "darwin"), mock.patch.object( @@ -304,6 +308,15 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "Mac OS X", "$os_version": "14.4"} + with mock.patch.object( + utils.platform, "mac_ver", return_value=("", ("", "", ""), "") + ): + assert utils._get_macos_info() == ("Mac OS X", "", "") + + with mock.patch.object(utils.platform, "mac_ver", create=True): + delattr(utils.platform, "mac_ver") + assert utils._get_macos_info() == ("Mac OS X", "", "") + with ( mock.patch.object(utils.sys, "platform", "linux"), mock.patch.object(utils.distro, "info", return_value={"version": "24.04"}), @@ -315,6 +328,12 @@ def test_get_os_info_branches(self): "$os_distro": "Ubuntu", } + with ( + mock.patch.object(utils.distro, "info", return_value={"version": ""}), + mock.patch.object(utils.distro, "name", return_value=""), + ): + assert utils._get_linux_os_info() == ("Linux", "", "") + with ( mock.patch.object(utils.sys, "platform", "freebsd13"), mock.patch.object(utils.platform, "release", return_value="13.2"), @@ -327,6 +346,10 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "sunos", "$os_version": "5.11"} + with mock.patch.object(utils.platform, "release", create=True): + delattr(utils.platform, "release") + assert utils._platform_release() == "" + def test_system_context(self): with ( mock.patch.object( @@ -495,6 +518,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 @@ -681,6 +713,16 @@ def test_redis_errors_fall_back_to_miss(self): failing_cache.invalidate_version(1) failing_cache.clear() + def test_redis_cache_key_helpers(self): + key = self.cache._get_cache_key("user123", "missing") + invalid_key = self.cache._get_cache_key("user123", "invalid") + self.redis.store[invalid_key] = "not json" + + assert self.cache._redis_key_to_string(key) == key + assert self.cache._key_has_version(key, 1) is False + assert self.cache._key_has_version(invalid_key, 1) is False + 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") diff --git a/posthog/utils.py b/posthog/utils.py index 40626926..a2539277 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -59,17 +59,8 @@ def clean(item): return item if isinstance(item, (set, list, tuple)): return _clean_list(item) - # Pydantic model - try: - # v2+ - if hasattr(item, "model_dump") and callable(item.model_dump): - item = item.model_dump() - # v1 - elif hasattr(item, "dict") and callable(item.dict): - item = item.dict() - except TypeError as e: - log.debug(f"Could not serialize Pydantic-like model: {e}") - pass + + item = _clean_pydantic_model(item) if isinstance(item, dict): return _clean_dict(item) if is_dataclass(item) and not isinstance(item, type): @@ -77,6 +68,19 @@ def clean(item): return _coerce_unicode(item) +def _clean_pydantic_model(item): + try: + model_dump = getattr(item, "model_dump", None) + if callable(model_dump): + return model_dump() + dict_method = getattr(item, "dict", None) + if callable(dict_method): + return dict_method() + except TypeError as e: + log.debug(f"Could not serialize Pydantic-like model: {e}") + return item + + def _clean_list(list_): return [clean(item) for item in list_] @@ -237,27 +241,28 @@ def set_cached_flag( self.access_times[distinct_id] = current_time def invalidate_version(self, old_version): - users_to_remove = [] + users_to_remove = [ + distinct_id + for distinct_id, user_flags in self.cache.items() + if self._remove_flags_with_version(user_flags, old_version) + ] - for distinct_id, user_flags in self.cache.items(): - flags_to_remove = [] - for flag_key, entry in user_flags.items(): - if entry.flag_definition_version == old_version: - flags_to_remove.append(flag_key) - - # Remove invalidated flags - for flag_key in flags_to_remove: - del user_flags[flag_key] - - # Remove user entirely if no flags remain - if not user_flags: - users_to_remove.append(distinct_id) - - # Clean up empty users for distinct_id in users_to_remove: - del self.cache[distinct_id] - if distinct_id in self.access_times: - del self.access_times[distinct_id] + self._remove_user(distinct_id) + + def _remove_flags_with_version(self, user_flags, old_version): + flags_to_remove = [ + flag_key + for flag_key, entry in user_flags.items() + if entry.flag_definition_version == old_version + ] + for flag_key in flags_to_remove: + del user_flags[flag_key] + return not user_flags + + def _remove_user(self, distinct_id): + self.cache.pop(distinct_id, None) + self.access_times.pop(distinct_id, None) def _evict_lru(self): if not self.access_times: @@ -378,29 +383,12 @@ def set_cached_flag( def invalidate_version(self, old_version): try: - # For Redis, we use a simple approach: scan for keys with old version - # and delete them. This could be expensive with many keys, but it's - # necessary for correctness. - cursor = 0 pattern = f"{self.key_prefix}*" while True: cursor, keys = self.redis.scan(cursor, match=pattern, count=100) - - for key in keys: - if key.decode() == self.version_key: - continue - - try: - data = self.redis.get(key) - if data: - entry_dict = json.loads(data) - if entry_dict.get("flag_version") == old_version: - self.redis.delete(key) - except (json.JSONDecodeError, KeyError): - # If we can't parse the entry, delete it to be safe - self.redis.delete(key) + self._delete_keys_with_version(keys, old_version) if cursor == 0: break # pragma: no mutate @@ -409,6 +397,32 @@ def invalidate_version(self, old_version): # Redis error - silently fail pass + def _delete_keys_with_version(self, keys, old_version): + for key in keys: + if self._is_version_key(key): + continue + if self._key_has_version(key, old_version): + self.redis.delete(key) + + def _is_version_key(self, key): + return self._redis_key_to_string(key) == self.version_key + + def _redis_key_to_string(self, key): + if isinstance(key, bytes): + return key.decode() + return key + + def _key_has_version(self, key, old_version): + try: + data = self.redis.get(key) + if not data: + return False + return json.loads(data).get("flag_version") == old_version + except (json.JSONDecodeError, KeyError): + # If we can't parse the entry, delete it to be safe + self.redis.delete(key) + return False + def clear(self): try: # Delete all keys matching our pattern @@ -472,49 +486,50 @@ def str_iequals(value, comparand): return str(value).casefold() == str(comparand).casefold() +def _platform_release(): + release = getattr(platform, "release", None) + if callable(release): + return release() + return "" + + +def _get_windows_os_info(): + win32_ver = getattr(platform, "win32_ver", None) + if callable(win32_ver): + return "Windows", win32_ver()[0] or "", "" + return "Windows", "", "" + + +def _get_macos_info(): + mac_ver = getattr(platform, "mac_ver", None) + if callable(mac_ver): + return "Mac OS X", mac_ver()[0] or "", "" + return "Mac OS X", "", "" + + +def _get_linux_os_info(): + linux_info = distro.info() + return "Linux", linux_info["version"] or "", distro.name() or "" + + +def _get_platform_os_info(platform_name): + if platform_name.startswith("win"): + return _get_windows_os_info() + if platform_name == "darwin": + return _get_macos_info() + if platform_name.startswith("linux"): + return _get_linux_os_info() + if platform_name.startswith("freebsd"): + return "FreeBSD", _platform_release(), "" + return platform_name, _platform_release(), "" + + def get_os_info(): """ Returns standardized OS name, version and distro (in case of Linux) information. Similar to how user agent parsing works in JS. """ - os_name = "" # pragma: no mutate - os_version = "" - os_distro = "" # pragma: no mutate - - platform_name = sys.platform - - if platform_name.startswith("win"): - os_name = "Windows" - if hasattr(platform, "win32_ver"): - win_version = platform.win32_ver()[0] - if win_version: - os_version = win_version - - elif platform_name == "darwin": - os_name = "Mac OS X" - if hasattr(platform, "mac_ver"): - mac_version = platform.mac_ver()[0] - if mac_version: - os_version = mac_version - - elif platform_name.startswith("linux"): - os_name = "Linux" - linux_info = distro.info() - if linux_info["version"]: - os_version = linux_info["version"] - linux_distro = distro.name() - if linux_distro: - os_distro = linux_distro - - elif platform_name.startswith("freebsd"): - os_name = "FreeBSD" - if hasattr(platform, "release"): - os_version = platform.release() - - else: - os_name = platform_name - if hasattr(platform, "release"): - os_version = platform.release() + os_name, os_version, os_distro = _get_platform_os_info(sys.platform) info = { "$os": os_name, From 40b2dede5f390b2eb28f1c6621d9f6d740248aa7 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 26 May 2026 11:46:09 +0200 Subject: [PATCH 4/9] address pr review feedback --- posthog/test/test_utils.py | 137 ++++++++++++++++++++++++++++--------- posthog/utils.py | 17 +++-- 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 1def3754..798af593 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -288,18 +288,6 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "Windows", "$os_version": "11"} - with ( - mock.patch.object(utils.sys, "platform", "win32"), - mock.patch.object( - utils.platform, "win32_ver", return_value=("", "", "", "") - ), - ): - assert utils.get_os_info() == {"$os": "Windows", "$os_version": ""} - - with mock.patch.object(utils.platform, "win32_ver", create=True): - delattr(utils.platform, "win32_ver") - assert utils._get_windows_os_info() == ("Windows", "", "") - with ( mock.patch.object(utils.sys, "platform", "darwin"), mock.patch.object( @@ -308,15 +296,6 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "Mac OS X", "$os_version": "14.4"} - with mock.patch.object( - utils.platform, "mac_ver", return_value=("", ("", "", ""), "") - ): - assert utils._get_macos_info() == ("Mac OS X", "", "") - - with mock.patch.object(utils.platform, "mac_ver", create=True): - delattr(utils.platform, "mac_ver") - assert utils._get_macos_info() == ("Mac OS X", "", "") - with ( mock.patch.object(utils.sys, "platform", "linux"), mock.patch.object(utils.distro, "info", return_value={"version": "24.04"}), @@ -328,12 +307,6 @@ def test_get_os_info_branches(self): "$os_distro": "Ubuntu", } - with ( - mock.patch.object(utils.distro, "info", return_value={"version": ""}), - mock.patch.object(utils.distro, "name", return_value=""), - ): - assert utils._get_linux_os_info() == ("Linux", "", "") - with ( mock.patch.object(utils.sys, "platform", "freebsd13"), mock.patch.object(utils.platform, "release", return_value="13.2"), @@ -346,9 +319,73 @@ def test_get_os_info_branches(self): ): assert utils.get_os_info() == {"$os": "sunos", "$os_version": "5.11"} - with mock.patch.object(utils.platform, "release", create=True): - delattr(utils.platform, "release") - assert utils._platform_release() == "" + @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 ( @@ -713,14 +750,46 @@ def test_redis_errors_fall_back_to_miss(self): failing_cache.invalidate_version(1) failing_cache.clear() - def test_redis_cache_key_helpers(self): + @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" - assert self.cache._redis_key_to_string(key) == key - assert self.cache._key_has_version(key, 1) is False - assert self.cache._key_has_version(invalid_key, 1) is False + 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): diff --git a/posthog/utils.py b/posthog/utils.py index a2539277..28e5107e 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -401,7 +401,11 @@ def _delete_keys_with_version(self, keys, old_version): for key in keys: if self._is_version_key(key): continue - if self._key_has_version(key, old_version): + try: + if self._key_has_version(key, old_version): + self.redis.delete(key) + except (json.JSONDecodeError, KeyError): + # If we can't parse the entry, delete it to be safe self.redis.delete(key) def _is_version_key(self, key): @@ -413,15 +417,10 @@ def _redis_key_to_string(self, key): return key def _key_has_version(self, key, old_version): - try: - data = self.redis.get(key) - if not data: - return False - return json.loads(data).get("flag_version") == old_version - except (json.JSONDecodeError, KeyError): - # If we can't parse the entry, delete it to be safe - self.redis.delete(key) + data = self.redis.get(key) + if not data: return False + return json.loads(data).get("flag_version") == old_version def clear(self): try: From 3fc50013697e77abcd44206c062da8a2ca5d2675 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 26 May 2026 11:50:26 +0200 Subject: [PATCH 5/9] pin cache action --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72330d6b..072c68a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,7 +137,7 @@ jobs: - name: Restore mutmut cache id: mutmut-cache if: steps.changes.outputs.changed == 'true' - uses: actions/cache@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') }} From 4b91f316ef991c974d2dd8268c9ff3d984616766 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 26 May 2026 12:05:35 +0200 Subject: [PATCH 6/9] address pr review feedback --- posthog/test/test_utils.py | 133 +++++++++++++++++++++++++------------ posthog/utils.py | 12 ++-- 2 files changed, 96 insertions(+), 49 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 5bb4958c..2c467efd 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -2,6 +2,7 @@ import sys import time import unittest +from contextlib import ExitStack from unittest import mock from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone, tzinfo @@ -279,53 +280,99 @@ def test_regex_datetime_and_case_helpers(self): assert utils.str_iequals("Hello World", "hello world") is True assert utils.str_iequals("Hello World", "hello") is False - def test_get_os_info_branches(self): - with ( - mock.patch.object(utils.sys, "platform", "win32"), - mock.patch.object( - utils.platform, "win32_ver", return_value=("11", "", "", "") + @parameterized.expand( + [ + ( + "win32 with version", + "win32", + {"$os": "Windows", "$os_version": "11"}, + ("11", "", "", ""), ), - ): - assert utils.get_os_info() == {"$os": "Windows", "$os_version": "11"} - - with ( - mock.patch.object(utils.sys, "platform", "win32"), - mock.patch.object( - utils.platform, "win32_ver", return_value=("", "", "", "") + ( + "win32 without version", + "win32", + {"$os": "Windows", "$os_version": ""}, + ("", "", "", ""), ), - ): - assert utils.get_os_info() == {"$os": "Windows", "$os_version": ""} - - with ( - mock.patch.object(utils.sys, "platform", "darwin"), - mock.patch.object( - utils.platform, "mac_ver", return_value=("14.4", ("", "", ""), "") + ( + "darwin", + "darwin", + {"$os": "Mac OS X", "$os_version": "14.4"}, + None, + ("14.4", ("", "", ""), ""), ), - ): - assert utils.get_os_info() == {"$os": "Mac OS X", "$os_version": "14.4"} - - with ( - mock.patch.object(utils.sys, "platform", "linux"), - mock.patch.object(utils.distro, "info", return_value={"version": "24.04"}), - mock.patch.object(utils.distro, "name", return_value="Ubuntu"), - ): - assert utils.get_os_info() == { - "$os": "Linux", - "$os_version": "24.04", - "$os_distro": "Ubuntu", - } - - with ( - mock.patch.object(utils.sys, "platform", "freebsd13"), - mock.patch.object(utils.platform, "release", return_value="13.2"), - ): - assert utils.get_os_info() == {"$os": "FreeBSD", "$os_version": "13.2"} + ( + "linux", + "linux", + { + "$os": "Linux", + "$os_version": "24.04", + "$os_distro": "Ubuntu", + }, + None, + None, + {"version": "24.04"}, + "Ubuntu", + ), + ( + "freebsd", + "freebsd13", + {"$os": "FreeBSD", "$os_version": "13.2"}, + None, + None, + None, + None, + "13.2", + ), + ( + "generic fallback", + "sunos", + {"$os": "sunos", "$os_version": "5.11"}, + None, + None, + None, + None, + "5.11", + ), + ] + ) + def test_get_os_info_branches( + self, + _name, + sys_platform, + expected, + win32_ver=None, + mac_ver=None, + distro_info=None, + distro_name=None, + release=None, + ): + patches = [mock.patch.object(utils.sys, "platform", sys_platform)] + if win32_ver is not None: + patches.append( + mock.patch.object(utils.platform, "win32_ver", return_value=win32_ver) + ) + if mac_ver is not None: + patches.append( + mock.patch.object(utils.platform, "mac_ver", return_value=mac_ver) + ) + if distro_info is not None: + patches.append( + mock.patch.object(utils.distro, "info", return_value=distro_info) + ) + if distro_name is not None: + patches.append( + mock.patch.object(utils.distro, "name", return_value=distro_name) + ) + if release is not None: + patches.append( + mock.patch.object(utils.platform, "release", return_value=release) + ) - with ( - mock.patch.object(utils.sys, "platform", "sunos"), - mock.patch.object(utils.platform, "release", return_value="5.11"), - ): - assert utils.get_os_info() == {"$os": "sunos", "$os_version": "5.11"} + with ExitStack() as stack: + for patch in patches: + stack.enter_context(patch) + assert utils.get_os_info() == expected def test_system_context(self): with ( diff --git a/posthog/utils.py b/posthog/utils.py index 40626926..bdbe34e2 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -199,9 +199,10 @@ def get_cached_flag(self, distinct_id, flag_key, current_flag_version): return None - # fmt: off - def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=CACHE_STALE_TTL): - # fmt: on + def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None): + if max_stale_age is None: + max_stale_age = CACHE_STALE_TTL + current_time = time.time() if distinct_id not in self.cache: @@ -231,9 +232,8 @@ def set_cached_flag( self.cache[distinct_id] = {} # Store the flag result - # fmt: off - self.cache[distinct_id][flag_key] = FlagCacheEntry(flag_result, flag_definition_version) - # fmt: on + entry = FlagCacheEntry(flag_result, flag_definition_version) + self.cache[distinct_id][flag_key] = entry self.access_times[distinct_id] = current_time def invalidate_version(self, old_version): From 198f2faf3808bead7c3f68fdac96fc8c21705a77 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 26 May 2026 12:07:08 +0200 Subject: [PATCH 7/9] pin mutation test cache action --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8906fa1..417167c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,7 @@ jobs: - name: Restore mutmut cache id: mutmut-cache if: steps.changes.outputs.changed == 'true' - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: mutants key: mutmut-${{ runner.os }}-py311-${{ hashFiles('posthog/utils.py', 'posthog/test/test_utils.py', 'posthog/test/test_size_limited_dict.py') }} From 5e5c6cc2c971e551dd722de5cfee656e9be677ea Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 26 May 2026 12:26:37 +0200 Subject: [PATCH 8/9] restore useful utility comments --- posthog/utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/posthog/utils.py b/posthog/utils.py index fdf42686..c77236d6 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -69,10 +69,13 @@ def clean(item): def _clean_pydantic_model(item): + # Pydantic model try: + # v2+ model_dump = getattr(item, "model_dump", None) if callable(model_dump): return model_dump() + # v1 dict_method = getattr(item, "dict", None) if callable(dict_method): return dict_method() @@ -247,6 +250,7 @@ def invalidate_version(self, old_version): if self._remove_flags_with_version(user_flags, old_version) ] + # Clean up empty users for distinct_id in users_to_remove: self._remove_user(distinct_id) @@ -256,8 +260,12 @@ def _remove_flags_with_version(self, user_flags, old_version): for flag_key, entry in user_flags.items() if entry.flag_definition_version == old_version ] + + # Remove invalidated flags for flag_key in flags_to_remove: del user_flags[flag_key] + + # Remove user entirely if no flags remain return not user_flags def _remove_user(self, distinct_id): @@ -383,6 +391,8 @@ def set_cached_flag( def invalidate_version(self, old_version): try: + # For Redis, scan for keys with old version and delete them. This could + # be expensive with many keys, but it's necessary for correctness. cursor = 0 pattern = f"{self.key_prefix}*" From 1d69d5740d0f7e91616c691b14ee42b457b7166e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Tue, 26 May 2026 16:59:51 +0200 Subject: [PATCH 9/9] test: add utils acceptance scenarios (#578) --- posthog/test/features/utils.feature | 30 +++++ posthog/test/test_utils_acceptance.py | 151 ++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 150 ++++++++++++++++++++++++- 4 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 posthog/test/features/utils.feature create mode 100644 posthog/test/test_utils_acceptance.py diff --git a/posthog/test/features/utils.feature b/posthog/test/features/utils.feature new file mode 100644 index 00000000..9b57d647 --- /dev/null +++ b/posthog/test/features/utils.feature @@ -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 + When the SDK cleans the event payload + Then the cleaned payload value equals + + 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 diff --git a/posthog/test/test_utils_acceptance.py b/posthog/test/test_utils_acceptance.py new file mode 100644 index 00000000..adfd09de --- /dev/null +++ b/posthog/test/test_utils_acceptance.py @@ -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", + } diff --git a/pyproject.toml b/pyproject.toml index f4cfc0f9..58180487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ test = [ "claude-agent-sdk", "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp-proto-http>=1.20.0", + "pytest-bdd>=8.1.0", ] [tool.setuptools] diff --git a/uv.lock b/uv.lock index 7175f202..7d28c730 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-08T23:44:49.815019Z" exclude-newer-span = "P7D" [[package]] @@ -743,6 +743,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "gherkin-official" +version = "29.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d8/7a28537efd7638448f7512a0cce011d4e3bf1c7f4794ad4e9c87b3f1e98e/gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb", size = 32303, upload-time = "2024-08-12T09:41:09.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fc/b86c22ad3b18d8324a9d6fe5a3b55403291d2bf7572ba6a16efa5aa88059/gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc", size = 37085, upload-time = "2024-08-12T09:41:07.954Z" }, +] + [[package]] name = "google-auth" version = "2.40.3" @@ -1378,6 +1387,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/a5/899a4719e02ff4383f3f96e5d1878f882f734377f10dfb69e73b5f223e44/lxml-6.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c86df1c9af35d903d2b52d22ea3e66db8058d21dc0f59842ca5deb0595921141", size = 3517946, upload-time = "2025-06-26T16:28:07.665Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1390,6 +1411,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "marshmallow" version = "3.26.1" @@ -2028,6 +2134,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475, upload-time = "2023-03-27T02:01:09.31Z" }, ] +[[package]] +name = "parse" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/a2/dd269daedd5ac3a244ca7855b4878d8655393fd4554d5c24a56bc31e302a/parse-1.22.0.tar.gz", hash = "sha256:d4987d68ccf08b6ba3bf80b5004ff7de61c4337cba2d8350ae5c9925794979d9", size = 36767, upload-time = "2026-05-02T01:36:25.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3a/0c2cf5922c6133b74c1cebe4b66f6949818e2cf8121aa59e3ebcd64ac6ac/parse-1.22.0-py2.py3-none-any.whl", hash = "sha256:eea8ed34e2614cea65d9c1d4af9cb68cce26aea13d44bdcaf83c1b40884fe945", size = 20839, upload-time = "2026-05-02T01:36:24.403Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -2111,6 +2239,7 @@ test = [ { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-bdd" }, { name = "pytest-timeout" }, { name = "python-dateutil" }, { name = "tiktoken" }, @@ -2155,6 +2284,7 @@ requires-dist = [ { name = "pydantic", marker = "extra == 'test'", specifier = ">=2.12.0" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-bdd", marker = "extra == 'test'", specifier = ">=8.1.0" }, { name = "pytest-timeout", marker = "extra == 'test'" }, { name = "python-dateutil", marker = "extra == 'test'", specifier = ">=2.9.0.post0" }, { name = "requests", specifier = ">=2.7,<3.0" }, @@ -2528,6 +2658,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, ] +[[package]] +name = "pytest-bdd" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gherkin-official" }, + { name = "mako" }, + { name = "packaging" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/2f/14c2e55372a5718a93b56aea48cd6ccc15d2d245364e516cd7b19bbd07ad/pytest_bdd-8.1.0.tar.gz", hash = "sha256:ef0896c5cd58816dc49810e8ff1d632f4a12019fb3e49959b2d349ffc1c9bfb5", size = 56147, upload-time = "2024-12-05T21:45:58.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7d/1461076b0cc9a9e6fa8b51b9dea2677182ba8bc248d99d95ca321f2c666f/pytest_bdd-8.1.0-py3-none-any.whl", hash = "sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb", size = 49149, upload-time = "2024-12-05T21:45:56.184Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0"