From a4af580f6acd19515d6db5e7375603e35dd216b5 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 11 Jun 2026 20:30:46 +1000 Subject: [PATCH] test: fix latent doctests + competitive suite, declare competitor test deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three latent hygiene issues, none caught by gating CI (it runs targeted tests/unit+critical paths and --doctest-modules only over tests/, not src/): 1. Broken src doctests (failed under `pytest --doctest-modules src/`): - backends/base.py: `RedisBackend()` needs DI/config, so the bare-construct line raised and the isinstance check NameError'd. Use `issubclass(...)`, which demonstrates the runtime-checkable protocol without a live backend. - reliability/metrics_collection.py: doctest called a non-existent `clear_metrics()`. Removed it — the assertion holds regardless of prior state (no need to invent a function for one doctest). 2. Undeclared competitor deps: tests/competitive/ hard-imports cachetools and aiocache but neither was declared, so the whole comparison suite failed to collect. Declared both in [dependency-groups] dev. 3. Declaring the deps un-hid 3 bit-rotted competitive tests (test bugs, not product regressions — verified): - tuple/nested-tuple preservation asserted the old 'L1 serializes -> list' behavior; cachekit's L1-only path now stores native objects, so tuples are preserved (confirmed). Updated assertions + docstrings. - test_cachekit_has_ttl used time_machine, which doesn't drive L1's time.time() expiry in that path; switched to a real short sleep (L1 TTL verified working with real time). --- pyproject.toml | 3 + src/cachekit/backends/base.py | 4 +- .../reliability/metrics_collection.py | 1 - tests/competitive/test_head_to_head.py | 61 ++++++++++--------- uv.lock | 22 +++++++ 5 files changed, 58 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6f47ab..db73d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,6 +208,9 @@ dev = [ "pytest-markdown-docs>=0.6.0", "pytest-redis>=3.0.0", "pymemcache>=4.0.0", + # Competitive comparison suite (tests/competitive/ benchmarks cachekit vs these) + "cachetools>=5.3.0", + "aiocache>=0.12.0", # Code quality "basedpyright>=1.32.1", "ruff>=0.6.0", diff --git a/src/cachekit/backends/base.py b/src/cachekit/backends/base.py index ba2907c..6e3d1e8 100644 --- a/src/cachekit/backends/base.py +++ b/src/cachekit/backends/base.py @@ -32,9 +32,9 @@ class BaseBackend(Protocol): Example: >>> from cachekit.backends import BaseBackend, RedisBackend - >>> backend = RedisBackend() - >>> isinstance(backend, BaseBackend) # Runtime checkable protocol + >>> issubclass(RedisBackend, BaseBackend) # structural (runtime-checkable) protocol True + >>> backend = RedisBackend() # doctest: +SKIP >>> backend.set("key", b"value", ttl=60) # doctest: +SKIP >>> data = backend.get("key") # doctest: +SKIP """ diff --git a/src/cachekit/reliability/metrics_collection.py b/src/cachekit/reliability/metrics_collection.py index 680e948..1175988 100644 --- a/src/cachekit/reliability/metrics_collection.py +++ b/src/cachekit/reliability/metrics_collection.py @@ -110,7 +110,6 @@ def get_all_metrics() -> dict[str, dict[str, Any]]: """Get all collected metrics. Examples: - >>> clear_metrics() # Start fresh >>> counter = MetricsCollector("api_calls") >>> counter.inc() >>> metrics = get_all_metrics() diff --git a/tests/competitive/test_head_to_head.py b/tests/competitive/test_head_to_head.py index 891049a..ebc43f0 100644 --- a/tests/competitive/test_head_to_head.py +++ b/tests/competitive/test_head_to_head.py @@ -33,12 +33,11 @@ import math import time import uuid -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from functools import lru_cache from typing import Any import pytest -import time_machine from cachetools import TTLCache, cached from cachekit import cache @@ -210,9 +209,10 @@ def test_tuple_preservation(self): lru_cache: preserves (in-memory, no serialization) cachetools: preserves (in-memory, no serialization) - cachekit L1-only: DOES NOT preserve — serializes via MessagePack which - converts tuples to lists, even in L1-only mode. This is a known - tradeoff: consistent serialization behavior regardless of backend. + cachekit L1-only (backend=None): preserves — the L1 cache stores native + object references with no serialization, so tuples survive. MessagePack + serialization (which converts tuples to lists) only happens when values + are written out to an L2 backend. """ def fn(): @@ -224,9 +224,9 @@ def fn(): assert isinstance(lru_result, tuple), "lru_cache preserves tuples" assert isinstance(ct_result, tuple), "cachetools preserves tuples" - # cachekit serializes even in L1-only mode — tuples become lists - assert isinstance(ck_result, list), "cachekit converts tuples to lists via MessagePack" - assert ck_result == [1, 2, 3] + # cachekit L1-only stores native objects (no serialization) — tuples preserved + assert isinstance(ck_result, tuple), "cachekit L1-only preserves tuples (native object cache)" + assert ck_result == (1, 2, 3) def test_set_preservation(self): """Set type preservation. @@ -264,7 +264,8 @@ def fn(): def test_nested_dict_of_lists_of_tuples(self): """Complex nested structure preservation. - cachekit serializes even in L1 mode, so inner tuples become lists. + cachekit L1-only stores native objects (no serialization), so inner + tuples are preserved just like lru_cache and cachetools. """ def fn(): @@ -277,8 +278,8 @@ def fn(): # lru_cache and cachetools preserve (in-memory, no serialization) assert isinstance(lru_result["users"][0], tuple) assert isinstance(ct_result["users"][0], tuple) - # cachekit serializes — tuples become lists - assert isinstance(ck_result["users"][0], list) + # cachekit L1-only stores native objects — inner tuples preserved + assert isinstance(ck_result["users"][0], tuple) class TestSpecialFloats: @@ -558,30 +559,30 @@ def fn(x): assert call_count == 2 # Re-executed after TTL def test_cachekit_has_ttl(self): - """cachekit supports TTL with decorator parameter.""" - call_count = 0 - - with time_machine.travel(0, tick=False) as traveller: + """cachekit supports TTL with decorator parameter. - @cache(backend=None, ttl=2) - def fn(x): - nonlocal call_count - call_count += 1 - return x * 2 + Uses a real (short) sleep rather than time_machine: cachekit's L1 cache + keys expiry on time.time(), and a real-time wait reliably exercises it + without coupling to clock-patching internals. + """ + call_count = 0 - fn(1) - first_count = call_count + @cache(backend=None, ttl=1) + def fn(x): + nonlocal call_count + call_count += 1 + return x * 2 - fn(1) - # May or may not cache depending on L1 implementation details - second_count = call_count + fn(1) + fn(1) # cached: no re-execution + second_count = call_count - traveller.shift(timedelta(seconds=3)) # Advance clock past TTL - fn(1) - # After TTL expiry, function MUST re-execute - assert call_count > second_count, "Function should re-execute after TTL expires" + time.sleep(1.2) # wait past the 1s TTL + fn(1) + # After TTL expiry, the function MUST re-execute + assert call_count > second_count, "Function should re-execute after TTL expires" - fn.cache_clear() + fn.cache_clear() class TestCacheManagement: diff --git a/uv.lock b/uv.lock index e1d6f8d..2de779e 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,15 @@ constraints = [ { name = "werkzeug", specifier = ">=3.1.4" }, ] +[[package]] +name = "aiocache" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/64/b945b8025a9d1e6e2138845f4022165d3b337f55f50984fbc6a4c0a1e355/aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713", size = 132196, upload-time = "2024-09-25T13:20:23.823Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d7/15d67e05b235d1ed8c3ce61688fe4d84130e72af1657acadfaac3479f4cf/aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d", size = 28199, upload-time = "2024-09-25T13:20:22.688Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -250,8 +259,10 @@ memcached = [ [package.dev-dependencies] dev = [ + { name = "aiocache" }, { name = "basedpyright" }, { name = "bashlex" }, + { name = "cachetools" }, { name = "faker" }, { name = "fakeredis" }, { name = "httpx" }, @@ -302,8 +313,10 @@ provides-extras = ["data", "memcached"] [package.metadata.requires-dev] dev = [ + { name = "aiocache", specifier = ">=0.12.0" }, { name = "basedpyright", specifier = ">=1.32.1" }, { name = "bashlex", specifier = ">=0.18" }, + { name = "cachetools", specifier = ">=5.3.0" }, { name = "faker", specifier = ">=20.0.0" }, { name = "fakeredis", specifier = ">=2.21.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -329,6 +342,15 @@ dev = [ ] fuzz = [{ name = "atheris", specifier = ">=2.3.0" }] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2025.11.12"