From 45ed2407f56efba1e46fc156b2b7a8cd4b2675c7 Mon Sep 17 00:00:00 2001 From: Gabriel Erzse Date: Thu, 6 Jun 2024 20:14:55 +0300 Subject: [PATCH] Hash field expiration commands (#3218) Support hash field expiration commands that become available with Redis 7.4. Adapt some tests to match recent server-side changes. Update tests related to memory stats. Make CLIENT KILL test not run with cluster. Disable tests related to Graph module. The Graph module is no longer part of Redis Stack, so for the moment disable all related tests. --------- Co-authored-by: Gabriel Erzse --- redis/commands/core.py | 360 +++++++++++++++++++++++++++ tests/test_asyncio/test_commands.py | 4 +- tests/test_asyncio/test_graph.py | 1 + tests/test_asyncio/test_hash.py | 300 ++++++++++++++++++++++ tests/test_commands.py | 7 +- tests/test_graph.py | 20 ++ tests/test_hash.py | 369 ++++++++++++++++++++++++++++ 7 files changed, 1056 insertions(+), 5 deletions(-) create mode 100644 tests/test_asyncio/test_hash.py create mode 100644 tests/test_hash.py diff --git a/redis/commands/core.py b/redis/commands/core.py index aacc12cb66..34c71ff488 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -5087,6 +5087,366 @@ def hstrlen(self, name: str, key: str) -> Union[Awaitable[int], int]: """ return self.execute_command("HSTRLEN", name, key) + def hexpire( + self, + name: KeyT, + seconds: ExpiryT, + *fields: str, + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets or updates the expiration time for fields within a hash key, using relative + time in seconds. + + If a field already has an expiration time, the behavior of the update can be + controlled using the `nx`, `xx`, `gt`, and `lt` parameters. + + The return value provides detailed information about the outcome for each field. + + For more information, see https://redis.io/commands/hexpire + + Args: + name: The name of the hash key. + seconds: Expiration time in seconds, relative. Can be an integer, or a + Python `timedelta` object. + fields: List of fields within the hash to apply the expiration time to. + nx: Set expiry only when the field has no expiry. + xx: Set expiry only when the field has an existing expiry. + gt: Set expiry only when the new expiry is greater than the current one. + lt: Set expiry only when the new expiry is less than the current one. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `0` if the specified NX | XX | GT | LT condition was not met. + - `1` if the expiration time was set or updated. + - `2` if the field was deleted because the specified expiration time is + in the past. + """ + conditions = [nx, xx, gt, lt] + if sum(conditions) > 1: + raise ValueError("Only one of 'nx', 'xx', 'gt', 'lt' can be specified.") + + if isinstance(seconds, datetime.timedelta): + seconds = int(seconds.total_seconds()) + + options = [] + if nx: + options.append("NX") + if xx: + options.append("XX") + if gt: + options.append("GT") + if lt: + options.append("LT") + + return self.execute_command( + "HEXPIRE", name, seconds, *options, "FIELDS", len(fields), *fields + ) + + def hpexpire( + self, + name: KeyT, + milliseconds: ExpiryT, + *fields: str, + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets or updates the expiration time for fields within a hash key, using relative + time in milliseconds. + + If a field already has an expiration time, the behavior of the update can be + controlled using the `nx`, `xx`, `gt`, and `lt` parameters. + + The return value provides detailed information about the outcome for each field. + + For more information, see https://redis.io/commands/hpexpire + + Args: + name: The name of the hash key. + milliseconds: Expiration time in milliseconds, relative. Can be an integer, + or a Python `timedelta` object. + fields: List of fields within the hash to apply the expiration time to. + nx: Set expiry only when the field has no expiry. + xx: Set expiry only when the field has an existing expiry. + gt: Set expiry only when the new expiry is greater than the current one. + lt: Set expiry only when the new expiry is less than the current one. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `0` if the specified NX | XX | GT | LT condition was not met. + - `1` if the expiration time was set or updated. + - `2` if the field was deleted because the specified expiration time is + in the past. + """ + conditions = [nx, xx, gt, lt] + if sum(conditions) > 1: + raise ValueError("Only one of 'nx', 'xx', 'gt', 'lt' can be specified.") + + if isinstance(milliseconds, datetime.timedelta): + milliseconds = int(milliseconds.total_seconds() * 1000) + + options = [] + if nx: + options.append("NX") + if xx: + options.append("XX") + if gt: + options.append("GT") + if lt: + options.append("LT") + + return self.execute_command( + "HPEXPIRE", name, milliseconds, *options, "FIELDS", len(fields), *fields + ) + + def hexpireat( + self, + name: KeyT, + unix_time_seconds: AbsExpiryT, + *fields: str, + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets or updates the expiration time for fields within a hash key, using an + absolute Unix timestamp in seconds. + + If a field already has an expiration time, the behavior of the update can be + controlled using the `nx`, `xx`, `gt`, and `lt` parameters. + + The return value provides detailed information about the outcome for each field. + + For more information, see https://redis.io/commands/hexpireat + + Args: + name: The name of the hash key. + unix_time_seconds: Expiration time as Unix timestamp in seconds. Can be an + integer or a Python `datetime` object. + fields: List of fields within the hash to apply the expiration time to. + nx: Set expiry only when the field has no expiry. + xx: Set expiry only when the field has an existing expiration time. + gt: Set expiry only when the new expiry is greater than the current one. + lt: Set expiry only when the new expiry is less than the current one. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `0` if the specified NX | XX | GT | LT condition was not met. + - `1` if the expiration time was set or updated. + - `2` if the field was deleted because the specified expiration time is + in the past. + """ + conditions = [nx, xx, gt, lt] + if sum(conditions) > 1: + raise ValueError("Only one of 'nx', 'xx', 'gt', 'lt' can be specified.") + + if isinstance(unix_time_seconds, datetime.datetime): + unix_time_seconds = int(unix_time_seconds.timestamp()) + + options = [] + if nx: + options.append("NX") + if xx: + options.append("XX") + if gt: + options.append("GT") + if lt: + options.append("LT") + + return self.execute_command( + "HEXPIREAT", + name, + unix_time_seconds, + *options, + "FIELDS", + len(fields), + *fields, + ) + + def hpexpireat( + self, + name: KeyT, + unix_time_milliseconds: AbsExpiryT, + *fields: str, + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets or updates the expiration time for fields within a hash key, using an + absolute Unix timestamp in milliseconds. + + If a field already has an expiration time, the behavior of the update can be + controlled using the `nx`, `xx`, `gt`, and `lt` parameters. + + The return value provides detailed information about the outcome for each field. + + For more information, see https://redis.io/commands/hpexpireat + + Args: + name: The name of the hash key. + unix_time_milliseconds: Expiration time as Unix timestamp in milliseconds. + Can be an integer or a Python `datetime` object. + fields: List of fields within the hash to apply the expiry. + nx: Set expiry only when the field has no expiry. + xx: Set expiry only when the field has an existing expiry. + gt: Set expiry only when the new expiry is greater than the current one. + lt: Set expiry only when the new expiry is less than the current one. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `0` if the specified NX | XX | GT | LT condition was not met. + - `1` if the expiration time was set or updated. + - `2` if the field was deleted because the specified expiration time is + in the past. + """ + conditions = [nx, xx, gt, lt] + if sum(conditions) > 1: + raise ValueError("Only one of 'nx', 'xx', 'gt', 'lt' can be specified.") + + if isinstance(unix_time_milliseconds, datetime.datetime): + unix_time_milliseconds = int(unix_time_milliseconds.timestamp() * 1000) + + options = [] + if nx: + options.append("NX") + if xx: + options.append("XX") + if gt: + options.append("GT") + if lt: + options.append("LT") + + return self.execute_command( + "HPEXPIREAT", + name, + unix_time_milliseconds, + *options, + "FIELDS", + len(fields), + *fields, + ) + + def hpersist(self, name: KeyT, *fields: str) -> ResponseT: + """ + Removes the expiration time for each specified field in a hash. + + For more information, see https://redis.io/commands/hpersist + + Args: + name: The name of the hash key. + fields: A list of fields within the hash from which to remove the + expiration time. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `-1` if the field exists but has no associated expiration time. + - `1` if the expiration time was successfully removed from the field. + """ + return self.execute_command("HPERSIST", name, "FIELDS", len(fields), *fields) + + def hexpiretime(self, key: KeyT, *fields: str) -> ResponseT: + """ + Returns the expiration times of hash fields as Unix timestamps in seconds. + + For more information, see https://redis.io/commands/hexpiretime + + Args: + key: The hash key. + fields: A list of fields within the hash for which to get the expiration + time. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `-1` if the field exists but has no associated expire time. + - A positive integer representing the expiration Unix timestamp in + seconds, if the field has an associated expiration time. + """ + return self.execute_command("HEXPIRETIME", key, "FIELDS", len(fields), *fields) + + def hpexpiretime(self, key: KeyT, *fields: str) -> ResponseT: + """ + Returns the expiration times of hash fields as Unix timestamps in milliseconds. + + For more information, see https://redis.io/commands/hpexpiretime + + Args: + key: The hash key. + fields: A list of fields within the hash for which to get the expiration + time. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `-1` if the field exists but has no associated expire time. + - A positive integer representing the expiration Unix timestamp in + milliseconds, if the field has an associated expiration time. + """ + return self.execute_command("HPEXPIRETIME", key, "FIELDS", len(fields), *fields) + + def httl(self, key: KeyT, *fields: str) -> ResponseT: + """ + Returns the TTL (Time To Live) in seconds for each specified field within a hash + key. + + For more information, see https://redis.io/commands/httl + + Args: + key: The hash key. + fields: A list of fields within the hash for which to get the TTL. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `-1` if the field exists but has no associated expire time. + - A positive integer representing the TTL in seconds if the field has + an associated expiration time. + """ + return self.execute_command("HTTL", key, "FIELDS", len(fields), *fields) + + def hpttl(self, key: KeyT, *fields: str) -> ResponseT: + """ + Returns the TTL (Time To Live) in milliseconds for each specified field within a + hash key. + + For more information, see https://redis.io/commands/hpttl + + Args: + key: The hash key. + fields: A list of fields within the hash for which to get the TTL. + + Returns: + If the key does not exist, returns an empty list. If the key exists, returns + a list which contains for each field in the request: + - `-2` if the field does not exist. + - `-1` if the field exists but has no associated expire time. + - A positive integer representing the TTL in milliseconds if the field + has an associated expiration time. + """ + return self.execute_command("HPTTL", key, "FIELDS", len(fields), *fields) + AsyncHashCommands = HashCommands diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 52c2a29a5f..cc16305641 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1351,7 +1351,7 @@ async def test_hscan(self, r: redis.Redis): _, dic = await r.hscan("a_notset", match="a") assert dic == {} - @skip_if_server_version_lt("7.4.0") + @skip_if_server_version_lt("7.3.240") async def test_hscan_novalues(self, r: redis.Redis): await r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) cursor, keys = await r.hscan("a", no_values=True) @@ -1372,7 +1372,7 @@ async def test_hscan_iter(self, r: redis.Redis): dic = {k: v async for k, v in r.hscan_iter("a_notset", match="a")} assert dic == {} - @skip_if_server_version_lt("7.4.0") + @skip_if_server_version_lt("7.3.240") async def test_hscan_iter_novalues(self, r: redis.Redis): await r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) keys = list([k async for k in r.hscan_iter("a", no_values=True)]) diff --git a/tests/test_asyncio/test_graph.py b/tests/test_asyncio/test_graph.py index 0250a730c9..9cd9149dcc 100644 --- a/tests/test_asyncio/test_graph.py +++ b/tests/test_asyncio/test_graph.py @@ -373,6 +373,7 @@ async def test_list_keys(decoded_r: redis.Redis): assert result == [] +@pytest.mark.redismod async def test_multi_label(decoded_r: redis.Redis): redis_graph = decoded_r.graph("g") diff --git a/tests/test_asyncio/test_hash.py b/tests/test_asyncio/test_hash.py new file mode 100644 index 0000000000..d4f180539e --- /dev/null +++ b/tests/test_asyncio/test_hash.py @@ -0,0 +1,300 @@ +import asyncio +from datetime import datetime, timedelta + +from tests.conftest import skip_if_server_version_lt + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpire_basic(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert await r.hexpire("test:hash", 1, "field1") == [1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpire_with_timedelta(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert await r.hexpire("test:hash", timedelta(seconds=1), "field1") == [1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpire_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1"}) + assert await r.hexpire("test:hash", 2, "field1", xx=True) == [0] + assert await r.hexpire("test:hash", 2, "field1", nx=True) == [1] + assert await r.hexpire("test:hash", 1, "field1", xx=True) == [1] + assert await r.hexpire("test:hash", 2, "field1", nx=True) == [0] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + await r.hset("test:hash", "field1", "value1") + await r.hexpire("test:hash", 2, "field1") + assert await r.hexpire("test:hash", 1, "field1", gt=True) == [0] + assert await r.hexpire("test:hash", 1, "field1", lt=True) == [1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpire_nonexistent_key_or_field(r): + await r.delete("test:hash") + assert await r.hexpire("test:hash", 1, "field1") == [] + await r.hset("test:hash", "field1", "value1") + assert await r.hexpire("test:hash", 1, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpire_multiple_fields(r): + await r.delete("test:hash") + await r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + assert await r.hexpire("test:hash", 1, "field1", "field2") == [1, 1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is False + assert await r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpire_basic(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert await r.hpexpire("test:hash", 500, "field1") == [1] + await asyncio.sleep(0.6) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpire_with_timedelta(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert await r.hpexpire("test:hash", timedelta(milliseconds=500), "field1") == [1] + await asyncio.sleep(0.6) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpire_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1"}) + assert await r.hpexpire("test:hash", 1500, "field1", xx=True) == [0] + assert await r.hpexpire("test:hash", 1500, "field1", nx=True) == [1] + assert await r.hpexpire("test:hash", 500, "field1", xx=True) == [1] + assert await r.hpexpire("test:hash", 1500, "field1", nx=True) == [0] + await asyncio.sleep(0.6) + assert await r.hexists("test:hash", "field1") is False + await r.hset("test:hash", "field1", "value1") + await r.hpexpire("test:hash", 1000, "field1") + assert await r.hpexpire("test:hash", 500, "field1", gt=True) == [0] + assert await r.hpexpire("test:hash", 500, "field1", lt=True) == [1] + await asyncio.sleep(0.6) + assert await r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpire_nonexistent_key_or_field(r): + await r.delete("test:hash") + assert await r.hpexpire("test:hash", 500, "field1") == [] + await r.hset("test:hash", "field1", "value1") + assert await r.hpexpire("test:hash", 500, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpire_multiple_fields(r): + await r.delete("test:hash") + await r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + assert await r.hpexpire("test:hash", 500, "field1", "field2") == [1, 1] + await asyncio.sleep(0.6) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is False + assert await r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpireat_basic(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert await r.hexpireat("test:hash", exp_time, "field1") == [1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpireat_with_datetime(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = datetime.now() + timedelta(seconds=1) + assert await r.hexpireat("test:hash", exp_time, "field1") == [1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpireat_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1"}) + future_exp_time = int((datetime.now() + timedelta(seconds=2)).timestamp()) + past_exp_time = int((datetime.now() - timedelta(seconds=1)).timestamp()) + assert await r.hexpireat("test:hash", future_exp_time, "field1", xx=True) == [0] + assert await r.hexpireat("test:hash", future_exp_time, "field1", nx=True) == [1] + assert await r.hexpireat("test:hash", past_exp_time, "field1", gt=True) == [0] + assert await r.hexpireat("test:hash", past_exp_time, "field1", lt=True) == [2] + assert await r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpireat_nonexistent_key_or_field(r): + await r.delete("test:hash") + future_exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert await r.hexpireat("test:hash", future_exp_time, "field1") == [] + await r.hset("test:hash", "field1", "value1") + assert await r.hexpireat("test:hash", future_exp_time, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpireat_multiple_fields(r): + await r.delete("test:hash") + await r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert await r.hexpireat("test:hash", exp_time, "field1", "field2") == [1, 1] + await asyncio.sleep(1.1) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is False + assert await r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpireat_basic(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000) + assert await r.hpexpireat("test:hash", exp_time, "field1") == [1] + await asyncio.sleep(0.5) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpireat_with_datetime(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = datetime.now() + timedelta(milliseconds=400) + assert await r.hpexpireat("test:hash", exp_time, "field1") == [1] + await asyncio.sleep(0.5) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpireat_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1"}) + future_exp_time = int( + (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000 + ) + past_exp_time = int( + (datetime.now() - timedelta(milliseconds=500)).timestamp() * 1000 + ) + assert await r.hpexpireat("test:hash", future_exp_time, "field1", xx=True) == [0] + assert await r.hpexpireat("test:hash", future_exp_time, "field1", nx=True) == [1] + assert await r.hpexpireat("test:hash", past_exp_time, "field1", gt=True) == [0] + assert await r.hpexpireat("test:hash", past_exp_time, "field1", lt=True) == [2] + assert await r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpireat_nonexistent_key_or_field(r): + await r.delete("test:hash") + future_exp_time = int( + (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000 + ) + assert await r.hpexpireat("test:hash", future_exp_time, "field1") == [] + await r.hset("test:hash", "field1", "value1") + assert await r.hpexpireat("test:hash", future_exp_time, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpireat_multiple_fields(r): + await r.delete("test:hash") + await r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000) + assert await r.hpexpireat("test:hash", exp_time, "field1", "field2") == [1, 1] + await asyncio.sleep(0.5) + assert await r.hexists("test:hash", "field1") is False + assert await r.hexists("test:hash", "field2") is False + assert await r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +async def test_hpersist_multiple_fields_mixed_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + await r.hexpire("test:hash", 5000, "field1") + assert await r.hpersist("test:hash", "field1", "field2", "field3") == [1, -1, -2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hexpiretime_multiple_fields_mixed_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + await r.hexpireat("test:hash", future_time, "field1") + result = await r.hexpiretime("test:hash", "field1", "field2", "field3") + assert future_time - 10 < result[0] <= future_time + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +async def test_hpexpiretime_multiple_fields_mixed_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + await r.hexpireat("test:hash", future_time, "field1") + result = await r.hpexpiretime("test:hash", "field1", "field2", "field3") + assert future_time * 1000 - 10000 < result[0] <= future_time * 1000 + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +async def test_ttl_multiple_fields_mixed_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + await r.hexpireat("test:hash", future_time, "field1") + result = await r.httl("test:hash", "field1", "field2", "field3") + assert 30 * 60 - 10 < result[0] <= 30 * 60 + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +async def test_pttl_multiple_fields_mixed_conditions(r): + await r.delete("test:hash") + await r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + await r.hexpireat("test:hash", future_time, "field1") + result = await r.hpttl("test:hash", "field1", "field2", "field3") + assert 30 * 60000 - 10000 < result[0] <= 30 * 60000 + assert result[1:] == [-1, -2] diff --git a/tests/test_commands.py b/tests/test_commands.py index 8fbec5fb7e..d0c235daf9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -707,8 +707,9 @@ def test_client_kill_filter_by_user(self, r, request): assert c["user"] != killuser r.acl_deluser(killuser) - @skip_if_server_version_lt("7.4.0") + @skip_if_server_version_lt("7.3.240") @skip_if_redis_enterprise() + @pytest.mark.onlynoncluster def test_client_kill_filter_by_maxage(self, r, request): _get_client(redis.Redis, request, flushdb=False) time.sleep(4) @@ -2174,7 +2175,7 @@ def test_hscan(self, r): _, dic = r.hscan("a_notset") assert dic == {} - @skip_if_server_version_lt("7.4.0") + @skip_if_server_version_lt("7.3.240") def test_hscan_novalues(self, r): r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) cursor, keys = r.hscan("a", no_values=True) @@ -2195,7 +2196,7 @@ def test_hscan_iter(self, r): dic = dict(r.hscan_iter("a_notset")) assert dic == {} - @skip_if_server_version_lt("7.4.0") + @skip_if_server_version_lt("7.3.240") def test_hscan_iter_novalues(self, r): r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) keys = list(r.hscan_iter("a", no_values=True)) diff --git a/tests/test_graph.py b/tests/test_graph.py index 786e92af39..7f46a538ff 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -31,6 +31,7 @@ def client(request, stack_url): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_bulk(client): with pytest.raises(NotImplementedError): client.graph().bulk() @@ -38,6 +39,7 @@ def test_bulk(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_graph_creation(client): graph = client.graph() @@ -83,6 +85,7 @@ def test_graph_creation(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_array_functions(client): query = """CREATE (p:person{name:'a',age:32, array:[0,1,2]})""" client.graph().query(query) @@ -104,6 +107,7 @@ def test_array_functions(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_path(client): node0 = Node(node_id=0, label="L1") node1 = Node(node_id=1, label="L1") @@ -124,6 +128,7 @@ def test_path(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_param(client): params = [1, 2.3, "str", True, False, None, [0, 1, 2], r"\" RETURN 1337 //"] query = "RETURN $param" @@ -134,6 +139,7 @@ def test_param(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_map(client): query = "RETURN {a:1, b:'str', c:NULL, d:[1,2,3], e:True, f:{x:1, y:2}}" @@ -151,6 +157,7 @@ def test_map(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_point(client): query = "RETURN point({latitude: 32.070794860, longitude: 34.820751118})" expected_lat = 32.070794860 @@ -168,6 +175,7 @@ def test_point(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_index_response(client): result_set = client.graph().query("CREATE INDEX ON :person(age)") assert 1 == result_set.indices_created @@ -183,6 +191,7 @@ def test_index_response(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_stringify_query_result(client): graph = client.graph() @@ -237,6 +246,7 @@ def test_stringify_query_result(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_optional_match(client): # Build a graph of form (a)-[R]->(b) node0 = Node(node_id=0, label="L1", properties={"value": "a"}) @@ -262,6 +272,7 @@ def test_optional_match(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_cached_execution(client): client.graph().query("CREATE ()") @@ -280,6 +291,7 @@ def test_cached_execution(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_slowlog(client): create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), @@ -309,6 +321,7 @@ def test_query_timeout(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_read_only_query(client): with pytest.raises(Exception): # Issue a write query, specifying read-only true, @@ -318,6 +331,7 @@ def test_read_only_query(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_profile(client): q = """UNWIND range(1, 3) AS x CREATE (p:Person {v:x})""" profile = client.graph().profile(q).result_set @@ -367,6 +381,7 @@ def test_config(client): @pytest.mark.onlynoncluster @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_list_keys(client): result = client.graph().list_keys() assert result == [] @@ -390,6 +405,7 @@ def test_list_keys(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_multi_label(client): redis_graph = client.graph("g") @@ -416,6 +432,7 @@ def test_multi_label(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_cache_sync(client): pass return @@ -489,6 +506,7 @@ def test_cache_sync(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_execution_plan(client): redis_graph = client.graph("execution_plan") create_query = """CREATE @@ -508,6 +526,7 @@ def test_execution_plan(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_explain(client): redis_graph = client.graph("execution_plan") # graph creation / population @@ -597,6 +616,7 @@ def test_explain(client): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_resultset_statistics(client): with patch.object(target=QueryResult, attribute="_get_stat") as mock_get_stats: result = client.graph().query("RETURN 1") diff --git a/tests/test_hash.py b/tests/test_hash.py new file mode 100644 index 0000000000..7145b10a07 --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,369 @@ +import time +from datetime import datetime, timedelta + +import pytest +from tests.conftest import skip_if_server_version_lt + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_basic(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert r.hexpire("test:hash", 1, "field1") == [1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_with_timedelta(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert r.hexpire("test:hash", timedelta(seconds=1), "field1") == [1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + assert r.hexpire("test:hash", 2, "field1", xx=True) == [0] + assert r.hexpire("test:hash", 2, "field1", nx=True) == [1] + assert r.hexpire("test:hash", 1, "field1", xx=True) == [1] + assert r.hexpire("test:hash", 2, "field1", nx=True) == [0] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + r.hset("test:hash", "field1", "value1") + r.hexpire("test:hash", 2, "field1") + assert r.hexpire("test:hash", 1, "field1", gt=True) == [0] + assert r.hexpire("test:hash", 1, "field1", lt=True) == [1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_nonexistent_key_or_field(r): + r.delete("test:hash") + assert r.hexpire("test:hash", 1, "field1") == [] + r.hset("test:hash", "field1", "value1") + assert r.hexpire("test:hash", 1, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_multiple_fields(r): + r.delete("test:hash") + r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + assert r.hexpire("test:hash", 1, "field1", "field2") == [1, 1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is False + assert r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpire_multiple_condition_flags_error(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + with pytest.raises(ValueError) as e: + r.hexpire("test:hash", 1, "field1", nx=True, xx=True) + assert "Only one of" in str(e) + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_basic(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert r.hpexpire("test:hash", 500, "field1") == [1] + time.sleep(0.6) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_with_timedelta(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + assert r.hpexpire("test:hash", timedelta(milliseconds=500), "field1") == [1] + time.sleep(0.6) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + assert r.hpexpire("test:hash", 1500, "field1", xx=True) == [0] + assert r.hpexpire("test:hash", 1500, "field1", nx=True) == [1] + assert r.hpexpire("test:hash", 500, "field1", xx=True) == [1] + assert r.hpexpire("test:hash", 1500, "field1", nx=True) == [0] + time.sleep(0.6) + assert r.hexists("test:hash", "field1") is False + r.hset("test:hash", "field1", "value1") + r.hpexpire("test:hash", 1000, "field1") + assert r.hpexpire("test:hash", 500, "field1", gt=True) == [0] + assert r.hpexpire("test:hash", 500, "field1", lt=True) == [1] + time.sleep(0.6) + assert r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_nonexistent_key_or_field(r): + r.delete("test:hash") + assert r.hpexpire("test:hash", 500, "field1") == [] + r.hset("test:hash", "field1", "value1") + assert r.hpexpire("test:hash", 500, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_multiple_fields(r): + r.delete("test:hash") + r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + assert r.hpexpire("test:hash", 500, "field1", "field2") == [1, 1] + time.sleep(0.6) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is False + assert r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpire_multiple_condition_flags_error(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + with pytest.raises(ValueError) as e: + r.hpexpire("test:hash", 500, "field1", nx=True, xx=True) + assert "Only one of" in str(e) + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_basic(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert r.hexpireat("test:hash", exp_time, "field1") == [1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_with_datetime(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = datetime.now() + timedelta(seconds=1) + assert r.hexpireat("test:hash", exp_time, "field1") == [1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + future_exp_time = int((datetime.now() + timedelta(seconds=2)).timestamp()) + past_exp_time = int((datetime.now() - timedelta(seconds=1)).timestamp()) + assert r.hexpireat("test:hash", future_exp_time, "field1", xx=True) == [0] + assert r.hexpireat("test:hash", future_exp_time, "field1", nx=True) == [1] + assert r.hexpireat("test:hash", past_exp_time, "field1", gt=True) == [0] + assert r.hexpireat("test:hash", past_exp_time, "field1", lt=True) == [2] + assert r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_nonexistent_key_or_field(r): + r.delete("test:hash") + future_exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert r.hexpireat("test:hash", future_exp_time, "field1") == [] + r.hset("test:hash", "field1", "value1") + assert r.hexpireat("test:hash", future_exp_time, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_multiple_fields(r): + r.delete("test:hash") + r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + assert r.hexpireat("test:hash", exp_time, "field1", "field2") == [1, 1] + time.sleep(1.1) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is False + assert r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hexpireat_multiple_condition_flags_error(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp()) + with pytest.raises(ValueError) as e: + r.hexpireat("test:hash", exp_time, "field1", nx=True, xx=True) + assert "Only one of" in str(e) + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_basic(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000) + assert r.hpexpireat("test:hash", exp_time, "field1") == [1] + time.sleep(0.5) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_with_datetime(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + exp_time = datetime.now() + timedelta(milliseconds=400) + assert r.hpexpireat("test:hash", exp_time, "field1") == [1] + time.sleep(0.5) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + future_exp_time = int( + (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000 + ) + past_exp_time = int( + (datetime.now() - timedelta(milliseconds=500)).timestamp() * 1000 + ) + assert r.hpexpireat("test:hash", future_exp_time, "field1", xx=True) == [0] + assert r.hpexpireat("test:hash", future_exp_time, "field1", nx=True) == [1] + assert r.hpexpireat("test:hash", past_exp_time, "field1", gt=True) == [0] + assert r.hpexpireat("test:hash", past_exp_time, "field1", lt=True) == [2] + assert r.hexists("test:hash", "field1") is False + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_nonexistent_key_or_field(r): + r.delete("test:hash") + future_exp_time = int( + (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000 + ) + assert r.hpexpireat("test:hash", future_exp_time, "field1") == [] + r.hset("test:hash", "field1", "value1") + assert r.hpexpireat("test:hash", future_exp_time, "nonexistent_field") == [-2] + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_multiple_fields(r): + r.delete("test:hash") + r.hset( + "test:hash", + mapping={"field1": "value1", "field2": "value2", "field3": "value3"}, + ) + exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000) + assert r.hpexpireat("test:hash", exp_time, "field1", "field2") == [1, 1] + time.sleep(0.5) + assert r.hexists("test:hash", "field1") is False + assert r.hexists("test:hash", "field2") is False + assert r.hexists("test:hash", "field3") is True + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpireat_multiple_condition_flags_error(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1"}) + exp_time = int((datetime.now() + timedelta(milliseconds=500)).timestamp()) + with pytest.raises(ValueError) as e: + r.hpexpireat("test:hash", exp_time, "field1", nx=True, xx=True) + assert "Only one of" in str(e) + + +@skip_if_server_version_lt("7.3.240") +def test_hpersist_multiple_fields(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + r.hexpire("test:hash", 5000, "field1") + assert r.hpersist("test:hash", "field1", "field2", "field3") == [1, -1, -2] + + +@skip_if_server_version_lt("7.3.240") +def test_hpersist_nonexistent_key(r): + r.delete("test:hash") + assert r.hpersist("test:hash", "field1", "field2", "field3") == [] + + +@skip_if_server_version_lt("7.3.240") +def test_hexpiretime_multiple_fields_mixed_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + r.hexpireat("test:hash", future_time, "field1") + result = r.hexpiretime("test:hash", "field1", "field2", "field3") + assert future_time - 10 < result[0] <= future_time + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +def test_hexpiretime_nonexistent_key(r): + r.delete("test:hash") + assert r.hexpiretime("test:hash", "field1", "field2", "field3") == [] + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpiretime_multiple_fields_mixed_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + r.hexpireat("test:hash", future_time, "field1") + result = r.hpexpiretime("test:hash", "field1", "field2", "field3") + assert future_time * 1000 - 10000 < result[0] <= future_time * 1000 + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +def test_hpexpiretime_nonexistent_key(r): + r.delete("test:hash") + assert r.hpexpiretime("test:hash", "field1", "field2", "field3") == [] + + +@skip_if_server_version_lt("7.3.240") +def test_httl_multiple_fields_mixed_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + r.hexpireat("test:hash", future_time, "field1") + result = r.httl("test:hash", "field1", "field2", "field3") + assert 30 * 60 - 10 < result[0] <= 30 * 60 + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +def test_httl_nonexistent_key(r): + r.delete("test:hash") + assert r.httl("test:hash", "field1", "field2", "field3") == [] + + +@skip_if_server_version_lt("7.3.240") +def test_hpttl_multiple_fields_mixed_conditions(r): + r.delete("test:hash") + r.hset("test:hash", mapping={"field1": "value1", "field2": "value2"}) + future_time = int((datetime.now() + timedelta(minutes=30)).timestamp()) + r.hexpireat("test:hash", future_time, "field1") + result = r.hpttl("test:hash", "field1", "field2", "field3") + assert 30 * 60000 - 10000 < result[0] <= 30 * 60000 + assert result[1:] == [-1, -2] + + +@skip_if_server_version_lt("7.3.240") +def test_hpttl_nonexistent_key(r): + r.delete("test:hash") + assert r.hpttl("test:hash", "field1", "field2", "field3") == []