Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions aredis_om/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions tests/test_hash_field_expiration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
21 changes: 21 additions & 0 deletions tests/test_hash_field_expiration_support.py
Original file line number Diff line number Diff line change
@@ -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