diff options
| author | Jordan Cook <jordan.cook@pioneer.com> | 2021-08-21 18:39:52 -0500 |
|---|---|---|
| committer | Jordan Cook <jordan.cook@pioneer.com> | 2021-12-01 12:45:43 -0600 |
| commit | 7e0690254ed45fa66f8b38571a9efd8fddda73cf (patch) | |
| tree | 7aab1adaa68fce189f81974130bfa35a1d91b450 /requests_cache | |
| parent | 85dce906c7b8d7343098c0b76571a3293c860d8d (diff) | |
| download | requests-cache-7e0690254ed45fa66f8b38571a9efd8fddda73cf.tar.gz | |
Add a new RedisDict class that stores responses in separate hashes instead of in a single hash
Diffstat (limited to 'requests_cache')
| -rw-r--r-- | requests_cache/backends/__init__.py | 4 | ||||
| -rw-r--r-- | requests_cache/backends/redis.py | 104 | ||||
| -rwxr-xr-x | requests_cache/models/response.py | 8 | ||||
| -rw-r--r-- | requests_cache/serializers/preconf.py | 2 |
4 files changed, 102 insertions, 16 deletions
diff --git a/requests_cache/backends/__init__.py b/requests_cache/backends/__init__.py index 27841b7..385e6a8 100644 --- a/requests_cache/backends/__init__.py +++ b/requests_cache/backends/__init__.py @@ -42,9 +42,9 @@ try: except ImportError as e: MongoCache = MongoDict = MongoPickleDict = get_placeholder_class(e) # type: ignore try: - from .redis import RedisCache, RedisHashDict + from .redis import RedisCache, RedisDict, RedisHashDict except ImportError as e: - RedisCache = RedisHashDict = get_placeholder_class(e) # type: ignore + RedisCache = RedisDict = RedisHashDict = get_placeholder_class(e) # type: ignore try: # Note: Heroku doesn't support SQLite due to ephemeral storage from .sqlite import SQLiteCache, SQLiteDict, SQLitePickleDict diff --git a/requests_cache/backends/redis.py b/requests_cache/backends/redis.py index c460b12..f40a735 100644 --- a/requests_cache/backends/redis.py +++ b/requests_cache/backends/redis.py @@ -9,7 +9,7 @@ applications. Persistence ^^^^^^^^^^^ Redis operates on data in memory, and by default also persists data to snapshots on disk. This is -optimized for performance with a minor risk of data loss, which is usually the best configuration +optimized for performance, with a minor risk of data loss, and is usually the best configuration for a cache. If you need different behavior, the frequency and type of persistence can be customized or disabled entirely. See `Redis Persistence <https://redis.io/topics/persistence>`_ for details. @@ -32,12 +32,13 @@ API Reference :nosignatures: """ from logging import getLogger -from typing import Any, Iterable, Iterator, List, Tuple +from typing import Iterable from redis import Redis, StrictRedis from .._utils import get_valid_kwargs from ..cache_keys import decode, encode +from ..serializers import dict_serializer from . import BaseCache, BaseStorage logger = getLogger(__name__) @@ -54,17 +55,94 @@ class RedisCache(BaseCache): def __init__(self, namespace='http_cache', connection: Redis = None, **kwargs): super().__init__(**kwargs) - self.responses = RedisHashDict(namespace, 'responses', connection=connection, **kwargs) + self.responses = RedisDict(namespace, connection=connection, **kwargs) self.redirects = RedisHashDict( namespace, 'redirects', connection=self.responses.connection, **kwargs ) +class RedisDict(BaseStorage): + """A dictionary-like interface for Redis operations. + + **Notes:** + * Each item is stored as a separate hash + * All keys will be encoded as bytes + * Does not support custom serializers + * Supports TTL + """ + + def __init__(self, namespace: str, connection=None, serializer=None, **kwargs): + if serializer: + logger.warning('RedisDict does not support custom serializers') + super().__init__(serializer=dict_serializer, **kwargs) + + connection_kwargs = get_valid_kwargs(Redis, kwargs) + self.connection = connection or StrictRedis(**connection_kwargs) + self.namespace = namespace + + def _bkey(self, key: str) -> bytes: + """Get a full hash key as bytes""" + return encode(f'{self.namespace}:{key}') + + def _bkeys(self, keys: Iterable[str]): + return [self._bkey(key) for key in keys] + + def __contains__(self, key) -> bool: + return bool(self.connection.exists(self._bkey(key))) + + def __getitem__(self, key): + # result = self.connection.get(self._bkey(key)) + result = self.connection.hgetall(self._bkey(key)) + if not result: + raise KeyError + return self.serializer.loads(result) + + def __setitem__(self, key, item): + """Save an item to the cache, optionally with TTL""" + # if getattr(item, 'ttl', None): + # self.connection.setex(self._bkey(key), item.ttl, self.serializer.dumps(item)) + # else: + # self.connection.set(self._bkey(key), self.serializer.dumps(item)) + self.connection.hmset(self._bkey(key), self.serializer.dumps(item)) + if getattr(item, 'ttl', None): + self.connection.expire(self._bkey(key), item.ttl) + + def __delitem__(self, key): + if not self.connection.delete(self._bkey(key)): + raise KeyError + + def __iter__(self): + yield from self.keys() + + def __len__(self): + return len(list(self.keys())) + + def bulk_delete(self, keys: Iterable[str]): + """Delete multiple keys from the cache, without raising errors for missing keys""" + if keys: + self.connection.delete(*self._bkeys(keys)) + + def clear(self): + self.bulk_delete(self.keys()) + + def keys(self): + return [ + decode(key).replace(f'{self.namespace}:', '') + for key in self.connection.keys(f'{self.namespace}:*') + ] + + def items(self): + return [(k, self[k]) for k in self.keys()] + + def values(self): + return [self.serializer.loads(v) for v in self.connection.mget(*self._bkeys(self.keys()))] + + class RedisHashDict(BaseStorage): """A dictionary-like interface for operations on a single Redis hash **Notes:** - * All keys will be encoded and all values will be serialized + * All keys will be encoded as bytes, and all values will be serialized * Items will be stored in a hash named ``namespace:collection_name`` """ @@ -76,26 +154,26 @@ class RedisHashDict(BaseStorage): self.connection = connection or StrictRedis(**connection_kwargs) self._hash_key = f'{namespace}:{collection_name}' - def __contains__(self, key: str) -> bool: + def __contains__(self, key): return self.connection.hexists(self._hash_key, encode(key)) - def __getitem__(self, key: str): + def __getitem__(self, key): result = self.connection.hget(self._hash_key, encode(key)) if result is None: raise KeyError return self.serializer.loads(result) - def __setitem__(self, key: str, item): + def __setitem__(self, key, item): self.connection.hset(self._hash_key, encode(key), self.serializer.dumps(item)) - def __delitem__(self, key: str): + def __delitem__(self, key): if not self.connection.hdel(self._hash_key, encode(key)): raise KeyError - def __iter__(self) -> Iterator[str]: + def __iter__(self): yield from self.keys() - def __len__(self) -> int: + def __len__(self): return self.connection.hlen(self._hash_key) def bulk_delete(self, keys: Iterable[str]): @@ -106,16 +184,16 @@ class RedisHashDict(BaseStorage): def clear(self): self.connection.delete(self._hash_key) - def keys(self) -> List[str]: + def keys(self): return [decode(key) for key in self.connection.hkeys(self._hash_key)] - def items(self) -> List[Tuple[str, Any]]: + def items(self): """Get all ``(key, value)`` pairs in the hash""" return [ (decode(k), self.serializer.loads(v)) for k, v in self.connection.hgetall(self._hash_key).items() ] - def values(self) -> List: + def values(self): """Get all values in the hash""" return [self.serializer.loads(v) for v in self.connection.hvals(self._hash_key)] diff --git a/requests_cache/models/response.py b/requests_cache/models/response.py index ec65034..561d6c8 100755 --- a/requests_cache/models/response.py +++ b/requests_cache/models/response.py @@ -96,6 +96,14 @@ class CachedResponse(Response): return self.expires is not None and datetime.utcnow() >= self.expires @property + def ttl(self) -> Optional[int]: + """Get time to expiration in seconds""" + if self.expires is None or self.is_expired: + return None + delta = self.expires - datetime.utcnow() + return int(delta.total_seconds()) + + @property def next(self) -> Optional[PreparedRequest]: """Returns a PreparedRequest for the next request in a redirect chain, if there is one.""" return self._next.prepare() if self._next else None diff --git a/requests_cache/serializers/preconf.py b/requests_cache/serializers/preconf.py index 4c695a5..dce7e60 100644 --- a/requests_cache/serializers/preconf.py +++ b/requests_cache/serializers/preconf.py @@ -24,7 +24,7 @@ from .cattrs import CattrStage from .pipeline import SerializerPipeline, Stage base_stage = CattrStage() #: Base stage for all serializer pipelines (or standalone dict serializer) -dict_serializer = base_stage #: Partial serializer that converts responses to dicts +dict_serializer = base_stage #: Partial serializer that unstructures responses into dicts bson_preconf_stage = CattrStage(bson_preconf.make_converter) #: Pre-serialization steps for BSON json_preconf_stage = CattrStage(json_preconf.make_converter) #: Pre-serialization steps for JSON msgpack_preconf_stage = CattrStage(msgpack.make_converter) #: Pre-serialization steps for msgpack |
