diff --git a/redis/commands/core.py b/redis/commands/core.py index 4e1e242dc4..a56b3d2cba 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -5094,6 +5094,374 @@ def hstrlen(self, name: str, key: str) -> Union[Awaitable[int], int]: """ return self.execute_command("HSTRLEN", name, key, keys=[name]) + 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, keys=[key] + ) + + 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, keys=[key] + ) + + 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, keys=[key] + ) + + 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, keys=[key] + ) + AsyncHashCommands = HashCommands diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index 0d9510c64b..80b0b1bff8 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -1453,7 +1453,7 @@ async def test_memory_stats(self, r: RedisCluster) -> None: assert isinstance(stats, dict) for key, value in stats.items(): if key.startswith("db."): - assert isinstance(value, dict) + assert not isinstance(value, list) @skip_if_server_version_lt("4.0.0") async def test_memory_help(self, r: RedisCluster) -> None: diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index a01a4862ab..a29b4a548a 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1352,7 +1352,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) @@ -1373,7 +1373,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 9cd9149dcc..ab8bf17618 100644 --- a/tests/test_asyncio/test_graph.py +++ b/tests/test_asyncio/test_graph.py @@ -20,6 +20,7 @@ async def test_bulk(decoded_r): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_graph_creation(decoded_r: redis.Redis): graph = decoded_r.graph() @@ -65,6 +66,7 @@ async def test_graph_creation(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_array_functions(decoded_r: redis.Redis): graph = decoded_r.graph() @@ -88,6 +90,7 @@ async def test_array_functions(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_path(decoded_r: redis.Redis): node0 = Node(node_id=0, label="L1") node1 = Node(node_id=1, label="L1") @@ -108,6 +111,7 @@ async def test_path(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_param(decoded_r: redis.Redis): params = [1, 2.3, "str", True, False, None, [0, 1, 2]] query = "RETURN $param" @@ -118,6 +122,7 @@ async def test_param(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_map(decoded_r: redis.Redis): query = "RETURN {a:1, b:'str', c:NULL, d:[1,2,3], e:True, f:{x:1, y:2}}" @@ -135,6 +140,7 @@ async def test_map(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_point(decoded_r: redis.Redis): query = "RETURN point({latitude: 32.070794860, longitude: 34.820751118})" expected_lat = 32.070794860 @@ -152,6 +158,7 @@ async def test_point(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_index_response(decoded_r: redis.Redis): result_set = await decoded_r.graph().query("CREATE INDEX ON :person(age)") assert 1 == result_set.indices_created @@ -167,6 +174,7 @@ async def test_index_response(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_stringify_query_result(decoded_r: redis.Redis): graph = decoded_r.graph() @@ -221,6 +229,7 @@ async def test_stringify_query_result(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_optional_match(decoded_r: redis.Redis): # Build a graph of form (a)-[R]->(b) node0 = Node(node_id=0, label="L1", properties={"value": "a"}) @@ -246,6 +255,7 @@ async def test_optional_match(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_cached_execution(decoded_r: redis.Redis): await decoded_r.graph().query("CREATE ()") @@ -266,6 +276,7 @@ async def test_cached_execution(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_slowlog(decoded_r: redis.Redis): create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), @@ -280,6 +291,7 @@ async def test_slowlog(decoded_r: redis.Redis): @pytest.mark.xfail(strict=False) @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_query_timeout(decoded_r: redis.Redis): # Build a sample graph with 1000 nodes. await decoded_r.graph().query("UNWIND range(0,1000) as val CREATE ({v: val})") @@ -294,6 +306,7 @@ async def test_query_timeout(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_read_only_query(decoded_r: redis.Redis): with pytest.raises(Exception): # Issue a write query, specifying read-only true, @@ -303,6 +316,7 @@ async def test_read_only_query(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_profile(decoded_r: redis.Redis): q = """UNWIND range(1, 3) AS x CREATE (p:Person {v:x})""" profile = (await decoded_r.graph().profile(q)).result_set @@ -319,6 +333,7 @@ async def test_profile(decoded_r: redis.Redis): @skip_if_redis_enterprise() @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_config(decoded_r: redis.Redis): config_name = "RESULTSET_SIZE" config_value = 3 @@ -351,6 +366,7 @@ async def test_config(decoded_r: redis.Redis): @pytest.mark.onlynoncluster @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_list_keys(decoded_r: redis.Redis): result = await decoded_r.graph().list_keys() assert result == [] @@ -374,6 +390,7 @@ async def test_list_keys(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_multi_label(decoded_r: redis.Redis): redis_graph = decoded_r.graph("g") @@ -400,6 +417,7 @@ async def test_multi_label(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_execution_plan(decoded_r: redis.Redis): redis_graph = decoded_r.graph("execution_plan") create_query = """CREATE @@ -419,6 +437,7 @@ async def test_execution_plan(decoded_r: redis.Redis): @pytest.mark.redismod +@pytest.mark.skip(reason="Graph module removed from Redis Stack") async def test_explain(decoded_r: redis.Redis): redis_graph = decoded_r.graph("execution_plan") # graph creation / population 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_cluster.py b/tests/test_cluster.py index d41ebcd506..5a32bd6a7e 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -1577,7 +1577,7 @@ def test_memory_stats(self, r): assert isinstance(stats, dict) for key, value in stats.items(): if key.startswith("db."): - assert isinstance(value, dict) + assert not isinstance(value, list) @skip_if_server_version_lt("4.0.0") def test_memory_help(self, r): 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 2eace0181c..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'}), @@ -294,6 +306,7 @@ def test_slowlog(client): @pytest.mark.redismod @pytest.mark.xfail(strict=False) +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_query_timeout(client): # Build a sample graph with 1000 nodes. client.graph().query("UNWIND range(0,1000) as val CREATE ({v: val})") @@ -308,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, @@ -317,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 @@ -333,6 +348,7 @@ def test_profile(client): @pytest.mark.redismod @skip_if_redis_enterprise() +@pytest.mark.skip(reason="Graph module removed from Redis Stack") def test_config(client): config_name = "RESULTSET_SIZE" config_value = 3 @@ -365,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 == [] @@ -388,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") @@ -414,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 @@ -487,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 @@ -506,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 @@ -595,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") == []