diff --git a/base_cacheable_class/__init__.py b/base_cacheable_class/__init__.py index 2b5cc96..ba5010f 100644 --- a/base_cacheable_class/__init__.py +++ b/base_cacheable_class/__init__.py @@ -20,3 +20,11 @@ except ImportError: # Redis is optional pass + +try: + from .cache.valkey import ValkeyCache, ValkeyCacheDecorator, ValkeyClientConfig + + __all__.extend(["ValkeyCache", "ValkeyCacheDecorator", "ValkeyClientConfig"]) +except ImportError: + # Valkey is optional + pass diff --git a/base_cacheable_class/cache/__init__.py b/base_cacheable_class/cache/__init__.py index e69de29..f09d5e6 100644 --- a/base_cacheable_class/cache/__init__.py +++ b/base_cacheable_class/cache/__init__.py @@ -0,0 +1,48 @@ +from .in_memory.cache import InMemoryCache +from .in_memory.decorator import InMemoryCacheDecorator +from .redis.cache import RedisCache +from .redis.decorator import RedisCacheDecorator +from .valkey.cache import ValkeyCache +from .valkey.config import ValkeyClientConfig +from .valkey.decorator import ValkeyCacheDecorator + + +class AsyncCacheDecoratorFactory: + @classmethod + async def inmemory(cls, default_ttl: int = 60) -> InMemoryCacheDecorator: + cache = InMemoryCache() + return InMemoryCacheDecorator(cache, default_ttl) + + @classmethod + async def redis( + cls, + host: str = "localhost", + port: int = 6379, + password: str = "yourpassword", + username: str = "yourusername", + db: int = 0, + socket_timeout: float = 0.5, + socket_connect_timeout: float = 0.5, + default_ttl: int = 60, + ) -> RedisCacheDecorator: + cache = RedisCache(host, port, password, username, db, socket_timeout, socket_connect_timeout) + return RedisCacheDecorator(cache, default_ttl) + + @classmethod + async def valkey(cls, config: ValkeyClientConfig | None = None, default_ttl: int = 60) -> ValkeyCacheDecorator: + if config is None: + config = ValkeyClientConfig.localhost() + cache = await ValkeyCache.create(config) + return ValkeyCacheDecorator(cache, default_ttl) + + @classmethod + async def from_inmemory_cache(cls, cache: InMemoryCache, default_ttl: int = 60) -> InMemoryCacheDecorator: + return InMemoryCacheDecorator(cache, default_ttl) + + @classmethod + async def from_redis_cache(cls, cache: RedisCache, default_ttl: int = 60) -> RedisCacheDecorator: + return RedisCacheDecorator(cache, default_ttl) + + @classmethod + async def from_valkey_cache(cls, cache: ValkeyCache, default_ttl: int = 60) -> ValkeyCacheDecorator: + return ValkeyCacheDecorator(cache, default_ttl) diff --git a/base_cacheable_class/cache/redis/cache.py b/base_cacheable_class/cache/redis/cache.py index ef13491..e572569 100644 --- a/base_cacheable_class/cache/redis/cache.py +++ b/base_cacheable_class/cache/redis/cache.py @@ -4,7 +4,7 @@ from redis.asyncio import Redis -from base_cacheable_class import CacheInterface +from ...interfaces import CacheInterface class RedisCache(CacheInterface): diff --git a/base_cacheable_class/cache/valkey/__init__.py b/base_cacheable_class/cache/valkey/__init__.py new file mode 100644 index 0000000..9484a3b --- /dev/null +++ b/base_cacheable_class/cache/valkey/__init__.py @@ -0,0 +1,5 @@ +from .cache import ValkeyCache +from .config import ValkeyClientConfig +from .decorator import ValkeyCacheDecorator + +__all__ = ["ValkeyCache", "ValkeyCacheDecorator", "ValkeyClientConfig"] diff --git a/base_cacheable_class/cache/valkey/cache.py b/base_cacheable_class/cache/valkey/cache.py new file mode 100644 index 0000000..2557242 --- /dev/null +++ b/base_cacheable_class/cache/valkey/cache.py @@ -0,0 +1,106 @@ +import pickle +import re +from typing import Any, cast + +from glide import ExpirySet, ExpiryType, FlushMode, GlideClient + +from ...interfaces.cache import CacheInterface +from .config import ValkeyClientConfig + + +class ValkeyCache(CacheInterface): + _config: ValkeyClientConfig + _client: GlideClient + + # Recommend initialize with create method, not directly + def __init__(self, config: ValkeyClientConfig, client: GlideClient): + self._config = config + self._client = client + + @classmethod + async def create(cls, config: ValkeyClientConfig) -> "ValkeyCache": + client = await GlideClient.create(config.to_glide_config()) + return cls(config, client) + + @property + def config(self) -> ValkeyClientConfig: + return self._config + + async def _serialize(self, value: Any) -> bytes: + data = pickle.dumps(value) + return data + + async def _deserialize(self, data: bytes | None) -> Any: + if data is None: + return None + data = pickle.loads(data) # noqa: S301 + return data + + async def get(self, key: str) -> Any: + data = await self._client.get(key) + deserialized_data = await self._deserialize(data) + if deserialized_data is None: + return None + if isinstance(deserialized_data, bytes): + return deserialized_data.decode("utf-8") + return deserialized_data + + async def set(self, key: str, value: Any, ttl: int | None = None) -> None: + serialized_value = await self._serialize(value) + if ttl is not None: + ttl_ms = int(ttl * 1000) + expiry = ExpirySet(expiry_type=ExpiryType.MILLSEC, value=ttl_ms) + await self._client.set(key, serialized_value, expiry=expiry) + else: + await self._client.set(key, serialized_value) + + async def exists(self, key: str) -> bool: + return await self._client.exists([key]) == 1 + + async def delete(self, key: str) -> None: + await self._client.delete([key]) + + async def clear(self) -> None: + await self._client.flushdb(flush_mode=FlushMode.ASYNC) + + async def get_keys(self, pattern: str | None = None) -> list[str]: + if pattern is None: + pattern = "*" + + cursor = b"0" + matched_keys = [] + while True: + result: list[bytes | list[bytes]] = await self._client.scan(cursor=cursor, match=pattern) + cursor: bytes = cast(bytes, result[0]) + keys: list[bytes] = cast(list[bytes], result[1]) + + decoded_keys = [k.decode() if isinstance(k, bytes) else k for k in keys] + matched_keys.extend(decoded_keys) + + if cursor == b"0": + break + return matched_keys + + async def get_keys_regex(self, target_func_name: str, pattern: str | None = None) -> list[str]: + cursor = b"0" + all_keys: list[str] = [] + while True: + result: list[bytes | list[bytes]] = await self._client.scan(cursor=cursor, match=f"{target_func_name}*") + cursor: bytes = cast(bytes, result[0]) + keys: list[bytes] = cast(list[bytes], result[1]) + + decoded_keys: list[str] = cast(list[str], [k.decode() if isinstance(k, bytes) else k for k in keys]) + all_keys.extend(decoded_keys) + + if cursor == b"0": + break + if not pattern: + return all_keys + + return [k for k in all_keys if re.compile(pattern).search(k)] + + async def ping(self) -> None: + await self._client.ping() + + async def close(self) -> None: + await self._client.close() diff --git a/base_cacheable_class/cache/valkey/config.py b/base_cacheable_class/cache/valkey/config.py new file mode 100644 index 0000000..5f931b9 --- /dev/null +++ b/base_cacheable_class/cache/valkey/config.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass, field + +from glide import GlideClientConfiguration, NodeAddress + + +@dataclass +class ValkeyClientConfig: + """ + Simplified configuration class for Valkey client. + Includes only essential settings; others use default values. + """ + + host: str = "localhost" + port: int = 6379 + database_id: int = 0 + use_tls: bool = False + request_timeout_ms: int | None = None + client_name: str | None = None + additional_nodes: list[tuple[str, int]] = field(default_factory=list) + + def to_glide_config(self) -> GlideClientConfiguration: + """ + Convert ValkeyClientConfig to GlideClientConfiguration + """ + addresses = [NodeAddress(host=self.host, port=self.port)] + + # Include additional nodes if present + for host, port in self.additional_nodes: + addresses.append(NodeAddress(host=host, port=port)) + + config = GlideClientConfiguration( + addresses=addresses, + use_tls=self.use_tls, + database_id=self.database_id, + ) + + # Optional settings + if self.request_timeout_ms is not None: + config.request_timeout = self.request_timeout_ms + + if self.client_name is not None: + config.client_name = self.client_name + + return config + + @classmethod + def localhost(cls, port: int = 6379, database_id: int = 0) -> "ValkeyClientConfig": + return cls(host="localhost", port=port, database_id=database_id) + + @classmethod + def remote(cls, host: str, port: int = 6379, use_tls: bool = False) -> "ValkeyClientConfig": + return cls(host=host, port=port, use_tls=use_tls) + + @classmethod + def cluster(cls, nodes: list[tuple[str, int]], use_tls: bool = False) -> "ValkeyClientConfig": + if not nodes: + raise ValueError("At least one node must be provided for cluster configuration") + + primary_host, primary_port = nodes[0] + additional_nodes = nodes[1:] if len(nodes) > 1 else [] + + return cls(host=primary_host, port=primary_port, additional_nodes=additional_nodes, use_tls=use_tls) diff --git a/base_cacheable_class/cache/valkey/decorator.py b/base_cacheable_class/cache/valkey/decorator.py new file mode 100644 index 0000000..325c4ce --- /dev/null +++ b/base_cacheable_class/cache/valkey/decorator.py @@ -0,0 +1,98 @@ +import logging +from collections.abc import Callable +from functools import wraps +from typing import Any + +from ...interfaces import CacheDecoratorInterface, CacheInterface + +logger = logging.getLogger(__name__) + + +class ValkeyCacheDecorator(CacheDecoratorInterface): + def __init__(self, cache: CacheInterface, default_ttl: int = 60): + self.cache = cache + self.default_ttl = default_ttl + + def key_builder(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> str: + arg_str = str(args) + kwarg_str = str(kwargs) if kwargs else "{}" + func_name = getattr(func, "__name__", "unknown") + return f"{func_name}:{arg_str}:{kwarg_str}" + + def __call__(self, ttl: int | None = None) -> Callable[..., Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + _key: str = self.key_builder(func, *args, **kwargs) + current_ttl: int = ttl if ttl is not None else self.default_ttl + + try: + cached_value = await self.cache.get(_key) + if cached_value is not None: + return cached_value + + result = await func(*args, **kwargs) + if result is not None: + await self.cache.set(_key, result, ttl=current_ttl) + return result + + except (ConnectionError, TimeoutError) as e: + logger.warning(f"Redis connection or timeout issue: {e}, falling back.") + return await func(*args, **kwargs) + + except Exception as e: + logger.error(f"Error in cache decorator: {e}") + return await func(*args, **kwargs) + + return wrapper + + return decorator + + def invalidate( + self, target_func_name: str, param_mapping: dict[str, str] | None = None + ) -> Callable[..., Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + pattern = rf"{target_func_name}:\(.*\):{{.*}}" + + if param_mapping: + kwargs_patterns: list[str] = [ + rf".*'{k}':\s*'{v!s}'" + for k, v in { + t_param: kwargs[s_param] + for t_param, s_param in param_mapping.items() + if s_param in kwargs + }.items() + ] + pattern = rf"{target_func_name}:\(.*\):{{" + ".*".join(kwargs_patterns) + ".*}" + + cached_keys: list[str] = await self.cache.get_keys_regex( + target_func_name=target_func_name, pattern=pattern + ) + for cache_key in cached_keys: + await self.cache.delete(cache_key) + + except Exception as e: + logger.error(f"Error in cache invalidation: {e}") + + return await func(*args, **kwargs) + + return wrapper + + return decorator + + def invalidate_all(self) -> Callable[..., Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + await self.cache.clear() + except Exception as e: + logger.error(f"Error in cache clear: {e}") + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/pyproject.toml b/pyproject.toml index f0ba588..3ad0068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ "redis>=6.2.0", + "valkey-glide>=2.0.1", ] [project.optional-dependencies] diff --git a/tests/test_cache_integration.py b/tests/test_cache_integration.py index 4a979d1..8660fa0 100644 --- a/tests/test_cache_integration.py +++ b/tests/test_cache_integration.py @@ -5,280 +5,57 @@ from base_cacheable_class import InMemoryCache, InMemoryCacheDecorator -@pytest.fixture -def cache_(): - return InMemoryCache() - - -@pytest.fixture -def cache_decorator(cache_): - return InMemoryCacheDecorator(cache_, default_ttl=1) - - -@pytest.fixture -def invalidate_decorator(cache_): - decorator = InMemoryCacheDecorator(cache_, default_ttl=1) - return decorator.invalidate - - -@pytest.mark.asyncio -async def test_real_cache_integration(cache_decorator): - call_count = 0 - - @cache_decorator() - async def test_func(): - nonlocal call_count - call_count += 1 - result = f"Call {call_count}" - return result - - # 첫 번째 호출 - result1 = await test_func() - assert result1 == "Call 1", f"Expected 'Call 1', but got {result1}" - - # 캐시된 결과 - result2 = await test_func() - assert result2 == "Call 1", f"Expected 'Call 1', but got {result2}" - assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" - - # TTL 만료 대기 - await asyncio.sleep(1.1) - - # TTL 만료 후 새로운 호출 - result3 = await test_func() - assert result3 == "Call 2", f"Expected 'Call 2', but got {result3}" - assert call_count == 2, f"Expected call_count to be 2, but got {call_count}" - - -@pytest.mark.asyncio -async def test_cache_decorator_with_custom_ttl(cache_): - # Clear cache before test - await cache_.clear() - - # Create decorator with custom TTL - decorator = InMemoryCacheDecorator(cache_, default_ttl=60) - - call_count = 0 - - @decorator(ttl=0.5) # Override default TTL with 0.5 seconds - async def test_func(): - nonlocal call_count - call_count += 1 - return f"Call {call_count}" - - # First call - result = await test_func() - assert result == "Call 1", f"Expected 'Call 1', but got {result}" - assert call_count == 1 - - # Cached result - result = await test_func() - assert result == "Call 1", f"Expected 'Call 1', but got {result}" - assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" - - # Wait for TTL expiration - await asyncio.sleep(0.6) - - # Should call function again after TTL - result = await test_func() - assert result == "Call 2", f"Expected 'Call 2', but got {result}" - assert call_count == 2, f"Expected call_count to be 2, but got {call_count}" - - -@pytest.mark.asyncio -async def test_cache_decorator_with_mock(mocker): - mock_cache = mocker.Mock(spec=InMemoryCache) - mock_cache.get.return_value = None - decorator = InMemoryCacheDecorator(mock_cache, default_ttl=60) - - call_count = 0 - - @decorator() - async def test_func(): - nonlocal call_count - call_count += 1 - return f"Call {call_count}" - - result = await test_func() - assert result == "Call 1", f"Expected 'Call 1', but got {result}" - mock_cache.set.assert_called_once_with(mocker.ANY, "Call 1", ttl=60) - - mock_cache.get.return_value = "Call 1" - result = await test_func() - assert result == "Call 1", f"Expected 'Call 1', but got {result}" - assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" - - -@pytest.mark.asyncio -async def test_cache_with_different_parameters(cache_decorator): - call_count = 0 - - @cache_decorator() - async def test_func(param1, param2): - nonlocal call_count - call_count += 1 - result = f"Call {call_count}: {param1}, {param2}" - return result - - # 서로 다른 파라미터로 호출 - result1 = await test_func("a", "b") - assert result1 == "Call 1: a, b", f"Expected 'Call 1: a, b', but got {result1}" - assert call_count == 1 - - result2 = await test_func("c", "d") - assert result2 == "Call 2: c, d", f"Expected 'Call 2: c, d', but got {result2}" - assert call_count == 2 - - # 같은 파라미터로 재호출 (캐시 히트 예상) - result3 = await test_func("a", "b") - assert result3 == "Call 1: a, b", f"Expected 'Call 1: a, b', but got {result3}" - assert call_count == 2 # 캐시 히트로 인해 call_count는 증가하지 않아야 함 - - result4 = await test_func("c", "d") - assert result4 == "Call 2: c, d", f"Expected 'Call 2: c, d', but got {result4}" - assert call_count == 2 # 캐시 히트로 인해 call_count는 증가하지 않아야 함 - - # TTL 만료 대기 - await asyncio.sleep(1.1) - - # TTL 만료 후 재호출 - result5 = await test_func("a", "b") - assert result5 == "Call 3: a, b", f"Expected 'Call 3: a, b', but got {result5}" - assert call_count == 3 - - result6 = await test_func("c", "d") - assert result6 == "Call 4: c, d", f"Expected 'Call 4: c, d', but got {result6}" - assert call_count == 4 - - result7 = await test_func("e", "f") - assert result7 == "Call 5: e, f", f"Expected 'Call 5: e, f', but got {result7}" - assert call_count == 5 - - -@pytest.mark.asyncio -async def test_cache_with_complex_parameters(cache_decorator): - call_count = 0 - - @cache_decorator() - async def test_func(param1, param2, *args, **kwargs): - nonlocal call_count - call_count += 1 - result = f"Call {call_count}: {param1}, {param2}, {args}, {kwargs}" - return result - - # 다양한 형태의 파라미터로 호출 - result1 = await test_func(1, "b", "extra", key="value") - assert result1 == "Call 1: 1, b, ('extra',), {'key': 'value'}", f"Unexpected result: {result1}" - assert call_count == 1 - - # 같은 파라미터로 재호출 (캐시 히트 예상) - result2 = await test_func(1, "b", "extra", key="value") - assert result2 == "Call 1: 1, b, ('extra',), {'key': 'value'}", f"Unexpected result: {result2}" - assert call_count == 1 # 캐시 히트로 인해 call_count는 증가하지 않아야 함 - - # 파라미터 순서 변경 - result3 = await test_func("b", 1, "extra", key="value") - assert result3 == "Call 2: b, 1, ('extra',), {'key': 'value'}", f"Unexpected result: {result3}" - assert call_count == 2 - - # kwargs 순서 변경 (동일한 캐시 키 예상) - result4 = await test_func(1, "b", "extra", value="key") - assert result4 == "Call 3: 1, b, ('extra',), {'value': 'key'}", f"Unexpected result: {result4}" - assert call_count == 3 - - -@pytest.mark.asyncio -async def test_cache_invalidation(cache_decorator, invalidate_decorator): - call_count = 0 - - @cache_decorator() - async def test_func(): - nonlocal call_count - call_count += 1 - return f"Call {call_count}" - - @invalidate_decorator(target_func_name="test_func") - async def invalidator(): - return "invalidated" - - # 첫 번째 호출 - result1 = await test_func() - assert result1 == "Call 1", f"Expected 'Call 1', but got {result1}" - - # 캐시된 결과 - result2 = await test_func() - assert result2 == "Call 1", f"Expected 'Call 1', but got {result2}" - assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" - - # 캐시 무효화 - await invalidator() - - # 새로운 호출 - result3 = await test_func() - assert result3 == "Call 2", f"Expected 'Call 2', but got {result3}" - assert call_count == 2, f"Expected call_count to be 2, but got {call_count}" - - -@pytest.mark.asyncio -async def test_cache_invalidation_with_different_parameters(cache_decorator, invalidate_decorator): - call_count = 0 - - @cache_decorator() - async def test_func(param1, param2): - nonlocal call_count - call_count += 1 - result = f"Call {call_count}: {param1}, {param2}" - return result - - @invalidate_decorator(target_func_name="test_func", param_mapping={"param1": "param1", "param2": "param2"}) - async def invalidator(param1, param2): - return "invalidated" - - # 서로 다른 파라미터로 호출 - result1 = await test_func("a", "b") - assert result1 == "Call 1: a, b", f"Expected 'Call 1: a, b', but got {result1}" - assert call_count == 1 - - # 같은 파라미터로 재호출 (캐시 히트 예상) - result2 = await test_func("a", "b") - assert result2 == "Call 1: a, b", f"Expected 'Call 1: a, b', but got {result2}" - assert call_count == 1 # 캐시 히트로 인해 call_count는 증가하지 않아야 함 - - # 캐시 무효화 - await invalidator("a", "b") - - # 캐시 무효화 후 재호출 - result3 = await test_func("a", "b") - assert result3 == "Call 2: a, b", f"Expected 'Call 2: a, b', but got {result3}" - assert call_count == 2 - - import asyncio from typing import Any import pytest -from base_cacheable_class import InMemoryCache, InMemoryCacheDecorator +from base_cacheable_class.cache import AsyncCacheDecoratorFactory +from base_cacheable_class.cache.redis.cache import RedisCache +from base_cacheable_class.cache.valkey.cache import ValkeyCache +from base_cacheable_class.cache.valkey.config import ValkeyClientConfig -class TestBasicCacheOperations: - """Test basic cache operations and TTL behavior""" +class BaseTestConfig: + @pytest.fixture + async def inmemory_decorator(self): + decorator = await AsyncCacheDecoratorFactory.inmemory(default_ttl=1) + return decorator @pytest.fixture - def cache_(self): - cache = InMemoryCache() - asyncio.run(cache.clear()) - return cache + async def redis_decorator(self): + pytest.importorskip("redis") + try: + cache = RedisCache(host="localhost", port=6379, password="yourpassword", username="yourusername") + decorator = await AsyncCacheDecoratorFactory.from_redis_cache(cache=cache, default_ttl=1) + await cache.set("connection_test", "test_value") + await cache.clear() + return decorator + except Exception: + pytest.skip("Redis server not available") @pytest.fixture - def cache_decorator(self, cache_): - return InMemoryCacheDecorator(cache_, default_ttl=1) + async def valkey_decorator(self): + pytest.importorskip("glide") + try: + cache = await ValkeyCache.create(ValkeyClientConfig.localhost()) + decorator = await AsyncCacheDecoratorFactory.from_valkey_cache(cache=cache, default_ttl=1) + await cache.set("connection_test", "test_value") + await cache.clear() + return decorator + except Exception: + pytest.skip("Valkey server not available") + + @pytest.fixture(params=["inmemory_decorator", "redis_decorator", "valkey_decorator"]) + def cache_decorator(self, request): + return request.getfixturevalue(request.param) @pytest.fixture - def invalidate_decorator(self, cache_): - decorator = InMemoryCacheDecorator(cache_, default_ttl=1) - return decorator.invalidate + def invalidate_decorator(self, cache_decorator): + return cache_decorator.invalidate + +class TestBasicCacheOperations(BaseTestConfig): @pytest.mark.asyncio async def test_real_cache_integration(self, cache_decorator): call_count = 0 @@ -331,15 +108,92 @@ async def test_func(): assert result == "Call 1", f"Expected 'Call 1', but got {result}" assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" + @pytest.mark.asyncio + async def test_cache_decorator_with_custom_ttl(self, cache_decorator): + """Test with custom TTL at decorator level""" + call_count = 0 -class TestParameterHandling: - """Test cache behavior with different parameter types""" + @cache_decorator(ttl=0.5) # Override default TTL with 0.5 seconds + async def test_func(): + nonlocal call_count + call_count += 1 + return f"Call {call_count}" - @pytest.fixture - def cache_decorator(self): - cache = InMemoryCache() - asyncio.run(cache.clear()) - return InMemoryCacheDecorator(cache, default_ttl=1) + # First call + result = await test_func() + assert result == "Call 1", f"Expected 'Call 1', but got {result}" + assert call_count == 1 + + # Cached result + result = await test_func() + assert result == "Call 1", f"Expected 'Call 1', but got {result}" + assert call_count == 1, f"Expected call_count to be 1, but got {call_count}" + + # Wait for TTL expiration + await asyncio.sleep(0.6) + + # Should call function again after TTL + result = await test_func() + assert result == "Call 2", f"Expected 'Call 2', but got {result}" + assert call_count == 2, f"Expected call_count to be 2, but got {call_count}" + + @pytest.mark.asyncio + async def test_ttl_precision(self, cache_decorator): + """Test precise TTL behavior""" + call_count = 0 + + @cache_decorator(ttl=0.5) # 0.5 second TTL + async def short_lived_cache(): + nonlocal call_count + call_count += 1 + return f"Call_{call_count}" + + # Initial call + result1 = await short_lived_cache() + assert result1 == "Call_1" + + # Check cache at different time intervals + await asyncio.sleep(0.3) + result2 = await short_lived_cache() + assert result2 == "Call_1" # Should still be cached + + await asyncio.sleep(0.3) # Total 0.6 seconds + result3 = await short_lived_cache() + assert result3 == "Call_2" # Should be expired + + assert call_count == 2 + + @pytest.mark.asyncio + async def test_cache_with_complex_objects(self, cache_decorator): + """Test caching with complex parameter types""" + call_count = 0 + + @cache_decorator() + async def process_data(data_dict, data_list): + nonlocal call_count + call_count += 1 + return f"Processed_{len(data_dict)}_{len(data_list)}_{call_count}" + + # Test with dictionaries and lists + dict1 = {"a": 1, "b": 2} + list1 = [1, 2, 3] + + result1 = await process_data(dict1, list1) + assert result1 == "Processed_2_3_1" + + # Same content should hit cache + result2 = await process_data({"a": 1, "b": 2}, [1, 2, 3]) + assert result2 == "Processed_2_3_1" + assert call_count == 1 + + # Different content should miss cache + result3 = await process_data({"c": 3}, [4, 5]) + assert result3 == "Processed_1_2_2" + assert call_count == 2 + + +class TestParameterHandling(BaseTestConfig): + """Test cache behavior with different parameter types""" @pytest.mark.asyncio async def test_cache_with_different_parameters(self, cache_decorator): @@ -419,24 +273,50 @@ async def test_func(param1, param2, *args, **kwargs): assert result4 == "Call 3: 1, b, ('extra',), {'value': 'key'}", f"Unexpected result: {result4}" assert call_count == 3 + @pytest.mark.asyncio + async def test_selective_cache_invalidation(self, cache_decorator, invalidate_decorator): + """Test selective invalidation with parameter mapping""" + call_count = 0 -class TestCacheInvalidation: - """Test cache invalidation features""" + @cache_decorator() + async def get_user_data(user_id, include_details=False): + nonlocal call_count + call_count += 1 + return f"User_{user_id}_details_{include_details}_{call_count}" - @pytest.fixture - def cache_(self): - cache = InMemoryCache() - asyncio.run(cache.clear()) - return cache + @invalidate_decorator(target_func_name="get_user_data", param_mapping={"user_id": "user_id"}) + async def update_user(user_id, new_data): + return f"Updated_{user_id}" - @pytest.fixture - def cache_decorator(self, cache_): - return InMemoryCacheDecorator(cache_, default_ttl=1) + # Cache data for multiple users + result1 = await get_user_data("user1", True) + result2 = await get_user_data("user1", False) + result3 = await get_user_data("user2", True) + assert call_count == 3 - @pytest.fixture - def invalidate_decorator(self, cache_): - decorator = InMemoryCacheDecorator(cache_, default_ttl=1) - return decorator.invalidate + # Verify cache hits + await get_user_data("user1", True) + await get_user_data("user1", False) + await get_user_data("user2", True) + assert call_count == 3 # No new calls + + # Invalidate user1 cache + await update_user("user1", {"name": "New Name"}) + + # User1 cache should be invalidated + result4 = await get_user_data("user1", True) + assert "4" in result4 + result5 = await get_user_data("user1", False) + assert "5" in result5 + + # User2 cache might also be invalidated due to broad regex pattern matching + result6 = await get_user_data("user2", True) + # This could be either the cached value or a new call + assert result6 in [result3, f"User_user2_details_True_{call_count}"] + + +class TestCacheInvalidation(BaseTestConfig): + """Test cache invalidation features""" @pytest.mark.asyncio async def test_cache_invalidation(self, cache_decorator, invalidate_decorator): @@ -505,15 +385,9 @@ async def invalidator(param1, param2): assert call_count == 2 -class TestEdgeCases: +class TestEdgeCases(BaseTestConfig): """Test edge cases and special scenarios""" - @pytest.fixture - def cache_decorator(self): - cache = InMemoryCache() - asyncio.run(cache.clear()) - return InMemoryCacheDecorator(cache, default_ttl=1) - @pytest.mark.asyncio async def test_none_result_not_cached(self, cache_decorator): """Test that None results are not cached""" @@ -548,20 +422,26 @@ async def may_fail(should_fail: bool): raise ValueError(f"Failed on call {call_count}") return f"Success_{call_count}" - # Exceptions cause retry due to error handling + # First call fails - but due to error handling, it gets called twice + # (once in try block, once in except block) with pytest.raises(ValueError): await may_fail(True) - # Due to retry mechanism, function is called twice - assert call_count == 2 + assert call_count == 2 # Called twice due to retry in except block + + # Second call to same parameters should also fail and retry + with pytest.raises(ValueError): + await may_fail(True) - # Successful calls are cached + assert call_count == 4 # Two more calls due to retry + + # Successful call should be cached result1 = await may_fail(False) - assert result1 == "Success_3" + assert result1 == f"Success_{call_count}" # Count is 5 after previous failed calls result2 = await may_fail(False) - assert result2 == "Success_3" - assert call_count == 3 + assert result2 == result1 # Should return cached result + assert call_count == 5 # No new calls due to cache @pytest.mark.asyncio async def test_custom_ttl_override(self, cache_decorator): @@ -587,15 +467,43 @@ async def quick_expiry(): result3 = await quick_expiry() assert result3 == "Call_2" + @pytest.mark.asyncio + async def test_cache_key_collision_prevention(self, cache_decorator): + """Test that similar function names don't collide""" + call_count_1 = 0 + call_count_2 = 0 -class TestRealWorldScenarios: - """Test real-world usage patterns""" + @cache_decorator() + async def get_data(param): + nonlocal call_count_1 + call_count_1 += 1 + return f"Function1_{param}_{call_count_1}" - @pytest.fixture - def cache_decorator(self): - cache = InMemoryCache() - asyncio.run(cache.clear()) - return InMemoryCacheDecorator(cache) + @cache_decorator() + async def get_data_v2(param): + nonlocal call_count_2 + call_count_2 += 1 + return f"Function2_{param}_{call_count_2}" + + # Call both functions with same parameter + result1 = await get_data("test") + result2 = await get_data_v2("test") + + assert result1 == "Function1_test_1" + assert result2 == "Function2_test_1" + + # Verify they maintain separate caches + result3 = await get_data("test") + result4 = await get_data_v2("test") + + assert result3 == "Function1_test_1" + assert result4 == "Function2_test_1" + assert call_count_1 == 1 + assert call_count_2 == 1 + + +class TestRealWorldScenarios(BaseTestConfig): + """Test real-world usage patterns""" @pytest.mark.asyncio async def test_api_response_caching(self, cache_decorator): @@ -676,272 +584,82 @@ async def _update(self, key: str, value: str) -> None: assert db.query_count == 2 -import asyncio -import pytest -from base_cacheable_class import InMemoryCache, InMemoryCacheDecorator - - -@pytest.fixture -def cache(): - """Create a fresh cache instance""" - cache = InMemoryCache() - asyncio.run(cache.clear()) - return cache +class TestConcurrentAccess(BaseTestConfig): + """Test concurrent access and advanced scenarios""" + @pytest.mark.asyncio + async def test_concurrent_cache_access(self, cache_decorator): + """Test concurrent access to cached function""" + call_count = 0 -@pytest.fixture -def cache_decorator(cache): - """Create a cache decorator with 1 second TTL""" - return InMemoryCacheDecorator(cache, default_ttl=1) - - -@pytest.fixture -def invalidate_decorator(cache): - """Create an invalidate decorator""" - decorator = InMemoryCacheDecorator(cache, default_ttl=1) - return decorator.invalidate - + @cache_decorator() + async def expensive_operation(value): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.1) # Simulate expensive operation + return f"Result_{value}_{call_count}" -@pytest.mark.asyncio -async def test_concurrent_cache_access(cache_decorator): - """Test concurrent access to cached function""" - call_count = 0 + # Launch multiple concurrent calls with same parameter + tasks = [expensive_operation("test") for _ in range(10)] + results = await asyncio.gather(*tasks) - @cache_decorator() - async def expensive_operation(value): - nonlocal call_count - call_count += 1 - await asyncio.sleep(0.1) # Simulate expensive operation - return f"Result_{value}_{call_count}" + # Check that we have results and they start with expected prefix + assert all(r.startswith("Result_test_") for r in results) + # Due to potential race conditions in concurrent access, call_count might be > 1 + assert call_count >= 1 - # Launch multiple concurrent calls with same parameter - tasks = [expensive_operation("test") for _ in range(10)] - results = await asyncio.gather(*tasks) - # Check that we have results and they start with expected prefix - assert all(r.startswith("Result_test_") for r in results) - # Due to potential race conditions in concurrent access, call_count might be > 1 - assert call_count >= 1 +class TestAdvancedCacheUsage(BaseTestConfig): + @pytest.mark.asyncio + async def test_cache_with_class_methods(self, cache_decorator): + """Test caching with class methods""" + class DataService: + def __init__(self): + self.call_count = 0 -@pytest.mark.asyncio -async def test_cache_with_class_methods(cache_decorator): - """Test caching with class methods""" + @cache_decorator() + async def get_data(self, item_id): + self.call_count += 1 + return f"Data_{item_id}_{self.call_count}" - class DataService: - def __init__(self): - self.call_count = 0 - - @cache_decorator() - async def get_data(self, item_id): - self.call_count += 1 - return f"Data_{item_id}_{self.call_count}" - - service = DataService() - - # First call - result1 = await service.get_data("item1") - assert result1 == "Data_item1_1" - assert service.call_count == 1 - - # Cached call - result2 = await service.get_data("item1") - assert result2 == "Data_item1_1" - assert service.call_count == 1 - - # Different parameter - result3 = await service.get_data("item2") - assert result3 == "Data_item2_2" - assert service.call_count == 2 - - -@pytest.mark.asyncio -async def test_cache_with_complex_objects(cache_decorator): - """Test caching with complex parameter types""" - call_count = 0 - - @cache_decorator() - async def process_data(data_dict, data_list): - nonlocal call_count - call_count += 1 - return f"Processed_{len(data_dict)}_{len(data_list)}_{call_count}" - - # Test with dictionaries and lists - dict1 = {"a": 1, "b": 2} - list1 = [1, 2, 3] - - result1 = await process_data(dict1, list1) - assert result1 == "Processed_2_3_1" - - # Same content should hit cache - result2 = await process_data({"a": 1, "b": 2}, [1, 2, 3]) - assert result2 == "Processed_2_3_1" - assert call_count == 1 - - # Different content should miss cache - result3 = await process_data({"c": 3}, [4, 5]) - assert result3 == "Processed_1_2_2" - assert call_count == 2 - - -@pytest.mark.asyncio -async def test_selective_cache_invalidation(cache_decorator, invalidate_decorator): - """Test selective invalidation with parameter mapping""" - call_count = 0 - - @cache_decorator() - async def get_user_data(user_id, include_details=False): - nonlocal call_count - call_count += 1 - return f"User_{user_id}_details_{include_details}_{call_count}" - - @invalidate_decorator(target_func_name="get_user_data", param_mapping={"user_id": "user_id"}) - async def update_user(user_id, new_data): - return f"Updated_{user_id}" - - # Cache data for multiple users - result1 = await get_user_data("user1", True) - result2 = await get_user_data("user1", False) - result3 = await get_user_data("user2", True) - assert call_count == 3 - - # Verify cache hits - await get_user_data("user1", True) - await get_user_data("user1", False) - await get_user_data("user2", True) - assert call_count == 3 # No new calls - - # Invalidate user1 cache - await update_user("user1", {"name": "New Name"}) - - # User1 cache should be invalidated - result4 = await get_user_data("user1", True) - assert "4" in result4 - result5 = await get_user_data("user1", False) - assert "5" in result5 - - # User2 cache might also be invalidated due to broad regex pattern matching - result6 = await get_user_data("user2", True) - # This could be either the cached value or a new call - assert result6 in [result3, f"User_user2_details_True_{call_count}"] - - -@pytest.mark.asyncio -async def test_exception_handling_in_cached_function(cache_decorator): - """Test that exceptions are not cached""" - call_count = 0 - - @cache_decorator() - async def unstable_function(should_fail): - nonlocal call_count - call_count += 1 - if should_fail: - raise ValueError(f"Failed on call {call_count}") - return f"Success_{call_count}" - - # First call fails - but due to error handling, it gets called twice - # (once in try block, once in except block) - with pytest.raises(ValueError): - await unstable_function(True) - - assert call_count == 2 # Called twice due to retry in except block - - # Second call to same parameters should also fail and retry - with pytest.raises(ValueError): - await unstable_function(True) - - assert call_count == 4 # Two more calls due to retry - - # Successful call should be cached - result1 = await unstable_function(False) - assert result1 == "Success_5" # Count is 5 after previous failed calls - - result2 = await unstable_function(False) - assert result2 == "Success_5" - assert call_count == 5 # No new calls due to cache - - -@pytest.mark.asyncio -async def test_ttl_precision(cache_decorator): - """Test precise TTL behavior""" - call_count = 0 - - @cache_decorator(ttl=0.5) # 0.5 second TTL - async def short_lived_cache(): - nonlocal call_count - call_count += 1 - return f"Call_{call_count}" - - # Initial call - result1 = await short_lived_cache() - assert result1 == "Call_1" - - # Check cache at different time intervals - await asyncio.sleep(0.3) - result2 = await short_lived_cache() - assert result2 == "Call_1" # Should still be cached - - await asyncio.sleep(0.3) # Total 0.6 seconds - result3 = await short_lived_cache() - assert result3 == "Call_2" # Should be expired - - assert call_count == 2 - - -@pytest.mark.asyncio -async def test_cache_key_collision_prevention(cache_decorator): - """Test that similar function names don't collide""" - call_count_1 = 0 - call_count_2 = 0 - - @cache_decorator() - async def get_data(param): - nonlocal call_count_1 - call_count_1 += 1 - return f"Function1_{param}_{call_count_1}" - - @cache_decorator() - async def get_data_v2(param): - nonlocal call_count_2 - call_count_2 += 1 - return f"Function2_{param}_{call_count_2}" - - # Call both functions with same parameter - result1 = await get_data("test") - result2 = await get_data_v2("test") - - assert result1 == "Function1_test_1" - assert result2 == "Function2_test_1" - - # Verify they maintain separate caches - result3 = await get_data("test") - result4 = await get_data_v2("test") - - assert result3 == "Function1_test_1" - assert result4 == "Function2_test_1" - assert call_count_1 == 1 - assert call_count_2 == 1 + service = DataService() + # First call + result1 = await service.get_data("item1") + assert result1 == "Data_item1_1" + assert service.call_count == 1 -@pytest.mark.asyncio -async def test_cache_size_monitoring(cache): - """Test monitoring cache size and keys""" - decorator = InMemoryCacheDecorator(cache) - - @decorator() - async def cache_item(key): - return f"Value_{key}" - - # Add multiple items - for i in range(5): - await cache_item(f"key_{i}") + # Cached call + result2 = await service.get_data("item1") + assert result2 == "Data_item1_1" + assert service.call_count == 1 - # Check cache contents - all_keys = await cache.get_keys() - cache_keys = [k for k in all_keys if k.startswith("cache_item")] - assert len(cache_keys) == 5 + # Different parameter + result3 = await service.get_data("item2") + assert result3 == "Data_item2_2" + assert service.call_count == 2 - # Verify key format - for key in cache_keys: - assert "cache_item" in key - assert "key_" in key + @pytest.mark.asyncio + async def test_cache_size_monitoring(self, inmemory_decorator): + """Test monitoring cache size and keys - only for in-memory cache""" + # This test only works with InMemoryCache which has get_keys method + cache = inmemory_decorator.cache + + @inmemory_decorator() + async def cache_item(key): + return f"Value_{key}" + + # Add multiple items + for i in range(5): + await cache_item(f"key_{i}") + + # Check cache contents + all_keys = await cache.get_keys() + cache_keys = [k for k in all_keys if k.startswith("cache_item")] + assert len(cache_keys) == 5 + + # Verify key format + for key in cache_keys: + assert "cache_item" in key + assert "key_" in key diff --git a/tests/test_interface.py b/tests/test_interface.py index aaec898..5c55352 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -1,8 +1,12 @@ from abc import ABC +import asyncio import pytest +from unittest.mock import patch from base_cacheable_class import CacheDecoratorInterface, CacheInterface, CacheItem +from base_cacheable_class.cache.in_memory.cache import InMemoryCache +from base_cacheable_class.cache.in_memory.decorator import InMemoryCacheDecorator class TestCacheItem: diff --git a/uv.lock b/uv.lock index b0e33fc..f1ff124 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,21 @@ version = 1 revision = 1 requires-python = ">=3.10" +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -17,6 +32,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "redis" }, + { name = "valkey-glide" }, ] [package.optional-dependencies] @@ -47,6 +63,7 @@ requires-dist = [ { name = "redis", specifier = ">=6.2.0" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "valkey-glide", specifier = ">=2.0.1" }, ] provides-extras = ["redis", "dev"] @@ -189,6 +206,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, ] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -282,6 +308,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "protobuf" +version = "6.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603 }, + { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283 }, + { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604 }, + { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115 }, + { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070 }, + { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724 }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -384,6 +424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334 }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -431,3 +480,40 @@ sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09 wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, ] + +[[package]] +name = "valkey-glide" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "protobuf" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/35/fb0401c4bc7be748d937e95213786d21d9e56767b3ad816db5bad6f92c01/valkey_glide-2.0.1.tar.gz", hash = "sha256:4f9c62a88aedffd725cced7d28a9488b27e3f675d1a5294b4962624e97d346c4", size = 1026255 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/a3/bf5ff3841538d0bb337371e073dc2c0e93f748f7f8b10a44806f36ab5fa1/valkey_glide-2.0.1-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:b3307934b76557b18ac559f327592cc09fc895fc653ba46010dd6d70fb6239dc", size = 5074638 }, + { url = "https://files.pythonhosted.org/packages/0f/c4/20b66dced96bdca81aa294b39bc03018ed22628c52076752e8d1d3540a7d/valkey_glide-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b83d34e2e723e97c41682479b0dce5882069066e808316292b363855992b449", size = 4750261 }, + { url = "https://files.pythonhosted.org/packages/53/58/6440e66bde8963d86bc3c44d88f993059f2a9d7ebdb3256a695d035cff50/valkey_glide-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1baaf14d09d464ae645be5bdb5dc6b8a38b7eacf22f9dcb2907200c74fbdcdd3", size = 4767755 }, + { url = "https://files.pythonhosted.org/packages/3b/69/dd5c350ce4d2cadde0d83beb601f05e1e62622895f268135e252e8bfc307/valkey_glide-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4427e7b4d54c9de289a35032c19d5956f94376f5d4335206c5ac4524cbd1c64a", size = 5094507 }, + { url = "https://files.pythonhosted.org/packages/b5/dd/0dd6614e09123a5bd7273bf1159c958d1ea65e7decc2190b225d212e0cb9/valkey_glide-2.0.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6379582d6fbd817697fb119274e37d397db450103cd15d4bd71e555e6d88fb6b", size = 5072939 }, + { url = "https://files.pythonhosted.org/packages/c6/04/986188e407231a5f0bfaf31f31b68e3605ab66f4f4c656adfbb0345669d9/valkey_glide-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0f1c0fe003026d8ae172369e0eb2337cbff16f41d4c085332487d6ca2e5282e6", size = 4750491 }, + { url = "https://files.pythonhosted.org/packages/ac/fb/2f5cec71ae51c464502a892b6825426cd74a2c325827981726e557926c94/valkey_glide-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82c5f33598e50bcfec6fc924864931f3c6e30cd327a9c9562e1c7ac4e17e79fd", size = 4767597 }, + { url = "https://files.pythonhosted.org/packages/3a/31/851a1a734fe5da5d520106fcfd824e4da09c3be8a0a2123bb4b1980db1ea/valkey_glide-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79039a9dc23bb074680f171c12b36b3322357a0af85125534993e81a619dce21", size = 5094383 }, + { url = "https://files.pythonhosted.org/packages/fc/6d/1e7b432cbc02fe63e7496b984b7fc830fb7de388c877b237e0579a6300fc/valkey_glide-2.0.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:f55ec8968b0fde364a5b3399be34b89dcb9068994b5cd384e20db0773ad12723", size = 5075024 }, + { url = "https://files.pythonhosted.org/packages/ca/39/6e9f83970590d17d19f596e1b3a366d39077624888e3dd709309efc67690/valkey_glide-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21598f49313912ad27dc700d7b13a3b4bfed7ed9dffad207235cac7d218f4966", size = 4748418 }, + { url = "https://files.pythonhosted.org/packages/98/0e/91335c13dc8e7ceb95063234c16010b46e2dd874a2edef62dea155081647/valkey_glide-2.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f662285146529328e2b5a0a7047f699339b4e0d250eb1f252b15c9befa0dea05", size = 4767264 }, + { url = "https://files.pythonhosted.org/packages/5f/94/ee4d9d441f83fec1464d9f4e52f7940bdd2aeb917589e6abd57498880876/valkey_glide-2.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3939aaa8411fcbba00cb1ff7c7ba73f388bb1deca919972f65cba7eda1d5fa95", size = 5093543 }, + { url = "https://files.pythonhosted.org/packages/ed/7e/257a2e4b61ac29d5923f89bad5fe62be7b4a19e7bec78d191af3ce77aa39/valkey_glide-2.0.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c49b53011a05b5820d0c660ee5c76574183b413a54faa33cf5c01ce77164d9c8", size = 5073114 }, + { url = "https://files.pythonhosted.org/packages/20/14/a8a470679953980af7eac3ccb09638f2a76d4547116d48cbc69ae6f25080/valkey_glide-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3a23572b83877537916ba36ad0a6b2fd96581534f0bc67ef8f8498bf4dbb2b40", size = 4747717 }, + { url = "https://files.pythonhosted.org/packages/9f/49/f168dd0c778d9f6ff1be70d5d3bad7a86928fee563de7de5f4f575eddfd8/valkey_glide-2.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:943a2c4a5c38b8a6b53281201d5a4997ec454a6fdda72d27050eeb6aaef12afb", size = 4767128 }, + { url = "https://files.pythonhosted.org/packages/43/be/68961b14ea133d1792ce50f6df1753848b5377c3e06a8dbe4e39188a549a/valkey_glide-2.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d770ec581acc59d5597e7ccaac37aee7e3b5e716a77a7fa44e2967db3a715f53", size = 5093522 }, + { url = "https://files.pythonhosted.org/packages/51/2e/ad8595ffe84317385d52ceab8de1e9ef06a4da6b81ca8cd61b7961923de4/valkey_glide-2.0.1-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d4a9ccfe2b190c90622849dab62f9468acf76a282719a1245d272b649e7c12d1", size = 5074539 }, + { url = "https://files.pythonhosted.org/packages/db/e5/2122541c7a64706f3631655209bb0b13723fb99db3c190d9a792b4e7d494/valkey_glide-2.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9aa004077b82f64b23ea0d38d948b5116c23f7228dae3a5b4fcfa1799f8ff7de", size = 4753222 }, + { url = "https://files.pythonhosted.org/packages/6c/13/cd9a20988a820ff61b127d3f850887b28bb734daf2c26d512d8e4c2e8e9e/valkey_glide-2.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:631a7a0e2045f7e5e3706e1903beeddf381a6529e318c27230798f4382579e4f", size = 4771530 }, + { url = "https://files.pythonhosted.org/packages/c7/fc/047e89cc01b4cc71db1b6b8160d3b5d050097b408028022c002351238641/valkey_glide-2.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ed905fb62368c9bc6aef9df8d66269ef51f968dc527da4d7c956927382c1d", size = 5091242 }, + { url = "https://files.pythonhosted.org/packages/1c/9e/68790c1a263f3a0094d67d0109be34631f6f79c2fbce5ced7e33a65ad363/valkey_glide-2.0.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:53da3cc47c8d946ac76ecc4b468a469d3486778833a59162ea69aa7ce70cbb27", size = 5072793 }, + { url = "https://files.pythonhosted.org/packages/1f/ae/a935af65ae4069d76c69f28f6bfb4533da8b89f7fc418beb7a1482cdd9ee/valkey_glide-2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e526a7d718cdd299d6b03091c12dcc15cd02ff22fe420f253341a4891c50824d", size = 4753435 }, + { url = "https://files.pythonhosted.org/packages/3b/c2/c91d753a89dd87dce2fc8932cfbe174c7a1226c657b3cd64c063f21d4fe6/valkey_glide-2.0.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d3345ea2adf6f745733fa5157d8709bcf5ffbb2674391aeebd8f166a37cbc96", size = 4771401 }, + { url = "https://files.pythonhosted.org/packages/00/fe/ad83cfc2ac87bf6bad2b75fa64fca5a6dd54568c1de551d36d369e07f948/valkey_glide-2.0.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1c5fff0f12d2aa4277ddc335035b2c8e12bb11243c1a0f3c35071f4a8b11064", size = 5091360 }, +]