From ea905a892d22b4e7c11a7cca39d7e1d6dc757832 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Thu, 27 Mar 2025 21:13:35 +0300 Subject: [PATCH 1/4] feat: add HEXPIRE --- tests/commands/hash/test_hexpire.py | 118 ++++++++++++++++++++++++++++ upstash_redis/commands.py | 60 ++++++++++++++ upstash_redis/commands.pyi | 30 +++++++ 3 files changed, 208 insertions(+) create mode 100644 tests/commands/hash/test_hexpire.py diff --git a/tests/commands/hash/test_hexpire.py b/tests/commands/hash/test_hexpire.py new file mode 100644 index 0000000..faf5626 --- /dev/null +++ b/tests/commands/hash/test_hexpire.py @@ -0,0 +1,118 @@ +import pytest +import time +from upstash_redis import Redis + +@pytest.fixture(autouse=True) +def flush_hash(redis: Redis): + hash_name = "myhash" + redis.delete(hash_name) + +def test_hexpire_expires_hash_key(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 1) == [1] + + time.sleep(2) + assert redis.hget(hash_name, field) is None + +def test_hexpire_nx_sets_expiry_if_no_expiry(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 1, "NX") == [1] + + time.sleep(2) + assert redis.hget(hash_name, field) is None + +def test_hexpire_nx_does_not_set_expiry_if_already_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1000) + assert redis.hexpire(hash_name, field, 1, "NX") == [0] + +def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1) + assert redis.hexpire(hash_name, [field], 5, "XX") == [1] + + time.sleep(6) + assert redis.hget(hash_name, field) is None + +def test_hexpire_xx_does_not_set_expiry_if_not_exists(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 5, "XX") == [0] + +def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 1) + assert redis.hexpire(hash_name, field, 5, "GT") == [1] + + time.sleep(6) + assert redis.hget(hash_name, field) is None + +def test_hexpire_gt_does_not_set_if_new_not_greater(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 10) + assert redis.hexpire(hash_name, field, 5, "GT") == [0] + +def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, [field], 5) + assert redis.hexpire(hash_name, field, 3, "LT") == [1] + + time.sleep(4) + assert redis.hget(hash_name, field) is None + +def test_hexpire_lt_does_not_set_if_new_not_less(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + redis.hexpire(hash_name, field, 10) + assert redis.hexpire(hash_name, field, 20, "LT") == [0] + +def test_hexpire_returns_minus2_if_field_does_not_exist(redis: Redis): + hash_name = "myhash" + field = "field1" + assert redis.hexpire(hash_name, field, 1) == [-2] + +def test_hexpire_returns_minus2_if_hash_does_not_exist(redis: Redis): + assert redis.hexpire("nonexistent_hash", "field1", 1) == [-2] + +def test_hexpire_returns_2_when_called_with_zero_seconds(redis: Redis): + hash_name = "myhash" + field = "field1" + value = "value1" + + redis.hset(hash_name, field, value) + assert redis.hexpire(hash_name, field, 0) == [2] + assert redis.hget(hash_name, field) is None \ No newline at end of file diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 5c72434..627cff3 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -363,6 +363,66 @@ def expire( command.append("LT") return self.execute(command) + + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> ResponseT: + """ + Sets a timeout on a hash field in seconds. + After the timeout has expired, the hash field will automatically be deleted. + + :param key: The key of the hash. + :param field: The field within the hash to set the expiry for. + :param seconds: The timeout in seconds as an int or a datetime.timedelta object. + :param nx: Set expiry only when the field has no expiry. + :param xx: Set expiry only when the field has an existing expiry. + :param gt: Set expiry only when the new expiry is greater than the current one. + :param lt: Set expiry only when the new expiry is less than the current one. + + Example: + ```python + # With seconds + redis.hset("myhash", "field1", "value1") + redis.hexpire("myhash", "field1", 5) + + assert redis.hget("myhash", "field1") == "value1" + + time.sleep(5) + + assert redis.hget("myhash", "field1") is None + + # With a timedelta + redis.hset("myhash", "field1", "value1") + redis.hexpire("myhash", "field1", datetime.timedelta(seconds=5)) + ``` + + See https://redis.io/commands/hexpire for more details on expiration behavior. + """ + + if isinstance(seconds, datetime.timedelta): + seconds = int(seconds.total_seconds()) + + command: List = ["HEXPIRE", key, seconds] + + if nx: + command.append("NX") + if xx: + command.append("XX") + if gt: + command.append("GT") + if lt: + command.append("LT") + + command.extend(["FIELDS", len(fields), *fields]) + + return self.execute(command) def expireat( self, diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index 46a4649..73ab40d 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -192,6 +192,16 @@ class Commands: ) -> int: ... def hdel(self, key: str, *fields: str) -> int: ... def hexists(self, key: str, field: str) -> bool: ... + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> List[int]: ... def hget(self, key: str, field: str) -> Optional[str]: ... def hgetall(self, key: str) -> Dict[str, str]: ... def hincrby(self, key: str, field: str, increment: int) -> int: ... @@ -691,6 +701,16 @@ class AsyncCommands: ) -> int: ... async def hdel(self, key: str, *fields: str) -> int: ... async def hexists(self, key: str, field: str) -> bool: ... + async def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> int: ... async def hget(self, key: str, field: str) -> Optional[str]: ... async def hgetall(self, key: str) -> Dict[str, str]: ... async def hincrby(self, key: str, field: str, increment: int) -> int: ... @@ -1233,6 +1253,16 @@ class PipelineCommands: ) -> PipelineCommands: ... def hdel(self, key: str, *fields: str) -> PipelineCommands: ... def hexists(self, key: str, field: str) -> PipelineCommands: ... + def hexpire( + self, + key: str, + fields: Union[str, List[str]], + seconds: Union[int, datetime.timedelta], + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> PipelineCommands: ... def hget(self, key: str, field: str) -> PipelineCommands: ... def hgetall(self, key: str) -> PipelineCommands: ... def hincrby(self, key: str, field: str, increment: int) -> PipelineCommands: ... From 68c3bfbc68b401569e8abf7e1dbc8670f15221e0 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Thu, 27 Mar 2025 21:17:12 +0300 Subject: [PATCH 2/4] fix: fmt --- tests/commands/hash/test_hexpire.py | 17 +++++++++++++++-- upstash_redis/commands.py | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/commands/hash/test_hexpire.py b/tests/commands/hash/test_hexpire.py index faf5626..683479f 100644 --- a/tests/commands/hash/test_hexpire.py +++ b/tests/commands/hash/test_hexpire.py @@ -2,11 +2,13 @@ import time from upstash_redis import Redis + @pytest.fixture(autouse=True) def flush_hash(redis: Redis): hash_name = "myhash" redis.delete(hash_name) + def test_hexpire_expires_hash_key(redis: Redis): hash_name = "myhash" field = "field1" @@ -18,6 +20,7 @@ def test_hexpire_expires_hash_key(redis: Redis): time.sleep(2) assert redis.hget(hash_name, field) is None + def test_hexpire_nx_sets_expiry_if_no_expiry(redis: Redis): hash_name = "myhash" field = "field1" @@ -29,6 +32,7 @@ def test_hexpire_nx_sets_expiry_if_no_expiry(redis: Redis): time.sleep(2) assert redis.hget(hash_name, field) is None + def test_hexpire_nx_does_not_set_expiry_if_already_exists(redis: Redis): hash_name = "myhash" field = "field1" @@ -38,6 +42,7 @@ def test_hexpire_nx_does_not_set_expiry_if_already_exists(redis: Redis): redis.hexpire(hash_name, field, 1000) assert redis.hexpire(hash_name, field, 1, "NX") == [0] + def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): hash_name = "myhash" field = "field1" @@ -46,10 +51,11 @@ def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 1) assert redis.hexpire(hash_name, [field], 5, "XX") == [1] - + time.sleep(6) assert redis.hget(hash_name, field) is None + def test_hexpire_xx_does_not_set_expiry_if_not_exists(redis: Redis): hash_name = "myhash" field = "field1" @@ -58,6 +64,7 @@ def test_hexpire_xx_does_not_set_expiry_if_not_exists(redis: Redis): redis.hset(hash_name, field, value) assert redis.hexpire(hash_name, field, 5, "XX") == [0] + def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): hash_name = "myhash" field = "field1" @@ -70,6 +77,7 @@ def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): time.sleep(6) assert redis.hget(hash_name, field) is None + def test_hexpire_gt_does_not_set_if_new_not_greater(redis: Redis): hash_name = "myhash" field = "field1" @@ -79,6 +87,7 @@ def test_hexpire_gt_does_not_set_if_new_not_greater(redis: Redis): redis.hexpire(hash_name, field, 10) assert redis.hexpire(hash_name, field, 5, "GT") == [0] + def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): hash_name = "myhash" field = "field1" @@ -91,6 +100,7 @@ def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): time.sleep(4) assert redis.hget(hash_name, field) is None + def test_hexpire_lt_does_not_set_if_new_not_less(redis: Redis): hash_name = "myhash" field = "field1" @@ -100,14 +110,17 @@ def test_hexpire_lt_does_not_set_if_new_not_less(redis: Redis): redis.hexpire(hash_name, field, 10) assert redis.hexpire(hash_name, field, 20, "LT") == [0] + def test_hexpire_returns_minus2_if_field_does_not_exist(redis: Redis): hash_name = "myhash" field = "field1" assert redis.hexpire(hash_name, field, 1) == [-2] + def test_hexpire_returns_minus2_if_hash_does_not_exist(redis: Redis): assert redis.hexpire("nonexistent_hash", "field1", 1) == [-2] + def test_hexpire_returns_2_when_called_with_zero_seconds(redis: Redis): hash_name = "myhash" field = "field1" @@ -115,4 +128,4 @@ def test_hexpire_returns_2_when_called_with_zero_seconds(redis: Redis): redis.hset(hash_name, field, value) assert redis.hexpire(hash_name, field, 0) == [2] - assert redis.hget(hash_name, field) is None \ No newline at end of file + assert redis.hget(hash_name, field) is None diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 627cff3..bc60925 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -363,7 +363,7 @@ def expire( command.append("LT") return self.execute(command) - + def hexpire( self, key: str, From f4f7ac505e6a31d36799fa8d0761a456d50c4d7b Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 2 Apr 2025 18:31:40 +0300 Subject: [PATCH 3/4] fix: review --- tests/commands/hash/test_hexpire.py | 20 +++++++++++--------- upstash_redis/commands.py | 3 +++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/commands/hash/test_hexpire.py b/tests/commands/hash/test_hexpire.py index 683479f..e183330 100644 --- a/tests/commands/hash/test_hexpire.py +++ b/tests/commands/hash/test_hexpire.py @@ -27,7 +27,7 @@ def test_hexpire_nx_sets_expiry_if_no_expiry(redis: Redis): value = "value1" redis.hset(hash_name, field, value) - assert redis.hexpire(hash_name, field, 1, "NX") == [1] + assert redis.hexpire(hash_name, field, 1, nx=True) == [1] time.sleep(2) assert redis.hget(hash_name, field) is None @@ -40,7 +40,7 @@ def test_hexpire_nx_does_not_set_expiry_if_already_exists(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 1000) - assert redis.hexpire(hash_name, field, 1, "NX") == [0] + assert redis.hexpire(hash_name, field, 1, nx=True) == [0] def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): @@ -50,7 +50,7 @@ def test_hexpire_xx_sets_expiry_if_exists(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 1) - assert redis.hexpire(hash_name, [field], 5, "XX") == [1] + assert redis.hexpire(hash_name, [field], 5, xx=True) == [1] time.sleep(6) assert redis.hget(hash_name, field) is None @@ -62,7 +62,7 @@ def test_hexpire_xx_does_not_set_expiry_if_not_exists(redis: Redis): value = "value1" redis.hset(hash_name, field, value) - assert redis.hexpire(hash_name, field, 5, "XX") == [0] + assert redis.hexpire(hash_name, field, 5, xx=True) == [0] def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): @@ -72,7 +72,7 @@ def test_hexpire_gt_sets_expiry_if_new_greater(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 1) - assert redis.hexpire(hash_name, field, 5, "GT") == [1] + assert redis.hexpire(hash_name, field, 5, gt=True) == [1] time.sleep(6) assert redis.hget(hash_name, field) is None @@ -85,7 +85,7 @@ def test_hexpire_gt_does_not_set_if_new_not_greater(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 10) - assert redis.hexpire(hash_name, field, 5, "GT") == [0] + assert redis.hexpire(hash_name, [field], 5, gt=True) == [0] def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): @@ -95,7 +95,7 @@ def test_hexpire_lt_sets_expiry_if_new_less(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, [field], 5) - assert redis.hexpire(hash_name, field, 3, "LT") == [1] + assert redis.hexpire(hash_name, field, 3, lt=True) == [1] time.sleep(4) assert redis.hget(hash_name, field) is None @@ -108,13 +108,15 @@ def test_hexpire_lt_does_not_set_if_new_not_less(redis: Redis): redis.hset(hash_name, field, value) redis.hexpire(hash_name, field, 10) - assert redis.hexpire(hash_name, field, 20, "LT") == [0] + assert redis.hexpire(hash_name, [field], 20, lt=True) == [0] def test_hexpire_returns_minus2_if_field_does_not_exist(redis: Redis): hash_name = "myhash" field = "field1" - assert redis.hexpire(hash_name, field, 1) == [-2] + field2 = "field2" + redis.hset(hash_name, field, 10) + assert redis.hexpire(hash_name, field2, 1) == [-2] def test_hexpire_returns_minus2_if_hash_does_not_exist(redis: Redis): diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index bc60925..3632ba2 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -420,6 +420,9 @@ def hexpire( if lt: command.append("LT") + if isinstance(fields, str): + fields = [fields] + command.extend(["FIELDS", len(fields), *fields]) return self.execute(command) From 18025bd8a8e03c1940d7541b1ab4662087304a8b Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 2 Apr 2025 18:42:00 +0300 Subject: [PATCH 4/4] fix: fmt --- tests/commands/hash/test_hexpire.py | 2 +- upstash_redis/commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/commands/hash/test_hexpire.py b/tests/commands/hash/test_hexpire.py index e183330..2908f47 100644 --- a/tests/commands/hash/test_hexpire.py +++ b/tests/commands/hash/test_hexpire.py @@ -115,7 +115,7 @@ def test_hexpire_returns_minus2_if_field_does_not_exist(redis: Redis): hash_name = "myhash" field = "field1" field2 = "field2" - redis.hset(hash_name, field, 10) + redis.hset(hash_name, field, "10") assert redis.hexpire(hash_name, field2, 1) == [-2] diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 3632ba2..7946221 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -420,7 +420,7 @@ def hexpire( if lt: command.append("LT") - if isinstance(fields, str): + if isinstance(fields, str): fields = [fields] command.extend(["FIELDS", len(fields), *fields])