From 35caf695ac423643493352e7d5c987f93bef3d68 Mon Sep 17 00:00:00 2001 From: sakshichitnis27 Date: Sat, 11 Jul 2026 16:48:38 +0000 Subject: [PATCH] Check Redis server before hash field TTL --- aredis_om/model/model.py | 41 ++++++++++++--------- tests/test_hash_field_expiration.py | 7 ++-- tests/test_hash_field_expiration_support.py | 21 +++++++++++ 3 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 tests/test_hash_field_expiration_support.py diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index 07f6de14..5301ebf4 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -59,7 +59,7 @@ UndefinedType = type(...) from redis.asyncio.client import Pipeline from redis.commands.json.path import Path -from redis.exceptions import ResponseError +from redis.exceptions import RedisError, ResponseError from typing_extensions import Protocol, Unpack, get_args, get_origin from ulid import ULID @@ -81,28 +81,34 @@ # Minimum redis-py version for hash field expiration support _HASH_FIELD_EXPIRATION_MIN_VERSION = (5, 1, 0) +_HASH_FIELD_EXPIRATION_MIN_SERVER_VERSION = (7, 4) -def supports_hash_field_expiration() -> bool: +async def supports_hash_field_expiration(conn) -> bool: """ - Check if the installed redis-py version supports hash field expiration commands. + Check if the client and connected server support hash field expiration commands. Hash field expiration (HEXPIRE, HTTL, HPERSIST, etc.) was added in redis-py 5.1.0 and requires Redis server 7.4+. Returns: - True if redis-py >= 5.1.0 and has the hexpire method, False otherwise. + True if redis-py >= 5.1.0, the client has the hexpire method, and the + connected Redis server is at least version 7.4. False otherwise. """ try: import redis as redis_lib version_str = getattr(redis_lib, "__version__", "0.0.0") version_parts = tuple(int(x) for x in version_str.split(".")[:3]) - if version_parts >= _HASH_FIELD_EXPIRATION_MIN_VERSION: - # Also check that the method actually exists - return hasattr(redis_lib.asyncio.Redis, "hexpire") - return False - except (ValueError, AttributeError): + if version_parts < _HASH_FIELD_EXPIRATION_MIN_VERSION or not hasattr( + redis_lib.asyncio.Redis, "hexpire" + ): + return False + + server_version = (await conn.info("server"))["redis_version"] + server_version_parts = tuple(int(x) for x in server_version.split(".")[:2]) + return server_version_parts >= _HASH_FIELD_EXPIRATION_MIN_SERVER_VERSION + except (RedisError, ValueError, TypeError, KeyError, AttributeError): return False @@ -3162,7 +3168,8 @@ async def _do_save(conn): # Note: TTL preservation is skipped when using pipelines because # pipeline commands return futures, not actual values preserved_ttls: Dict[str, int] = {} - if supports_hash_field_expiration() and not is_pipeline: + supports_field_expiration = await supports_hash_field_expiration(self.db()) + if supports_field_expiration and not is_pipeline: fields_to_check = [f for f in document.keys() if f != "pk"] if fields_to_check: current_ttls = await conn.httl(key, *fields_to_check) @@ -3176,7 +3183,7 @@ async def _do_save(conn): # Apply field expirations after HSET (requires Redis 7.4+) # When using pipelines, we can still apply default expirations but # can't preserve manually-set TTLs - if supports_hash_field_expiration(): + if supports_field_expiration: for field_name in document.keys(): if field_name == "pk": continue @@ -3421,12 +3428,12 @@ async def expire_field( Raises: NotImplementedError: If redis-py version doesn't support HEXPIRE. """ - if not supports_hash_field_expiration(): + db = self.db() + if not await supports_hash_field_expiration(db): raise NotImplementedError( "Hash field expiration requires redis-py >= 5.1.0 and Redis 7.4+" ) - db = self.db() key = self.key() result = await db.hexpire(key, seconds, field_name, nx=nx, xx=xx, gt=gt, lt=lt) # hexpire returns a list of results, one per field @@ -3447,12 +3454,12 @@ async def field_ttl(self, field_name: str) -> int: Raises: NotImplementedError: If redis-py version doesn't support HTTL. """ - if not supports_hash_field_expiration(): + db = self.db() + if not await supports_hash_field_expiration(db): raise NotImplementedError( "Hash field expiration requires redis-py >= 5.1.0 and Redis 7.4+" ) - db = self.db() key = self.key() result = await db.httl(key, field_name) # httl returns a list of results, one per field @@ -3473,12 +3480,12 @@ async def persist_field(self, field_name: str) -> int: Raises: NotImplementedError: If redis-py version doesn't support HPERSIST. """ - if not supports_hash_field_expiration(): + db = self.db() + if not await supports_hash_field_expiration(db): raise NotImplementedError( "Hash field expiration requires redis-py >= 5.1.0 and Redis 7.4+" ) - db = self.db() key = self.key() result = await db.hpersist(key, field_name) # hpersist returns a list of results, one per field diff --git a/tests/test_hash_field_expiration.py b/tests/test_hash_field_expiration.py index 300ebc9e..0f81ffaf 100644 --- a/tests/test_hash_field_expiration.py +++ b/tests/test_hash_field_expiration.py @@ -239,13 +239,12 @@ async def test_hexpire_not_available_raises_or_warns(models, redis): @py_test_mark_asyncio -async def test_check_hash_field_expiration_support(): +async def test_check_hash_field_expiration_support(redis): """Test utility function to check if hash field expiration is supported.""" from aredis_om.model.model import supports_hash_field_expiration - # This should return True for redis-py >= 5.1.0 - # The actual value depends on installed redis-py version - result = supports_hash_field_expiration() + # The result depends on both the installed redis-py and Redis server versions. + result = await supports_hash_field_expiration(redis) assert isinstance(result, bool) diff --git a/tests/test_hash_field_expiration_support.py b/tests/test_hash_field_expiration_support.py new file mode 100644 index 00000000..f95bc85c --- /dev/null +++ b/tests/test_hash_field_expiration_support.py @@ -0,0 +1,21 @@ +from unittest import mock + +import pytest + +from aredis_om.model.model import supports_hash_field_expiration + +from .conftest import py_test_mark_asyncio + + +@py_test_mark_asyncio +@pytest.mark.parametrize( + ("server_version", "expected"), + [("7.2.14", False), ("7.4.0", True), ("8.0.0", True)], +) +async def test_supports_hash_field_expiration_checks_server_version( + server_version, expected +): + conn = mock.AsyncMock() + conn.info.return_value = {"redis_version": server_version} + + assert await supports_hash_field_expiration(conn) is expected