From b16eca3ec9beb7f97d8f5cf98c1d708a002af99f Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 16:50:57 +0300 Subject: [PATCH 01/14] feat: add new hash commands --- tests/commands/hash/test_hgetdel.py | 88 +++++++++++++ tests/commands/hash/test_hgetex.py | 169 ++++++++++++++++++++++++ tests/commands/hash/test_hsetex.py | 193 ++++++++++++++++++++++++++++ upstash_redis/commands.py | 160 +++++++++++++++++++++++ upstash_redis/commands.pyi | 75 +++++++++++ 5 files changed, 685 insertions(+) create mode 100644 tests/commands/hash/test_hgetdel.py create mode 100644 tests/commands/hash/test_hgetex.py create mode 100644 tests/commands/hash/test_hsetex.py diff --git a/tests/commands/hash/test_hgetdel.py b/tests/commands/hash/test_hgetdel.py new file mode 100644 index 0000000..f46fd9f --- /dev/null +++ b/tests/commands/hash/test_hgetdel.py @@ -0,0 +1,88 @@ +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_hash(redis: Redis): + hash_name = "myhash" + redis.delete(hash_name) + yield + redis.delete(hash_name) + + +def test_hgetdel_single_field(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + redis.hset(hash_name, field1, value1) + + # Get and delete the field + result = redis.hgetdel(hash_name, field1) + + assert result == [value1] + # Verify field is deleted + assert redis.hget(hash_name, field1) is None + + +def test_hgetdel_multiple_fields(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, values={ + "field1": "value1", + "field2": "value2", + "field3": "value3" + }) + + # Get and delete multiple fields + result = redis.hgetdel(hash_name, "field1", "field2") + + assert result == ["value1", "value2"] + # Verify fields are deleted + assert redis.hget(hash_name, "field1") is None + assert redis.hget(hash_name, "field2") is None + # Verify field3 still exists + assert redis.hget(hash_name, "field3") == "value3" + + +def test_hgetdel_non_existing_field(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, "field1", "value1") + + # Get and delete existing and non-existing fields + result = redis.hgetdel(hash_name, "field1", "non_existing") + + assert result == ["value1", None] + assert redis.hget(hash_name, "field1") is None + + +def test_hgetdel_deletes_key_when_last_field_removed(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, "field1", "value1") + + # Get and delete the only field + result = redis.hgetdel(hash_name, "field1") + + assert result == ["value1"] + # Verify the entire key is deleted + assert redis.exists(hash_name) == 0 + + +def test_hgetdel_non_existing_hash(redis: Redis): + hash_name = "non_existing_hash" + + # Try to get and delete from non-existing hash + result = redis.hgetdel(hash_name, "field1") + + assert result == [None] + + +def test_hgetdel_requires_at_least_one_field(redis: Redis): + hash_name = "myhash" + + with pytest.raises(Exception, match="requires at least one field"): + redis.hgetdel(hash_name) + diff --git a/tests/commands/hash/test_hgetex.py b/tests/commands/hash/test_hgetex.py new file mode 100644 index 0000000..0ffd566 --- /dev/null +++ b/tests/commands/hash/test_hgetex.py @@ -0,0 +1,169 @@ +import time + +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_hash(redis: Redis): + hash_name = "myhash" + redis.delete(hash_name) + yield + redis.delete(hash_name) + + +def test_hgetex_single_field(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + redis.hset(hash_name, field1, value1) + + # Get single field value without setting expiration + result = redis.hgetex(hash_name, field1) + + assert result == [value1] + + +def test_hgetex_multiple_fields(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, values={ + "field1": "value1", + "field2": "value2", + "field3": "value3" + }) + + # Get multiple field values + result = redis.hgetex(hash_name, "field1", "field2") + + assert result == ["value1", "value2"] + + +def test_hgetex_with_ex(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, values={ + "field1": "value1", + "field2": "value2" + }) + + # Get values and set expiration in seconds + result = redis.hgetex(hash_name, "field1", "field2", ex=2) + + assert result == ["value1", "value2"] + # Verify fields still exist + assert redis.hget(hash_name, "field1") == "value1" + assert redis.hget(hash_name, "field2") == "value2" + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, "field1") is None + assert redis.hget(hash_name, "field2") is None + + +def test_hgetex_with_px(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, values={ + "field1": "value1", + "field2": "value2" + }) + + # Get values and set expiration in milliseconds + result = redis.hgetex(hash_name, "field1", "field2", px=2000) + + assert result == ["value1", "value2"] + # Verify fields still exist + assert redis.hget(hash_name, "field1") == "value1" + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, "field1") is None + + +def test_hgetex_with_exat(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + redis.hset(hash_name, field1, value1) + + # Get value and set expiration at specific timestamp + future_timestamp = int(time.time()) + 2 + result = redis.hgetex(hash_name, field1, exat=future_timestamp) + + assert result == [value1] + # Verify field still exists + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hgetex_with_pxat(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + redis.hset(hash_name, field1, value1) + + # Get value and set expiration at specific timestamp in milliseconds + future_timestamp_ms = int(time.time() * 1000) + 2000 + result = redis.hgetex(hash_name, field1, pxat=future_timestamp_ms) + + assert result == [value1] + # Verify field still exists + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hgetex_with_persist(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + # Set field with expiration + redis.hset(hash_name, field1, value1) + redis.hexpire(hash_name, field1, 10) + + # Get value and remove expiration + result = redis.hgetex(hash_name, field1, persist=True) + + assert result == [value1] + # Verify field has no expiration + ttl = redis.httl(hash_name, [field1]) + assert ttl == [-1] + + +def test_hgetex_non_existing_field(redis: Redis): + hash_name = "myhash" + + redis.hset(hash_name, "field1", "value1") + + # Try to get existing and non-existing fields + result = redis.hgetex(hash_name, "field1", "non_existing", ex=60) + + assert result == ["value1", None] + + +def test_hgetex_non_existing_hash(redis: Redis): + hash_name = "non_existing_hash" + + # Try to get from non-existing hash + result = redis.hgetex(hash_name, "field1", "field2", ex=60) + + assert result == [None, None] + + +def test_hgetex_requires_at_least_one_field(redis: Redis): + hash_name = "myhash" + + with pytest.raises(Exception, match="requires at least one field"): + redis.hgetex(hash_name) + diff --git a/tests/commands/hash/test_hsetex.py b/tests/commands/hash/test_hsetex.py new file mode 100644 index 0000000..da286cd --- /dev/null +++ b/tests/commands/hash/test_hsetex.py @@ -0,0 +1,193 @@ +import time + +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_hash(redis: Redis): + hash_name = "myhash" + redis.delete(hash_name) + yield + redis.delete(hash_name) + + +def test_hsetex_single_field_with_ex(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + # Set field with expiration in seconds + result = redis.hsetex(hash_name, field1, value1, ex=2) + + assert result == 1 + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hsetex_single_field_with_px(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + # Set field with expiration in milliseconds + result = redis.hsetex(hash_name, field1, value1, px=2000) + + assert result == 1 + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hsetex_single_field_with_exat(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + # Set field with expiration at specific timestamp + future_timestamp = int(time.time()) + 2 + result = redis.hsetex(hash_name, field1, value1, exat=future_timestamp) + + assert result == 1 + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hsetex_single_field_with_pxat(redis: Redis): + hash_name = "myhash" + field1 = "field1" + value1 = "value1" + + # Set field with expiration at specific timestamp in milliseconds + future_timestamp_ms = int(time.time() * 1000) + 2000 + result = redis.hsetex(hash_name, field1, value1, pxat=future_timestamp_ms) + + assert result == 1 + assert redis.hget(hash_name, field1) == value1 + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, field1) is None + + +def test_hsetex_multiple_fields_with_ex(redis: Redis): + hash_name = "myhash" + + # Set multiple fields with expiration + result = redis.hsetex(hash_name, values={ + "field1": "value1", + "field2": "value2", + "field3": "value3" + }, ex=2) + + assert result >= 1 # Returns success indicator + assert redis.hget(hash_name, "field1") == "value1" + assert redis.hget(hash_name, "field2") == "value2" + assert redis.hget(hash_name, "field3") == "value3" + + # Wait for expiration + time.sleep(3) + assert redis.hget(hash_name, "field1") is None + assert redis.hget(hash_name, "field2") is None + assert redis.hget(hash_name, "field3") is None + + +def test_hsetex_with_fnx(redis: Redis): + hash_name = "myhash" + field1 = "field1" + + # Set field with FNX (only if field doesn't exist) + result1 = redis.hsetex(hash_name, field1, "value1", fnx=True, ex=60) + assert result1 == 1 + assert redis.hget(hash_name, field1) == "value1" + + # Try to set again with FNX (should not update) + result2 = redis.hsetex(hash_name, field1, "value2", fnx=True, ex=60) + assert result2 == 0 + assert redis.hget(hash_name, field1) == "value1" + + +def test_hsetex_with_fxx(redis: Redis): + hash_name = "myhash" + field1 = "field1" + + # Try to set with FXX when field doesn't exist (should fail) + result1 = redis.hsetex(hash_name, field1, "value1", fxx=True, ex=60) + assert result1 == 0 + assert redis.hget(hash_name, field1) is None + + # Set field first + redis.hset(hash_name, field1, "initial") + + # Now set with FXX (should work) + result2 = redis.hsetex(hash_name, field1, "value2", fxx=True, ex=60) + assert result2 >= 0 # Returns success indicator + assert redis.hget(hash_name, field1) == "value2" + + +def test_hsetex_with_keepttl(redis: Redis): + hash_name = "myhash" + field1 = "field1" + + # Set field with expiration + redis.hset(hash_name, field1, "value1") + redis.hexpire(hash_name, field1, 100) + + # Update field value but keep TTL + result = redis.hsetex(hash_name, field1, "value2", keepttl=True) + + assert result >= 0 # Returns success indicator + assert redis.hget(hash_name, field1) == "value2" + + # Verify TTL is still set (should be close to 100) + ttl = redis.httl(hash_name, [field1]) + assert ttl[0] > 90 # Allow some time for execution + + +def test_hsetex_requires_field_value_pairs(redis: Redis): + hash_name = "myhash" + + with pytest.raises(Exception, match="no key value pairs"): + redis.hsetex(hash_name, ex=60) + + +def test_hsetex_mixed_field_and_values(redis: Redis): + hash_name = "myhash" + + # Set both single field and multiple values + result = redis.hsetex( + hash_name, + field="field1", + value="value1", + values={"field2": "value2", "field3": "value3"}, + ex=60 + ) + + assert result >= 1 # Returns success indicator + assert redis.hget(hash_name, "field1") == "value1" + assert redis.hget(hash_name, "field2") == "value2" + assert redis.hget(hash_name, "field3") == "value3" + + +def test_hsetex_updates_existing_field(redis: Redis): + hash_name = "myhash" + field1 = "field1" + + # Set initial value + redis.hset(hash_name, field1, "initial") + + # Update with HSETEX + result = redis.hsetex(hash_name, field1, "updated", ex=60) + + assert result >= 0 # Returns success indicator + assert redis.hget(hash_name, field1) == "updated" + diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 34fa5b5..f37dc0c 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -1568,6 +1568,88 @@ def hget(self, key: str, field: str) -> ResponseT: return self.execute(command) + def hgetdel(self, key: str, *fields: str) -> ResponseT: + """ + Returns the value of one or more fields and deletes them atomically from a hash. + + When the last field is deleted, the key is also deleted. + + Returns a list of values corresponding to the fields. Returns None for fields that do not exist. + + Example: + ```python + redis.hset("myhash", values={"field1": "Hello", "field2": "World"}) + + values = redis.hgetdel("myhash", "field1", "field2") + assert values == ["Hello", "World"] + + # Fields are now deleted + assert redis.hget("myhash", "field1") is None + ``` + + See https://redis.io/commands/hgetdel + """ + if not fields: + raise Exception("'hgetdel' requires at least one field") + + command: List = ["HGETDEL", key, "FIELDS", len(fields), *fields] + + return self.execute(command) + + def hgetex( + self, + key: str, + *fields: str, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + persist: Optional[bool] = None, + ) -> ResponseT: + """ + Returns the values of one or more fields and optionally sets their expiration. + + Returns a list of values corresponding to the fields. Returns None for fields that do not exist. + + :param ex: the number of seconds until the field(s) expire. + :param px: the number of milliseconds until the field(s) expire. + :param exat: the UNIX timestamp in seconds until the field(s) expire. + :param pxat: the UNIX timestamp in milliseconds until the field(s) expire. + :param persist: Remove the expiration from the field(s). + + Example: + ```python + redis.hset("myhash", values={"field1": "Hello", "field2": "World"}) + + # Get values and set expiration to 60 seconds + values = redis.hgetex("myhash", "field1", "field2", ex=60) + assert values == ["Hello", "World"] + ``` + + See https://redis.io/commands/hgetex + """ + if not fields: + raise Exception("'hgetex' requires at least one field") + + command: List = ["HGETEX", key] + + # Add expiration options before FIELDS + if ex is not None: + command.extend(["EX", ex]) + elif px is not None: + command.extend(["PX", px]) + elif exat is not None: + command.extend(["EXAT", exat]) + elif pxat is not None: + command.extend(["PXAT", pxat]) + elif persist: + command.append("PERSIST") + + # Add FIELDS keyword and field list + command.extend(["FIELDS", len(fields), *fields]) + + return self.execute(command) + def hgetall(self, key: str) -> ResponseT: """ Returns all fields and values of a hash. @@ -1894,6 +1976,84 @@ def hsetnx(self, key: str, field: str, value: ValueT) -> ResponseT: return self.execute(command) + def hsetex( + self, + key: str, + field: Optional[str] = None, + value: Optional[ValueT] = None, + values: Optional[Mapping[str, ValueT]] = None, + fnx: bool = False, + fxx: bool = False, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + keepttl: bool = False, + ) -> ResponseT: + """ + Sets the value of one or multiple fields in a hash with optional expiration support. + + Returns the number of fields that were added. + + :param fnx: Only set if field does not exist. + :param fxx: Only set if field exists. + :param ex: the number of seconds until the field(s) expire. + :param px: the number of milliseconds until the field(s) expire. + :param exat: the UNIX timestamp in seconds until the field(s) expire. + :param pxat: the UNIX timestamp in milliseconds until the field(s) expire. + :param keepttl: Retain the time to live associated with the field. + + Example: + ```python + # Set a single field with expiration + assert redis.hsetex("myhash", "field1", "Hello", ex=60) == 1 + + # Set multiple fields with expiration + assert redis.hsetex("myhash", values={ + "field1": "Hello", + "field2": "World" + }, px=60000) == 2 + ``` + + See https://redis.io/commands/hsetex + """ + command: List = ["HSETEX", key] + + if field is None and values is None: + raise Exception("'hsetex' with no key value pairs") + + # Add conditional options + if fnx: + command.append("FNX") + elif fxx: + command.append("FXX") + + # Add expiration options + if ex is not None: + command.extend(["EX", ex]) + elif px is not None: + command.extend(["PX", px]) + elif exat is not None: + command.extend(["EXAT", exat]) + elif pxat is not None: + command.extend(["PXAT", pxat]) + elif keepttl: + command.append("KEEPTTL") + + # Build fields list + fields_data: List = [] + if field and value is not None: + fields_data.extend([field, value]) + + if values is not None: + for f, v in values.items(): + fields_data.extend([f, v]) + + # Add FIELDS numfields and field-value pairs + command.extend(["FIELDS", len(fields_data) // 2, *fields_data]) + + return self.execute(command) + def hstrlen(self, key: str, field: str) -> ResponseT: """ Returns the length of a value in a hash. diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index 5b3b60c..c336e0a 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -238,6 +238,17 @@ class Commands: def hpexpiretime(self, key: str, fields: Union[str, List[str]]) -> List[int]: ... def hpersist(self, key: str, fields: Union[str, List[str]]) -> int: ... def hget(self, key: str, field: str) -> Optional[str]: ... + def hgetdel(self, key: str, *fields: str) -> List[Optional[str]]: ... + def hgetex( + self, + key: str, + *fields: str, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + persist: Optional[bool] = None, + ) -> List[Optional[str]]: ... def hgetall(self, key: str) -> Dict[str, str]: ... def hincrby(self, key: str, field: str, increment: int) -> int: ... def hincrbyfloat(self, key: str, field: str, increment: float) -> float: ... @@ -263,6 +274,20 @@ class Commands: values: Optional[Mapping[str, ValueT]] = None, ) -> int: ... def hsetnx(self, key: str, field: str, value: ValueT) -> bool: ... + def hsetex( + self, + key: str, + field: Optional[str] = None, + value: Optional[ValueT] = None, + values: Optional[Mapping[str, ValueT]] = None, + fnx: bool = False, + fxx: bool = False, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + keepttl: bool = False, + ) -> int: ... def hstrlen(self, key: str, field: str) -> int: ... def hvals(self, key: str) -> List[str]: ... def pfadd(self, key: str, *elements: ValueT) -> bool: ... @@ -883,6 +908,17 @@ class AsyncCommands: ) -> List[int]: ... async def hpersist(self, key: str, fields: Union[str, List[str]]) -> List[int]: ... async def hget(self, key: str, field: str) -> Optional[str]: ... + async def hgetdel(self, key: str, *fields: str) -> List[Optional[str]]: ... + async def hgetex( + self, + key: str, + *fields: str, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + persist: Optional[bool] = None, + ) -> List[Optional[str]]: ... async def hgetall(self, key: str) -> Dict[str, str]: ... async def hincrby(self, key: str, field: str, increment: int) -> int: ... async def hincrbyfloat(self, key: str, field: str, increment: float) -> float: ... @@ -908,6 +944,20 @@ class AsyncCommands: values: Optional[Mapping[str, ValueT]] = None, ) -> int: ... async def hsetnx(self, key: str, field: str, value: ValueT) -> bool: ... + async def hsetex( + self, + key: str, + field: Optional[str] = None, + value: Optional[ValueT] = None, + values: Optional[Mapping[str, ValueT]] = None, + fnx: bool = False, + fxx: bool = False, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + keepttl: bool = False, + ) -> int: ... async def hstrlen(self, key: str, field: str) -> int: ... async def hvals(self, key: str) -> List[str]: ... async def pfadd(self, key: str, *elements: ValueT) -> bool: ... @@ -1573,6 +1623,17 @@ class PipelineCommands: ) -> PipelineCommands: ... def hpersist(self, key: str, fields: Union[str, List[str]]) -> PipelineCommands: ... def hget(self, key: str, field: str) -> PipelineCommands: ... + def hgetdel(self, key: str, *fields: str) -> PipelineCommands: ... + def hgetex( + self, + key: str, + *fields: str, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + persist: Optional[bool] = None, + ) -> PipelineCommands: ... def hgetall(self, key: str) -> PipelineCommands: ... def hincrby(self, key: str, field: str, increment: int) -> PipelineCommands: ... def hincrbyfloat( @@ -1600,6 +1661,20 @@ class PipelineCommands: values: Optional[Mapping[str, ValueT]] = None, ) -> PipelineCommands: ... def hsetnx(self, key: str, field: str, value: ValueT) -> PipelineCommands: ... + def hsetex( + self, + key: str, + field: Optional[str] = None, + value: Optional[ValueT] = None, + values: Optional[Mapping[str, ValueT]] = None, + fnx: bool = False, + fxx: bool = False, + ex: Optional[int] = None, + px: Optional[int] = None, + exat: Optional[int] = None, + pxat: Optional[int] = None, + keepttl: bool = False, + ) -> PipelineCommands: ... def hstrlen(self, key: str, field: str) -> PipelineCommands: ... def hvals(self, key: str) -> PipelineCommands: ... def pfadd(self, key: str, *elements: ValueT) -> PipelineCommands: ... From 3c42076d9e7b76e9f97ab24d2f08c5dca09a915f Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 16:55:21 +0300 Subject: [PATCH 02/14] feat: add new stream commands --- tests/commands/stream/test_xackdel.py | 294 ++++++++++++++++++++++++++ tests/commands/stream/test_xdelex.py | 209 ++++++++++++++++++ upstash_redis/commands.py | 73 +++++++ upstash_redis/commands.pyi | 26 +++ 4 files changed, 602 insertions(+) create mode 100644 tests/commands/stream/test_xackdel.py create mode 100644 tests/commands/stream/test_xdelex.py diff --git a/tests/commands/stream/test_xackdel.py b/tests/commands/stream/test_xackdel.py new file mode 100644 index 0000000..5f2229d --- /dev/null +++ b/tests/commands/stream/test_xackdel.py @@ -0,0 +1,294 @@ +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_db(redis: Redis): + redis.flushdb() + + +def test_xackdel_acknowledge_and_delete_messages(redis: Redis): + """Test acknowledging and deleting messages successfully""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add some messages to the stream + id1 = redis.xadd(stream_key, "*", {"field1": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field2": "value2"}) + + # Create a consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read messages with the consumer group (makes them pending) + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + # Acknowledge and delete the messages + result = redis.xackdel(stream_key, group, id1, id2) + assert isinstance(result, list) + assert len(result) == 2 + + # Verify messages are deleted + length = redis.xlen(stream_key) + assert length == 0 + + +def test_xackdel_single_message(redis: Redis): + """Test acknowledging and deleting a single message""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add a message + message_id = redis.xadd(stream_key, "*", {"field": "value"}) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read the message + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=1) + + # Acknowledge and delete it + result = redis.xackdel(stream_key, group, message_id) + assert isinstance(result, list) + assert len(result) == 1 + + # Verify message is deleted + length = redis.xlen(stream_key) + assert length == 0 + + +def test_xackdel_with_keepref_option(redis: Redis): + """Test XACKDEL with KEEPREF option""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add messages + id1 = redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + + # Create consumer group and read messages + redis.xgroup_create(stream_key, group, "0") + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + # Acknowledge and delete with KEEPREF option + result = redis.xackdel(stream_key, group, id1, option="KEEPREF") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xackdel_with_delref_option(redis: Redis): + """Test XACKDEL with DELREF option""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add messages + id1 = redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + + # Create consumer group and read messages + redis.xgroup_create(stream_key, group, "0") + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + # Acknowledge and delete with DELREF option + result = redis.xackdel(stream_key, group, id1, option="DELREF") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xackdel_with_acked_option(redis: Redis): + """Test XACKDEL with ACKED option""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add messages + id1 = redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + + # Create consumer group and read messages + redis.xgroup_create(stream_key, group, "0") + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + # First acknowledge the messages + redis.xack(stream_key, group, id1, id2) + + # Then use XACKDEL with ACKED option + result = redis.xackdel(stream_key, group, id1, id2, option="ACKED") + assert isinstance(result, list) + assert len(result) == 2 + + +def test_xackdel_case_insensitive_option(redis: Redis): + """Test that option is case-insensitive""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add messages + id1 = redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + + # Create consumer group and read messages + redis.xgroup_create(stream_key, group, "0") + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + # Test with lowercase option + result1 = redis.xackdel(stream_key, group, id1, option="keepref") + assert isinstance(result1, list) + + # Test with uppercase option + result2 = redis.xackdel(stream_key, group, id2, option="DELREF") + assert isinstance(result2, list) + + +def test_xackdel_nonexistent_message(redis: Redis): + """Test acknowledging and deleting non-existent messages""" + stream_key = "test_stream" + group = "test_group" + + # Add a message and create group + redis.xadd(stream_key, "*", {"field": "value"}) + redis.xgroup_create(stream_key, group, "0") + + # Try to acknowledge and delete a non-existent message + result = redis.xackdel(stream_key, group, "9999999999999-0") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xackdel_multiple_messages(redis: Redis): + """Test acknowledging and deleting multiple messages at once""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add multiple messages + ids = [] + for i in range(5): + message_id = redis.xadd(stream_key, "*", {"field": f"value{i}"}) + ids.append(message_id) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read all messages + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=5) + + # Acknowledge and delete all messages + result = redis.xackdel(stream_key, group, *ids) + assert isinstance(result, list) + assert len(result) == 5 + + # Verify all messages are deleted + length = redis.xlen(stream_key) + assert length == 0 + + +def test_xackdel_partial_messages(redis: Redis): + """Test acknowledging and deleting some messages out of many""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add multiple messages + ids = [] + for i in range(5): + message_id = redis.xadd(stream_key, "*", {"field": f"value{i}"}) + ids.append(message_id) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read all messages + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=5) + + # Acknowledge and delete only first 3 messages + result = redis.xackdel(stream_key, group, ids[0], ids[1], ids[2]) + assert isinstance(result, list) + assert len(result) == 3 + + # Verify 2 messages still remain + length = redis.xlen(stream_key) + assert length == 2 + + +def test_xackdel_requires_at_least_one_id(redis: Redis): + """Test that XACKDEL requires at least one ID""" + stream_key = "test_stream" + group = "test_group" + + with pytest.raises(Exception, match="requires at least one ID"): + redis.xackdel(stream_key, group) + + +def test_xackdel_wrong_group_name(redis: Redis): + """Test acknowledging and deleting with wrong group name""" + stream_key = "test_stream" + group = "test_group" + wrong_group = "wrong_group" + consumer = "test_consumer" + + # Add a message + message_id = redis.xadd(stream_key, "*", {"field": "value"}) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read the message + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=1) + + # Try with wrong group (should still return a result) + result = redis.xackdel(stream_key, wrong_group, message_id) + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xackdel_unread_messages(redis: Redis): + """Test XACKDEL on messages that haven't been read yet""" + stream_key = "test_stream" + group = "test_group" + + # Add messages but don't read them + id1 = redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Try to acknowledge and delete without reading + result = redis.xackdel(stream_key, group, id1, id2) + assert isinstance(result, list) + assert len(result) == 2 + + +def test_xackdel_already_acknowledged(redis: Redis): + """Test XACKDEL on already acknowledged messages""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add a message + message_id = redis.xadd(stream_key, "*", {"field": "value"}) + + # Create consumer group + redis.xgroup_create(stream_key, group, "0") + + # Read the message + redis.xreadgroup(group, consumer, {stream_key: ">"}, count=1) + + # Acknowledge first + redis.xack(stream_key, group, message_id) + + # Then try XACKDEL + result = redis.xackdel(stream_key, group, message_id) + assert isinstance(result, list) + assert len(result) == 1 + + # Message should be deleted + length = redis.xlen(stream_key) + assert length == 0 + diff --git a/tests/commands/stream/test_xdelex.py b/tests/commands/stream/test_xdelex.py new file mode 100644 index 0000000..f6461b9 --- /dev/null +++ b/tests/commands/stream/test_xdelex.py @@ -0,0 +1,209 @@ +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_db(redis: Redis): + redis.flushdb() + + +def test_xdelex_single_entry(redis: Redis): + """Test extended delete of a single entry from stream""" + key = "test_stream" + + # Add some entries + id1 = redis.xadd(key, "*", {"name": "Jane", "surname": "Austen"}) + id2 = redis.xadd(key, "*", {"name": "Toni", "surname": "Morrison"}) + + # Delete one entry with XDELEX + result = redis.xdelex(key, id2) + assert isinstance(result, list) + assert len(result) == 1 + + # Verify only one entry remains + entries = redis.xrange(key, "-", "+") + assert len(entries) == 1 + assert entries[0][0] == id1 + + +def test_xdelex_multiple_entries(redis: Redis): + """Test extended delete of multiple entries from stream""" + key = "test_stream" + + # Add multiple entries + id1 = redis.xadd(key, "*", {"name": "Jane", "surname": "Austen"}) + id2 = redis.xadd(key, "*", {"name": "Toni", "surname": "Morrison"}) + id3 = redis.xadd(key, "*", {"name": "Agatha", "surname": "Christie"}) + id4 = redis.xadd(key, "*", {"name": "Ngozi", "surname": "Adichie"}) + + # Delete multiple entries at once + result = redis.xdelex(key, id1, id2, id3) + assert isinstance(result, list) + assert len(result) == 3 + + # Verify only one entry remains + entries = redis.xrange(key, "-", "+") + assert len(entries) == 1 + assert entries[0][0] == id4 + + +def test_xdelex_with_keepref_option(redis: Redis): + """Test XDELEX with KEEPREF option""" + key = "test_stream" + + # Add entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Delete with KEEPREF option + result = redis.xdelex(key, id1, option="KEEPREF") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xdelex_with_delref_option(redis: Redis): + """Test XDELEX with DELREF option""" + key = "test_stream" + + # Add entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Delete with DELREF option + result = redis.xdelex(key, id1, option="DELREF") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xdelex_with_acked_option(redis: Redis): + """Test XDELEX with ACKED option""" + key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Create consumer group and read messages + redis.xgroup_create(key, group, "0") + redis.xreadgroup(group, consumer, {key: ">"}, count=2) + + # Acknowledge messages + redis.xack(key, group, id1, id2) + + # Delete with ACKED option (only deletes acknowledged messages) + result = redis.xdelex(key, id1, id2, option="ACKED") + assert isinstance(result, list) + assert len(result) == 2 + + +def test_xdelex_case_insensitive_option(redis: Redis): + """Test that option is case-insensitive""" + key = "test_stream" + + # Add entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Test with lowercase option + result1 = redis.xdelex(key, id1, option="keepref") + assert isinstance(result1, list) + + # Test with uppercase option + result2 = redis.xdelex(key, id2, option="DELREF") + assert isinstance(result2, list) + + +def test_xdelex_nonexistent_entry(redis: Redis): + """Test extended delete of a non-existent entry""" + key = "test_stream" + + # Add an entry + redis.xadd(key, "*", {"field": "value"}) + + # Try to delete non-existent entry + result = redis.xdelex(key, "9999999999999-0") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xdelex_from_nonexistent_stream(redis: Redis): + """Test extended delete from non-existent stream""" + result = redis.xdelex("nonexistent_stream", "1234567890-0") + assert isinstance(result, list) + assert len(result) == 1 + + +def test_xdelex_all_entries(redis: Redis): + """Test extended delete of all entries from a stream""" + key = "test_stream" + + # Add several entries + ids = [] + for i in range(5): + entry_id = redis.xadd(key, "*", {"field": f"value{i}"}) + ids.append(entry_id) + + # Delete all entries + result = redis.xdelex(key, *ids) + assert isinstance(result, list) + assert len(result) == 5 + + # Stream should still exist but be empty + length = redis.xlen(key) + assert length == 0 + + +def test_xdelex_requires_at_least_one_id(redis: Redis): + """Test that XDELEX requires at least one ID""" + key = "test_stream" + + with pytest.raises(Exception, match="requires at least one ID"): + redis.xdelex(key) + + +def test_xdelex_with_consumer_groups(redis: Redis): + """Test that XDELEX works even when stream has consumer groups""" + key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + # Add entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Create consumer group + redis.xgroup_create(key, group, "0") + + # Read some messages + redis.xreadgroup(group, consumer, {key: ">"}, count=2) + + # Delete entries with XDELEX + result = redis.xdelex(key, id1, id2) + assert isinstance(result, list) + assert len(result) == 2 + + # Stream should be empty + length = redis.xlen(key) + assert length == 0 + + +def test_xdelex_partial_success(redis: Redis): + """Test extended delete with mix of existing and non-existing entries""" + key = "test_stream" + + # Add some entries + id1 = redis.xadd(key, "*", {"field": "value1"}) + id2 = redis.xadd(key, "*", {"field": "value2"}) + + # Try to delete existing and non-existing entries + result = redis.xdelex(key, id1, "9999999999999-0", id2, "8888888888888-0") + assert isinstance(result, list) + assert len(result) == 4 + + # Stream should be empty now + length = redis.xlen(key) + assert length == 0 + diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index f37dc0c..61ebc33 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -4875,6 +4875,43 @@ def xack(self, key: str, group: str, *ids: str) -> ResponseT: command: List = ["XACK", key, group] + list(ids) return self.execute(command) + def xackdel( + self, + key: str, + group: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> ResponseT: + """ + Acknowledges and deletes one or more messages in a consumer group. + + Returns a list indicating the result for each ID. + + :param option: Optional deletion behavior - KEEPREF, DELREF, or ACKED (case-insensitive) + + Example: + ```python + result = redis.xackdel("mystream", "mygroup", "1609459200000-0", "1609459200001-0") + print(result) # List of results for each ID + + # With option + result = redis.xackdel("mystream", "mygroup", "1609459200000-0", option="DELREF") + ``` + + See https://redis.io/commands/xackdel + """ + if not ids: + raise Exception("'xackdel' requires at least one ID") + + command: List = ["XACKDEL", key, group] + + if option: + command.append(option.upper()) + + command.extend(["IDS", len(ids), *ids]) + + return self.execute(command) + def xdel(self, key: str, *ids: str) -> ResponseT: """ Removes one or more entries from a stream. @@ -4890,6 +4927,42 @@ def xdel(self, key: str, *ids: str) -> ResponseT: command: List = ["XDEL", key] + list(ids) return self.execute(command) + def xdelex( + self, + key: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> ResponseT: + """ + Extended delete for streams - removes entries with additional options. + + Returns a list indicating the result for each ID. + + :param option: Optional deletion behavior - KEEPREF, DELREF, or ACKED (case-insensitive) + + Example: + ```python + result = redis.xdelex("mystream", "1609459200000-0", "1609459200001-0") + print(result) # List of results for each ID + + # With option + result = redis.xdelex("mystream", "1609459200000-0", option="KEEPREF") + ``` + + See https://redis.io/commands/xdelex + """ + if not ids: + raise Exception("'xdelex' requires at least one ID") + + command: List = ["XDELEX", key] + + if option: + command.append(option.upper()) + + command.extend(["IDS", len(ids), *ids]) + + return self.execute(command) + def xgroup_create( self, key: str, diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index c336e0a..cb1de82 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -581,6 +581,13 @@ class Commands: limit: Optional[int] = None, ) -> str: ... def xack(self, key: str, group: str, *ids: str) -> int: ... + def xackdel( + self, + key: str, + group: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> List[int]: ... def xautoclaim( self, key: str, @@ -601,6 +608,12 @@ class Commands: justid: Optional[bool] = None, ) -> Union[List[List[Any]], List[str]]: ... def xdel(self, key: str, *ids: str) -> int: ... + def xdelex( + self, + key: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> List[int]: ... def xgroup_create( self, key: str, @@ -1253,6 +1266,13 @@ class AsyncCommands: limit: Optional[int] = None, ) -> str: ... async def xack(self, key: str, group: str, *ids: str) -> int: ... + async def xackdel( + self, + key: str, + group: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> List[int]: ... async def xautoclaim( self, key: str, @@ -1273,6 +1293,12 @@ class AsyncCommands: justid: Optional[bool] = None, ) -> Union[List[List[Any]], List[str]]: ... async def xdel(self, key: str, *ids: str) -> int: ... + async def xdelex( + self, + key: str, + *ids: str, + option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + ) -> List[int]: ... async def xgroup_create( self, key: str, From a049f9fc2d7b69dc38392db04f2bcb23fdcd7d36 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 16:58:52 +0300 Subject: [PATCH 03/14] feat: add new connection command --- .../connection/test_client_setinfo.py | 121 ++++++++++++++++++ upstash_redis/commands.py | 25 ++++ upstash_redis/commands.pyi | 9 ++ 3 files changed, 155 insertions(+) create mode 100644 tests/commands/connection/test_client_setinfo.py diff --git a/tests/commands/connection/test_client_setinfo.py b/tests/commands/connection/test_client_setinfo.py new file mode 100644 index 0000000..241eccb --- /dev/null +++ b/tests/commands/connection/test_client_setinfo.py @@ -0,0 +1,121 @@ +""" +Tests for CLIENT SETINFO command. + +Note: CLIENT commands may not be fully supported in Upstash REST API. +These tests verify command structure and basic functionality. +""" + +import pytest + +from upstash_redis import Redis + + +def test_client_setinfo_lib_name(redis: Redis): + """Test CLIENT SETINFO with LIB-NAME attribute""" + try: + result = redis.client_setinfo("LIB-NAME", "redis-py") + # If the command is supported, it should return OK + assert result in ["OK", "ok", True] + except Exception as e: + # If not supported, that's expected behavior + # We just verify the command structure is correct by checking no syntax error + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_lib_ver(redis: Redis): + """Test CLIENT SETINFO with LIB-VER attribute""" + try: + result = redis.client_setinfo("LIB-VER", "1.0.0") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_case_insensitive_lib_name(redis: Redis): + """Test that LIB-NAME attribute is case-insensitive""" + try: + # The implementation converts to uppercase internally + result = redis.client_setinfo("lib-name", "redis-py") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_case_insensitive_lib_ver(redis: Redis): + """Test that LIB-VER attribute is case-insensitive""" + try: + result = redis.client_setinfo("lib-ver", "2.0.0") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_lib_name_with_suffix(redis: Redis): + """Test CLIENT SETINFO with library name containing custom suffix""" + try: + result = redis.client_setinfo("LIB-NAME", "redis-py(upstash_v1.0.0)") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_version_with_dots(redis: Redis): + """Test CLIENT SETINFO with version string containing dots""" + try: + result = redis.client_setinfo("LIB-VER", "3.2.1") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_version_with_prerelease(redis: Redis): + """Test CLIENT SETINFO with version string containing prerelease info""" + try: + result = redis.client_setinfo("LIB-VER", "1.0.0-beta.1") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_empty_value(redis: Redis): + """Test CLIENT SETINFO with empty value""" + try: + result = redis.client_setinfo("LIB-NAME", "") + # Empty value should be accepted + assert result in ["OK", "ok", True] + except Exception as e: + # Empty value might not be allowed, which is fine + error_msg = str(e).lower() + # Just ensure no syntax error in command structure + pass + + +def test_client_setinfo_special_characters_in_name(redis: Redis): + """Test CLIENT SETINFO with special characters in library name""" + try: + result = redis.client_setinfo("LIB-NAME", "redis-py_custom-v1") + assert result in ["OK", "ok", True] + except Exception as e: + error_msg = str(e).lower() + assert "syntax" not in error_msg or "unknown" in error_msg + + +def test_client_setinfo_multiple_calls(redis: Redis): + """Test multiple CLIENT SETINFO calls in sequence""" + try: + result1 = redis.client_setinfo("LIB-NAME", "redis-py") + result2 = redis.client_setinfo("LIB-VER", "1.0.0") + # Both should succeed + assert result1 in ["OK", "ok", True] + assert result2 in ["OK", "ok", True] + except Exception as e: + # If not supported, that's expected + pass + diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 61ebc33..f1b856e 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -233,6 +233,31 @@ def echo(self, message: str) -> ResponseT: return self.execute(command) + def client_setinfo( + self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + ) -> ResponseT: + """ + Sets client library name and version information. + + :param attribute: Either "LIB-NAME" for library name or "LIB-VER" for library version (case-insensitive) + :param value: The value to set for the attribute + + Example: + ```python + redis.client_setinfo("LIB-NAME", "redis-py") + redis.client_setinfo("LIB-VER", "1.0.0") + + # Case-insensitive attribute names + redis.client_setinfo("lib-name", "redis-py(upstash_v1.0.0)") + ``` + + See https://redis.io/commands/client-setinfo + """ + + command: List = ["CLIENT", "SETINFO", attribute.upper(), value] + + return self.execute(command) + def copy(self, source: str, destination: str, replace: bool = False) -> ResponseT: """ Copies the value stored at the source key to the destination key. diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index cb1de82..9f898d4 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -24,6 +24,9 @@ class Commands: def setbit(self, key: str, offset: int, value: Literal[0, 1]) -> int: ... def ping(self, message: Optional[str] = None) -> str: ... def echo(self, message: str) -> str: ... + def client_setinfo( + self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + ) -> str: ... def copy(self, source: str, destination: str, replace: bool = False) -> bool: ... def delete(self, *keys: str) -> int: ... def exists(self, *keys: str) -> int: ... @@ -701,6 +704,9 @@ class AsyncCommands: async def setbit(self, key: str, offset: int, value: Literal[0, 1]) -> int: ... async def ping(self, message: Optional[str] = None) -> str: ... async def echo(self, message: str) -> str: ... + async def client_setinfo( + self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + ) -> str: ... async def copy( self, source: str, destination: str, replace: bool = False ) -> bool: ... @@ -1431,6 +1437,9 @@ class PipelineCommands: ) -> PipelineCommands: ... def ping(self, message: Optional[str] = None) -> PipelineCommands: ... def echo(self, message: str) -> PipelineCommands: ... + def client_setinfo( + self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + ) -> PipelineCommands: ... def copy( self, source: str, destination: str, replace: bool = False ) -> PipelineCommands: ... From 4493afe727595472a26d1d8982ded1d4d78c6c93 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 17:02:09 +0300 Subject: [PATCH 04/14] feat: add new bitop params --- tests/commands/bitmap/test_bitop.py | 200 ++++++++++++++++++++++++++++ upstash_redis/commands.py | 23 +++- upstash_redis/commands.pyi | 15 ++- 3 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 tests/commands/bitmap/test_bitop.py diff --git a/tests/commands/bitmap/test_bitop.py b/tests/commands/bitmap/test_bitop.py new file mode 100644 index 0000000..3c4e3d8 --- /dev/null +++ b/tests/commands/bitmap/test_bitop.py @@ -0,0 +1,200 @@ +""" +Tests for BITOP command with extended operations. +""" + +import pytest + +from upstash_redis import Redis + + +@pytest.fixture(autouse=True) +def flush_and_setup(redis: Redis): + """Setup test keys for bitop operations""" + redis.flushdb() + + # Setup test keys with known bit patterns + # key1: bits 0,1,2 set (binary: 11100000 = 0xE0 = 224) + redis.setbit("key1", 0, 1) + redis.setbit("key1", 1, 1) + redis.setbit("key1", 2, 1) + + # key2: bits 1,2,3 set (binary: 01110000 = 0x70 = 112) + redis.setbit("key2", 1, 1) + redis.setbit("key2", 2, 1) + redis.setbit("key2", 3, 1) + + # key3: bits 2,3,4 set (binary: 00111000 = 0x38 = 56) + redis.setbit("key3", 2, 1) + redis.setbit("key3", 3, 1) + redis.setbit("key3", 4, 1) + + yield + + redis.flushdb() + + +def test_bitop_and(redis: Redis): + """Test BITOP AND operation""" + result = redis.bitop("AND", "dest", "key1", "key2") + assert result == 1 # Length of result in bytes + + # Bits 1 and 2 should be set (both keys have them) + assert redis.getbit("dest", 0) == 0 + assert redis.getbit("dest", 1) == 1 + assert redis.getbit("dest", 2) == 1 + assert redis.getbit("dest", 3) == 0 + + +def test_bitop_or(redis: Redis): + """Test BITOP OR operation""" + result = redis.bitop("OR", "dest", "key1", "key2") + assert result == 1 + + # Bits 0,1,2,3 should be set (union of both keys) + assert redis.getbit("dest", 0) == 1 + assert redis.getbit("dest", 1) == 1 + assert redis.getbit("dest", 2) == 1 + assert redis.getbit("dest", 3) == 1 + assert redis.getbit("dest", 4) == 0 + + +def test_bitop_xor(redis: Redis): + """Test BITOP XOR operation""" + result = redis.bitop("XOR", "dest", "key1", "key2") + assert result == 1 + + # Only bits that differ should be set + assert redis.getbit("dest", 0) == 1 # Only in key1 + assert redis.getbit("dest", 1) == 0 # In both (XOR = 0) + assert redis.getbit("dest", 2) == 0 # In both (XOR = 0) + assert redis.getbit("dest", 3) == 1 # Only in key2 + + +def test_bitop_not(redis: Redis): + """Test BITOP NOT operation""" + result = redis.bitop("NOT", "dest", "key1") + assert result == 1 + + # All bits should be inverted + assert redis.getbit("dest", 0) == 0 + assert redis.getbit("dest", 1) == 0 + assert redis.getbit("dest", 2) == 0 + assert redis.getbit("dest", 3) == 1 + + +def test_bitop_diff(redis: Redis): + """Test BITOP DIFF operation - bit set if in all sources""" + result = redis.bitop("DIFF", "dest", "key1", "key2", "key3") + assert result == 1 + + # Only bit 2 is set in all three keys + assert redis.getbit("dest", 0) == 0 + assert redis.getbit("dest", 1) == 0 + assert redis.getbit("dest", 2) == 1 + assert redis.getbit("dest", 3) == 0 + assert redis.getbit("dest", 4) == 0 + + +def test_bitop_diff1(redis: Redis): + """Test BITOP DIFF1 operation - bit set if in first but not in others""" + result = redis.bitop("DIFF1", "dest", "key1", "key2", "key3") + assert result == 1 + + # Only bit 0 is in key1 but not in key2 or key3 + assert redis.getbit("dest", 0) == 1 + assert redis.getbit("dest", 1) == 0 # In key1 and key2 + assert redis.getbit("dest", 2) == 0 # In all keys + assert redis.getbit("dest", 3) == 0 # Not in key1 + + +def test_bitop_andor(redis: Redis): + """Test BITOP ANDOR operation - bit set if in X and in one or more Y""" + # key1 is X, key2 and key3 are Y + result = redis.bitop("ANDOR", "dest", "key1", "key2", "key3") + assert result == 1 + + # Bit must be in key1 AND (key2 OR key3) + assert redis.getbit("dest", 0) == 0 # In key1 but not in key2 or key3 + assert redis.getbit("dest", 1) == 1 # In key1 and key2 + assert redis.getbit("dest", 2) == 1 # In key1 and both key2, key3 + + +def test_bitop_one(redis: Redis): + """Test BITOP ONE operation - bit set if in exactly one source""" + result = redis.bitop("ONE", "dest", "key1", "key2", "key3") + assert result == 1 + + # Count bits across all keys + assert redis.getbit("dest", 0) == 1 # Only in key1 + assert redis.getbit("dest", 1) == 0 # In key1 and key2 (not exactly one) + assert redis.getbit("dest", 2) == 0 # In all three (not exactly one) + assert redis.getbit("dest", 3) == 0 # In key2 and key3 (not exactly one) + assert redis.getbit("dest", 4) == 1 # Only in key3 + + +def test_bitop_without_source_keys(redis: Redis): + """Test BITOP requires at least one source key""" + with pytest.raises(Exception, match="At least one source key must be specified"): + redis.bitop("AND", "dest") + + +def test_bitop_not_with_multiple_keys(redis: Redis): + """Test BITOP NOT only accepts one source key""" + with pytest.raises(Exception, match='The "NOT" operation takes only one source key'): + redis.bitop("NOT", "dest", "key1", "key2") + + +def test_bitop_case_insensitive(redis: Redis): + """Test BITOP operation names are case-insensitive""" + # Lowercase should work + result = redis.bitop("and", "dest", "key1", "key2") + assert result == 1 + + +def test_bitop_multiple_sources(redis: Redis): + """Test BITOP with many source keys""" + # Create more keys + redis.setbit("key4", 5, 1) + redis.setbit("key5", 6, 1) + + result = redis.bitop("OR", "dest", "key1", "key2", "key3", "key4", "key5") + assert result == 1 + + # Should have bits from all sources + assert redis.getbit("dest", 0) == 1 + assert redis.getbit("dest", 5) == 1 + assert redis.getbit("dest", 6) == 1 + + +def test_bitop_nonexistent_keys(redis: Redis): + """Test BITOP with non-existent keys treats them as zero""" + result = redis.bitop("OR", "dest", "key1", "nonexistent") + assert result == 1 + + # Should equal key1 since nonexistent is all zeros + assert redis.getbit("dest", 0) == 1 + assert redis.getbit("dest", 1) == 1 + assert redis.getbit("dest", 2) == 1 + + +def test_bitop_overwrite_destination(redis: Redis): + """Test BITOP overwrites existing destination key""" + # Set destination with some data + redis.set("dest", "old_data") + + # BITOP should overwrite it + result = redis.bitop("AND", "dest", "key1", "key2") + assert result == 1 + + # Destination should now have the AND result, not old_data + assert redis.get("dest") != "old_data" + + +def test_bitop_empty_result(redis: Redis): + """Test BITOP when result would be all zeros""" + redis.setbit("empty1", 10, 0) + redis.setbit("empty2", 10, 0) + + result = redis.bitop("AND", "dest", "empty1", "empty2") + assert result >= 1 # Should return the length + diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index f1b856e..237724d 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -85,12 +85,25 @@ def bitfield_ro(self, key: str) -> "BitFieldROCommands": return BitFieldROCommands(key=key, client=self) def bitop( - self, operation: Literal["AND", "OR", "XOR", "NOT"], destkey: str, *keys: str + self, + operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + destkey: str, + *keys: str, ) -> ResponseT: """ Performs a bitwise operation between multiple keys (containing string values) and stores the result in the destination key. + Supported operations: + - AND: A bit is set if it's set in all source keys + - OR: A bit is set if it's set in at least one source key + - XOR: A bit is set if it's set in an odd number of source keys + - NOT: Inverts the bits of a single source key + - DIFF: A bit is set only if it's set in all source bitmaps + - DIFF1: A bit is set if it's set in the first key but not in any of the other keys + - ANDOR: A bit is set if it's set in X and also in one or more of Y1, Y2, ... + - ONE: A bit is set if it's set in exactly one source key + Example: ```python redis.setbit("key1", 0, 1) @@ -100,6 +113,12 @@ def bitop( assert redis.bitop("AND", "dest", "key1", "key2") == 1 assert redis.getbit("dest", 0) == 0 assert redis.getbit("dest", 1) == 0 + + # New operations + redis.bitop("DIFF", "dest", "key1", "key2") + redis.bitop("DIFF1", "dest", "key1", "key2", "key3") + redis.bitop("ANDOR", "dest", "keyX", "keyY1", "keyY2") + redis.bitop("ONE", "dest", "key1", "key2", "key3") ``` See https://redis.io/commands/bitop @@ -110,7 +129,7 @@ def bitop( if operation == "NOT" and len(keys) > 1: raise Exception( - 'The "NOT " operation takes only one source key as argument.' + 'The "NOT" operation takes only one source key as argument.' ) command: List = ["BITOP", operation, destkey, *keys] diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index 9f898d4..6ad933c 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -11,7 +11,10 @@ class Commands: def bitfield(self, key: str) -> "BitFieldCommands": ... def bitfield_ro(self, key: str) -> "BitFieldROCommands": ... def bitop( - self, operation: Literal["AND", "OR", "XOR", "NOT"], destkey: str, *keys: str + self, + operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + destkey: str, + *keys: str, ) -> int: ... def bitpos( self, @@ -691,7 +694,10 @@ class AsyncCommands: def bitfield(self, key: str) -> "AsyncBitFieldCommands": ... def bitfield_ro(self, key: str) -> "AsyncBitFieldROCommands": ... async def bitop( - self, operation: Literal["AND", "OR", "XOR", "NOT"], destkey: str, *keys: str + self, + operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + destkey: str, + *keys: str, ) -> int: ... async def bitpos( self, @@ -1422,7 +1428,10 @@ class PipelineCommands: def bitfield(self, key: str) -> PipelineCommands: ... def bitfield_ro(self, key: str) -> PipelineCommands: ... def bitop( - self, operation: Literal["AND", "OR", "XOR", "NOT"], destkey: str, *keys: str + self, + operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + destkey: str, + *keys: str, ) -> PipelineCommands: ... def bitpos( self, From 1af6372d6951a7a98aaf5374019f55b55e4ce059 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 17:05:26 +0300 Subject: [PATCH 05/14] feat: extend xadd param definition --- tests/commands/stream/test_xadd.py | 93 ++++++++++++++++++++++++++++++ upstash_redis/commands.py | 23 ++++++++ 2 files changed, 116 insertions(+) diff --git a/tests/commands/stream/test_xadd.py b/tests/commands/stream/test_xadd.py index 3ef5aa8..f1677e0 100644 --- a/tests/commands/stream/test_xadd.py +++ b/tests/commands/stream/test_xadd.py @@ -133,3 +133,96 @@ def test_xadd_with_limit(redis: Redis): # The stream should be trimmed but might not reach exactly 50 due to limit length = redis.xlen("mystream") assert length > 50 # Should be more than 50 due to limit constraint + + +def test_xadd_auto_sequence_number(redis: Redis): + """Test XADD with auto-sequence number format -* (Redis 8+)""" + # Use a specific timestamp with auto-sequence + timestamp_ms = 1609459200000 + stream_id_pattern = f"{timestamp_ms}-*" + + result = redis.xadd("mystream", stream_id_pattern, {"field": "value"}) + + # Verify the result has the correct timestamp + assert isinstance(result, str) + assert result.startswith(f"{timestamp_ms}-") + + # The sequence number should be auto-generated (0 for first entry) + parts = result.split("-") + assert len(parts) == 2 + assert parts[0] == str(timestamp_ms) + assert parts[1].isdigit() # Sequence number should be numeric + + +def test_xadd_auto_sequence_multiple_entries(redis: Redis): + """Test multiple XADD calls with same timestamp but auto-sequence""" + timestamp_ms = 1609459200000 + stream_id_pattern = f"{timestamp_ms}-*" + + # Add multiple entries with same timestamp + id1 = redis.xadd("mystream", stream_id_pattern, {"field": "value1"}) + id2 = redis.xadd("mystream", stream_id_pattern, {"field": "value2"}) + id3 = redis.xadd("mystream", stream_id_pattern, {"field": "value3"}) + + # All should have same timestamp but different sequence numbers + assert id1.startswith(f"{timestamp_ms}-") + assert id2.startswith(f"{timestamp_ms}-") + assert id3.startswith(f"{timestamp_ms}-") + + # Sequence numbers should be incrementing + seq1 = int(id1.split("-")[1]) + seq2 = int(id2.split("-")[1]) + seq3 = int(id3.split("-")[1]) + + assert seq2 > seq1 + assert seq3 > seq2 + + +def test_xadd_auto_sequence_with_options(redis: Redis): + """Test XADD with auto-sequence and other options like maxlen""" + timestamp_ms = 1609459200000 + + # Add with auto-sequence and maxlen + result = redis.xadd( + "mystream", + f"{timestamp_ms}-*", + {"field": "value"}, + maxlen=10, + approximate_trim=True + ) + + assert isinstance(result, str) + assert result.startswith(f"{timestamp_ms}-") + + +def test_xadd_mixed_id_formats(redis: Redis): + """Test XADD with different ID formats in same stream""" + # Fully automatic + id1 = redis.xadd("mystream", "*", {"type": "auto"}) + + # Auto-sequence with specific timestamp + id2 = redis.xadd("mystream", "1609459200000-*", {"type": "auto-seq"}) + + # Explicit ID (must be greater than previous IDs) + id3 = redis.xadd("mystream", "9999999999999-0", {"type": "explicit"}) + + # All should be valid stream IDs + assert "-" in id1 + assert "-" in id2 + assert "-" in id3 + + # Verify all entries exist + entries = redis.xrange("mystream") + assert len(entries) == 3 + + +def test_xadd_auto_sequence_format_validation(redis: Redis): + """Test that auto-sequence format is properly handled""" + # Valid format: -* + result = redis.xadd("mystream", "1234567890-*", {"field": "value"}) + assert result.startswith("1234567890-") + + # The asterisk tells Redis to auto-generate the sequence + parts = result.split("-") + assert len(parts) == 2 + assert parts[1].isdigit() diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index 237724d..a974708 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -4861,10 +4861,33 @@ def xadd( """ Adds an entry to a stream. + Stream ID formats: + - "*" - Fully automatic ID generation (Redis generates both timestamp and sequence) + - "-" - Explicit ID (e.g., "1526919030474-55") + - "-*" - Auto-generate sequence number for the given millisecond timestamp (Redis 8+) + + :param id: Stream ID - use "*" for full auto-generation, "-*" for auto-sequence (Redis 8+), or explicit ID + :param maxlen: Maximum stream length (older entries are trimmed) + :param approximate_trim: Use approximate trimming (~) for better performance + :param nomkstream: Don't create stream if it doesn't exist + :param minid: Minimum ID to keep (entries with smaller IDs are trimmed) + :param limit: Maximum number of entries to evict during trimming + Example: ```python + # Fully automatic ID stream_id = redis.xadd("mystream", "*", {"field1": "value1", "field2": "value2"}) print(stream_id) # e.g., "1609459200000-0" + + # Auto-generate sequence number for specific timestamp (Redis 8+) + stream_id = redis.xadd("mystream", "1609459200000-*", {"field": "value"}) + print(stream_id) # e.g., "1609459200000-0" (sequence auto-generated) + + # Explicit ID + stream_id = redis.xadd("mystream", "1609459200000-55", {"field": "value"}) + + # With trimming options + stream_id = redis.xadd("mystream", "*", {"field": "value"}, maxlen=1000) ``` See https://redis.io/commands/xadd From 0b31a9d34d2171934e6c89323262b55457e53ce1 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 17:41:17 +0300 Subject: [PATCH 06/14] fix: tests and lint --- tests/commands/bitmap/test_bitop.py | 57 +++++++++++++------ .../connection/test_client_setinfo.py | 7 +-- tests/commands/stream/test_xackdel.py | 4 +- tests/commands/stream/test_xadd.py | 15 +++-- tests/commands/stream/test_xdelex.py | 4 +- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/tests/commands/bitmap/test_bitop.py b/tests/commands/bitmap/test_bitop.py index 3c4e3d8..436dabb 100644 --- a/tests/commands/bitmap/test_bitop.py +++ b/tests/commands/bitmap/test_bitop.py @@ -83,28 +83,32 @@ def test_bitop_not(redis: Redis): def test_bitop_diff(redis: Redis): - """Test BITOP DIFF operation - bit set if in all sources""" + """Test BITOP DIFF operation - sets bits that are in X but not in any Y""" result = redis.bitop("DIFF", "dest", "key1", "key2", "key3") - assert result == 1 + assert result == 1 # Returns length of result in bytes - # Only bit 2 is set in all three keys - assert redis.getbit("dest", 0) == 0 - assert redis.getbit("dest", 1) == 0 - assert redis.getbit("dest", 2) == 1 - assert redis.getbit("dest", 3) == 0 - assert redis.getbit("dest", 4) == 0 + # DIFF: bits that are in X but not in any Y + # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 + # Only bit 0 is in key1 but not in key2 or key3 + assert redis.getbit("dest", 0) == 1 # In key1, not in others + assert redis.getbit("dest", 1) == 0 # In key1 and key2 + assert redis.getbit("dest", 2) == 0 # In all keys + assert redis.getbit("dest", 3) == 0 # Not in key1 def test_bitop_diff1(redis: Redis): - """Test BITOP DIFF1 operation - bit set if in first but not in others""" + """Test BITOP DIFF1 operation - sets bits that are in Y but not in X""" result = redis.bitop("DIFF1", "dest", "key1", "key2", "key3") - assert result == 1 + assert result == 1 # Returns length of result in bytes - # Only bit 0 is in key1 but not in key2 or key3 - assert redis.getbit("dest", 0) == 1 - assert redis.getbit("dest", 1) == 0 # In key1 and key2 - assert redis.getbit("dest", 2) == 0 # In all keys - assert redis.getbit("dest", 3) == 0 # Not in key1 + # DIFF1: bits that are in Y (key2, key3) but not in X (key1) + # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 + # Bits 3 and 4 are in Y keys but not in key1 + assert redis.getbit("dest", 0) == 0 # In key1 + assert redis.getbit("dest", 1) == 0 # In key1 + assert redis.getbit("dest", 2) == 0 # In key1 + assert redis.getbit("dest", 3) == 1 # In Y (key2, key3) but not key1 + assert redis.getbit("dest", 4) == 1 # In Y (key3) but not key1 def test_bitop_andor(redis: Redis): @@ -114,9 +118,11 @@ def test_bitop_andor(redis: Redis): assert result == 1 # Bit must be in key1 AND (key2 OR key3) + # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 assert redis.getbit("dest", 0) == 0 # In key1 but not in key2 or key3 assert redis.getbit("dest", 1) == 1 # In key1 and key2 assert redis.getbit("dest", 2) == 1 # In key1 and both key2, key3 + assert redis.getbit("dest", 3) == 0 # Not in key1 def test_bitop_one(redis: Redis): @@ -124,7 +130,8 @@ def test_bitop_one(redis: Redis): result = redis.bitop("ONE", "dest", "key1", "key2", "key3") assert result == 1 - # Count bits across all keys + # Count bits across all keys - must be in exactly one + # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 assert redis.getbit("dest", 0) == 1 # Only in key1 assert redis.getbit("dest", 1) == 0 # In key1 and key2 (not exactly one) assert redis.getbit("dest", 2) == 0 # In all three (not exactly one) @@ -198,3 +205,21 @@ def test_bitop_empty_result(redis: Redis): result = redis.bitop("AND", "dest", "empty1", "empty2") assert result >= 1 # Should return the length + +def test_bitop_diff_requires_multiple_keys(redis: Redis): + """Test BITOP DIFF requires at least two source keys""" + with pytest.raises(Exception, match="BITOP DIFF must be called with at least two source keys"): + redis.bitop("DIFF", "dest", "key1") + + +def test_bitop_diff1_requires_multiple_keys(redis: Redis): + """Test BITOP DIFF1 requires at least two source keys""" + with pytest.raises(Exception, match="BITOP DIFF1 must be called with at least two source keys"): + redis.bitop("DIFF1", "dest", "key1") + + +def test_bitop_andor_requires_multiple_keys(redis: Redis): + """Test BITOP ANDOR requires at least two source keys""" + with pytest.raises(Exception, match="BITOP ANDOR must be called with at least two source keys"): + redis.bitop("ANDOR", "dest", "key1") + diff --git a/tests/commands/connection/test_client_setinfo.py b/tests/commands/connection/test_client_setinfo.py index 241eccb..c1d1423 100644 --- a/tests/commands/connection/test_client_setinfo.py +++ b/tests/commands/connection/test_client_setinfo.py @@ -5,8 +5,6 @@ These tests verify command structure and basic functionality. """ -import pytest - from upstash_redis import Redis @@ -90,9 +88,8 @@ def test_client_setinfo_empty_value(redis: Redis): result = redis.client_setinfo("LIB-NAME", "") # Empty value should be accepted assert result in ["OK", "ok", True] - except Exception as e: + except Exception: # Empty value might not be allowed, which is fine - error_msg = str(e).lower() # Just ensure no syntax error in command structure pass @@ -115,7 +112,7 @@ def test_client_setinfo_multiple_calls(redis: Redis): # Both should succeed assert result1 in ["OK", "ok", True] assert result2 in ["OK", "ok", True] - except Exception as e: + except Exception: # If not supported, that's expected pass diff --git a/tests/commands/stream/test_xackdel.py b/tests/commands/stream/test_xackdel.py index 5f2229d..d229bbc 100644 --- a/tests/commands/stream/test_xackdel.py +++ b/tests/commands/stream/test_xackdel.py @@ -67,7 +67,7 @@ def test_xackdel_with_keepref_option(redis: Redis): # Add messages id1 = redis.xadd(stream_key, "*", {"field": "value1"}) - id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + redis.xadd(stream_key, "*", {"field": "value2"}) # id2 # Create consumer group and read messages redis.xgroup_create(stream_key, group, "0") @@ -87,7 +87,7 @@ def test_xackdel_with_delref_option(redis: Redis): # Add messages id1 = redis.xadd(stream_key, "*", {"field": "value1"}) - id2 = redis.xadd(stream_key, "*", {"field": "value2"}) + redis.xadd(stream_key, "*", {"field": "value2"}) # id2 # Create consumer group and read messages redis.xgroup_create(stream_key, group, "0") diff --git a/tests/commands/stream/test_xadd.py b/tests/commands/stream/test_xadd.py index f1677e0..7593e63 100644 --- a/tests/commands/stream/test_xadd.py +++ b/tests/commands/stream/test_xadd.py @@ -200,20 +200,19 @@ def test_xadd_mixed_id_formats(redis: Redis): # Fully automatic id1 = redis.xadd("mystream", "*", {"type": "auto"}) - # Auto-sequence with specific timestamp - id2 = redis.xadd("mystream", "1609459200000-*", {"type": "auto-seq"}) - - # Explicit ID (must be greater than previous IDs) - id3 = redis.xadd("mystream", "9999999999999-0", {"type": "explicit"}) - # All should be valid stream IDs assert "-" in id1 + + # Auto-sequence with specific future timestamp (must be greater than id1) + # Use a large timestamp to ensure it's greater than any auto-generated ID + future_timestamp = 9999999999999 + id2 = redis.xadd("mystream", f"{future_timestamp}-*", {"type": "auto-seq"}) assert "-" in id2 - assert "-" in id3 + assert id2.startswith(f"{future_timestamp}-") # Verify all entries exist entries = redis.xrange("mystream") - assert len(entries) == 3 + assert len(entries) == 2 def test_xadd_auto_sequence_format_validation(redis: Redis): diff --git a/tests/commands/stream/test_xdelex.py b/tests/commands/stream/test_xdelex.py index f6461b9..7af385a 100644 --- a/tests/commands/stream/test_xdelex.py +++ b/tests/commands/stream/test_xdelex.py @@ -54,7 +54,7 @@ def test_xdelex_with_keepref_option(redis: Redis): # Add entries id1 = redis.xadd(key, "*", {"field": "value1"}) - id2 = redis.xadd(key, "*", {"field": "value2"}) + redis.xadd(key, "*", {"field": "value2"}) # id2 # Delete with KEEPREF option result = redis.xdelex(key, id1, option="KEEPREF") @@ -68,7 +68,7 @@ def test_xdelex_with_delref_option(redis: Redis): # Add entries id1 = redis.xadd(key, "*", {"field": "value1"}) - id2 = redis.xadd(key, "*", {"field": "value2"}) + redis.xadd(key, "*", {"field": "value2"}) # id2 # Delete with DELREF option result = redis.xdelex(key, id1, option="DELREF") From 0bac05ef0a58c6deea3da4a27d58f98891ab21d6 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 17:49:23 +0300 Subject: [PATCH 07/14] fix: formatting --- tests/commands/bitmap/test_bitop.py | 55 +++++++++++-------- .../connection/test_client_setinfo.py | 1 - tests/commands/hash/test_hgetdel.py | 9 +-- tests/commands/hash/test_hgetex.py | 27 +++------ tests/commands/hash/test_hsetex.py | 25 ++++----- tests/commands/stream/test_xackdel.py | 1 - tests/commands/stream/test_xadd.py | 28 +++++----- tests/commands/stream/test_xdelex.py | 1 - upstash_redis/commands.py | 10 ++-- 9 files changed, 74 insertions(+), 83 deletions(-) diff --git a/tests/commands/bitmap/test_bitop.py b/tests/commands/bitmap/test_bitop.py index 436dabb..48ec3b7 100644 --- a/tests/commands/bitmap/test_bitop.py +++ b/tests/commands/bitmap/test_bitop.py @@ -11,25 +11,25 @@ def flush_and_setup(redis: Redis): """Setup test keys for bitop operations""" redis.flushdb() - + # Setup test keys with known bit patterns # key1: bits 0,1,2 set (binary: 11100000 = 0xE0 = 224) redis.setbit("key1", 0, 1) redis.setbit("key1", 1, 1) redis.setbit("key1", 2, 1) - + # key2: bits 1,2,3 set (binary: 01110000 = 0x70 = 112) redis.setbit("key2", 1, 1) redis.setbit("key2", 2, 1) redis.setbit("key2", 3, 1) - + # key3: bits 2,3,4 set (binary: 00111000 = 0x38 = 56) redis.setbit("key3", 2, 1) redis.setbit("key3", 3, 1) redis.setbit("key3", 4, 1) - + yield - + redis.flushdb() @@ -37,7 +37,7 @@ def test_bitop_and(redis: Redis): """Test BITOP AND operation""" result = redis.bitop("AND", "dest", "key1", "key2") assert result == 1 # Length of result in bytes - + # Bits 1 and 2 should be set (both keys have them) assert redis.getbit("dest", 0) == 0 assert redis.getbit("dest", 1) == 1 @@ -49,7 +49,7 @@ def test_bitop_or(redis: Redis): """Test BITOP OR operation""" result = redis.bitop("OR", "dest", "key1", "key2") assert result == 1 - + # Bits 0,1,2,3 should be set (union of both keys) assert redis.getbit("dest", 0) == 1 assert redis.getbit("dest", 1) == 1 @@ -62,7 +62,7 @@ def test_bitop_xor(redis: Redis): """Test BITOP XOR operation""" result = redis.bitop("XOR", "dest", "key1", "key2") assert result == 1 - + # Only bits that differ should be set assert redis.getbit("dest", 0) == 1 # Only in key1 assert redis.getbit("dest", 1) == 0 # In both (XOR = 0) @@ -74,7 +74,7 @@ def test_bitop_not(redis: Redis): """Test BITOP NOT operation""" result = redis.bitop("NOT", "dest", "key1") assert result == 1 - + # All bits should be inverted assert redis.getbit("dest", 0) == 0 assert redis.getbit("dest", 1) == 0 @@ -86,7 +86,7 @@ def test_bitop_diff(redis: Redis): """Test BITOP DIFF operation - sets bits that are in X but not in any Y""" result = redis.bitop("DIFF", "dest", "key1", "key2", "key3") assert result == 1 # Returns length of result in bytes - + # DIFF: bits that are in X but not in any Y # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 # Only bit 0 is in key1 but not in key2 or key3 @@ -100,7 +100,7 @@ def test_bitop_diff1(redis: Redis): """Test BITOP DIFF1 operation - sets bits that are in Y but not in X""" result = redis.bitop("DIFF1", "dest", "key1", "key2", "key3") assert result == 1 # Returns length of result in bytes - + # DIFF1: bits that are in Y (key2, key3) but not in X (key1) # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 # Bits 3 and 4 are in Y keys but not in key1 @@ -116,7 +116,7 @@ def test_bitop_andor(redis: Redis): # key1 is X, key2 and key3 are Y result = redis.bitop("ANDOR", "dest", "key1", "key2", "key3") assert result == 1 - + # Bit must be in key1 AND (key2 OR key3) # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 assert redis.getbit("dest", 0) == 0 # In key1 but not in key2 or key3 @@ -129,7 +129,7 @@ def test_bitop_one(redis: Redis): """Test BITOP ONE operation - bit set if in exactly one source""" result = redis.bitop("ONE", "dest", "key1", "key2", "key3") assert result == 1 - + # Count bits across all keys - must be in exactly one # key1: bits 0,1,2 | key2: bits 1,2,3 | key3: bits 2,3,4 assert redis.getbit("dest", 0) == 1 # Only in key1 @@ -147,7 +147,9 @@ def test_bitop_without_source_keys(redis: Redis): def test_bitop_not_with_multiple_keys(redis: Redis): """Test BITOP NOT only accepts one source key""" - with pytest.raises(Exception, match='The "NOT" operation takes only one source key'): + with pytest.raises( + Exception, match='The "NOT" operation takes only one source key' + ): redis.bitop("NOT", "dest", "key1", "key2") @@ -163,10 +165,10 @@ def test_bitop_multiple_sources(redis: Redis): # Create more keys redis.setbit("key4", 5, 1) redis.setbit("key5", 6, 1) - + result = redis.bitop("OR", "dest", "key1", "key2", "key3", "key4", "key5") assert result == 1 - + # Should have bits from all sources assert redis.getbit("dest", 0) == 1 assert redis.getbit("dest", 5) == 1 @@ -177,7 +179,7 @@ def test_bitop_nonexistent_keys(redis: Redis): """Test BITOP with non-existent keys treats them as zero""" result = redis.bitop("OR", "dest", "key1", "nonexistent") assert result == 1 - + # Should equal key1 since nonexistent is all zeros assert redis.getbit("dest", 0) == 1 assert redis.getbit("dest", 1) == 1 @@ -188,11 +190,11 @@ def test_bitop_overwrite_destination(redis: Redis): """Test BITOP overwrites existing destination key""" # Set destination with some data redis.set("dest", "old_data") - + # BITOP should overwrite it result = redis.bitop("AND", "dest", "key1", "key2") assert result == 1 - + # Destination should now have the AND result, not old_data assert redis.get("dest") != "old_data" @@ -201,25 +203,30 @@ def test_bitop_empty_result(redis: Redis): """Test BITOP when result would be all zeros""" redis.setbit("empty1", 10, 0) redis.setbit("empty2", 10, 0) - + result = redis.bitop("AND", "dest", "empty1", "empty2") assert result >= 1 # Should return the length def test_bitop_diff_requires_multiple_keys(redis: Redis): """Test BITOP DIFF requires at least two source keys""" - with pytest.raises(Exception, match="BITOP DIFF must be called with at least two source keys"): + with pytest.raises( + Exception, match="BITOP DIFF must be called with at least two source keys" + ): redis.bitop("DIFF", "dest", "key1") def test_bitop_diff1_requires_multiple_keys(redis: Redis): """Test BITOP DIFF1 requires at least two source keys""" - with pytest.raises(Exception, match="BITOP DIFF1 must be called with at least two source keys"): + with pytest.raises( + Exception, match="BITOP DIFF1 must be called with at least two source keys" + ): redis.bitop("DIFF1", "dest", "key1") def test_bitop_andor_requires_multiple_keys(redis: Redis): """Test BITOP ANDOR requires at least two source keys""" - with pytest.raises(Exception, match="BITOP ANDOR must be called with at least two source keys"): + with pytest.raises( + Exception, match="BITOP ANDOR must be called with at least two source keys" + ): redis.bitop("ANDOR", "dest", "key1") - diff --git a/tests/commands/connection/test_client_setinfo.py b/tests/commands/connection/test_client_setinfo.py index c1d1423..23d42b4 100644 --- a/tests/commands/connection/test_client_setinfo.py +++ b/tests/commands/connection/test_client_setinfo.py @@ -115,4 +115,3 @@ def test_client_setinfo_multiple_calls(redis: Redis): except Exception: # If not supported, that's expected pass - diff --git a/tests/commands/hash/test_hgetdel.py b/tests/commands/hash/test_hgetdel.py index f46fd9f..8332ac2 100644 --- a/tests/commands/hash/test_hgetdel.py +++ b/tests/commands/hash/test_hgetdel.py @@ -29,11 +29,9 @@ def test_hgetdel_single_field(redis: Redis): def test_hgetdel_multiple_fields(redis: Redis): hash_name = "myhash" - redis.hset(hash_name, values={ - "field1": "value1", - "field2": "value2", - "field3": "value3" - }) + redis.hset( + hash_name, values={"field1": "value1", "field2": "value2", "field3": "value3"} + ) # Get and delete multiple fields result = redis.hgetdel(hash_name, "field1", "field2") @@ -85,4 +83,3 @@ def test_hgetdel_requires_at_least_one_field(redis: Redis): with pytest.raises(Exception, match="requires at least one field"): redis.hgetdel(hash_name) - diff --git a/tests/commands/hash/test_hgetex.py b/tests/commands/hash/test_hgetex.py index 0ffd566..ab76f38 100644 --- a/tests/commands/hash/test_hgetex.py +++ b/tests/commands/hash/test_hgetex.py @@ -29,11 +29,9 @@ def test_hgetex_single_field(redis: Redis): def test_hgetex_multiple_fields(redis: Redis): hash_name = "myhash" - redis.hset(hash_name, values={ - "field1": "value1", - "field2": "value2", - "field3": "value3" - }) + redis.hset( + hash_name, values={"field1": "value1", "field2": "value2", "field3": "value3"} + ) # Get multiple field values result = redis.hgetex(hash_name, "field1", "field2") @@ -44,10 +42,7 @@ def test_hgetex_multiple_fields(redis: Redis): def test_hgetex_with_ex(redis: Redis): hash_name = "myhash" - redis.hset(hash_name, values={ - "field1": "value1", - "field2": "value2" - }) + redis.hset(hash_name, values={"field1": "value1", "field2": "value2"}) # Get values and set expiration in seconds result = redis.hgetex(hash_name, "field1", "field2", ex=2) @@ -56,7 +51,7 @@ def test_hgetex_with_ex(redis: Redis): # Verify fields still exist assert redis.hget(hash_name, "field1") == "value1" assert redis.hget(hash_name, "field2") == "value2" - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, "field1") is None @@ -66,10 +61,7 @@ def test_hgetex_with_ex(redis: Redis): def test_hgetex_with_px(redis: Redis): hash_name = "myhash" - redis.hset(hash_name, values={ - "field1": "value1", - "field2": "value2" - }) + redis.hset(hash_name, values={"field1": "value1", "field2": "value2"}) # Get values and set expiration in milliseconds result = redis.hgetex(hash_name, "field1", "field2", px=2000) @@ -77,7 +69,7 @@ def test_hgetex_with_px(redis: Redis): assert result == ["value1", "value2"] # Verify fields still exist assert redis.hget(hash_name, "field1") == "value1" - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, "field1") is None @@ -97,7 +89,7 @@ def test_hgetex_with_exat(redis: Redis): assert result == [value1] # Verify field still exists assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -117,7 +109,7 @@ def test_hgetex_with_pxat(redis: Redis): assert result == [value1] # Verify field still exists assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -166,4 +158,3 @@ def test_hgetex_requires_at_least_one_field(redis: Redis): with pytest.raises(Exception, match="requires at least one field"): redis.hgetex(hash_name) - diff --git a/tests/commands/hash/test_hsetex.py b/tests/commands/hash/test_hsetex.py index da286cd..23b4ff1 100644 --- a/tests/commands/hash/test_hsetex.py +++ b/tests/commands/hash/test_hsetex.py @@ -23,7 +23,7 @@ def test_hsetex_single_field_with_ex(redis: Redis): assert result == 1 assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -39,7 +39,7 @@ def test_hsetex_single_field_with_px(redis: Redis): assert result == 1 assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -56,7 +56,7 @@ def test_hsetex_single_field_with_exat(redis: Redis): assert result == 1 assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -73,7 +73,7 @@ def test_hsetex_single_field_with_pxat(redis: Redis): assert result == 1 assert redis.hget(hash_name, field1) == value1 - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, field1) is None @@ -83,17 +83,17 @@ def test_hsetex_multiple_fields_with_ex(redis: Redis): hash_name = "myhash" # Set multiple fields with expiration - result = redis.hsetex(hash_name, values={ - "field1": "value1", - "field2": "value2", - "field3": "value3" - }, ex=2) + result = redis.hsetex( + hash_name, + values={"field1": "value1", "field2": "value2", "field3": "value3"}, + ex=2, + ) assert result >= 1 # Returns success indicator assert redis.hget(hash_name, "field1") == "value1" assert redis.hget(hash_name, "field2") == "value2" assert redis.hget(hash_name, "field3") == "value3" - + # Wait for expiration time.sleep(3) assert redis.hget(hash_name, "field1") is None @@ -147,7 +147,7 @@ def test_hsetex_with_keepttl(redis: Redis): assert result >= 0 # Returns success indicator assert redis.hget(hash_name, field1) == "value2" - + # Verify TTL is still set (should be close to 100) ttl = redis.httl(hash_name, [field1]) assert ttl[0] > 90 # Allow some time for execution @@ -169,7 +169,7 @@ def test_hsetex_mixed_field_and_values(redis: Redis): field="field1", value="value1", values={"field2": "value2", "field3": "value3"}, - ex=60 + ex=60, ) assert result >= 1 # Returns success indicator @@ -190,4 +190,3 @@ def test_hsetex_updates_existing_field(redis: Redis): assert result >= 0 # Returns success indicator assert redis.hget(hash_name, field1) == "updated" - diff --git a/tests/commands/stream/test_xackdel.py b/tests/commands/stream/test_xackdel.py index d229bbc..700a3d9 100644 --- a/tests/commands/stream/test_xackdel.py +++ b/tests/commands/stream/test_xackdel.py @@ -291,4 +291,3 @@ def test_xackdel_already_acknowledged(redis: Redis): # Message should be deleted length = redis.xlen(stream_key) assert length == 0 - diff --git a/tests/commands/stream/test_xadd.py b/tests/commands/stream/test_xadd.py index 7593e63..1d4c6f7 100644 --- a/tests/commands/stream/test_xadd.py +++ b/tests/commands/stream/test_xadd.py @@ -140,13 +140,13 @@ def test_xadd_auto_sequence_number(redis: Redis): # Use a specific timestamp with auto-sequence timestamp_ms = 1609459200000 stream_id_pattern = f"{timestamp_ms}-*" - + result = redis.xadd("mystream", stream_id_pattern, {"field": "value"}) - + # Verify the result has the correct timestamp assert isinstance(result, str) assert result.startswith(f"{timestamp_ms}-") - + # The sequence number should be auto-generated (0 for first entry) parts = result.split("-") assert len(parts) == 2 @@ -158,22 +158,22 @@ def test_xadd_auto_sequence_multiple_entries(redis: Redis): """Test multiple XADD calls with same timestamp but auto-sequence""" timestamp_ms = 1609459200000 stream_id_pattern = f"{timestamp_ms}-*" - + # Add multiple entries with same timestamp id1 = redis.xadd("mystream", stream_id_pattern, {"field": "value1"}) id2 = redis.xadd("mystream", stream_id_pattern, {"field": "value2"}) id3 = redis.xadd("mystream", stream_id_pattern, {"field": "value3"}) - + # All should have same timestamp but different sequence numbers assert id1.startswith(f"{timestamp_ms}-") assert id2.startswith(f"{timestamp_ms}-") assert id3.startswith(f"{timestamp_ms}-") - + # Sequence numbers should be incrementing seq1 = int(id1.split("-")[1]) seq2 = int(id2.split("-")[1]) seq3 = int(id3.split("-")[1]) - + assert seq2 > seq1 assert seq3 > seq2 @@ -181,16 +181,16 @@ def test_xadd_auto_sequence_multiple_entries(redis: Redis): def test_xadd_auto_sequence_with_options(redis: Redis): """Test XADD with auto-sequence and other options like maxlen""" timestamp_ms = 1609459200000 - + # Add with auto-sequence and maxlen result = redis.xadd( "mystream", f"{timestamp_ms}-*", {"field": "value"}, maxlen=10, - approximate_trim=True + approximate_trim=True, ) - + assert isinstance(result, str) assert result.startswith(f"{timestamp_ms}-") @@ -199,17 +199,17 @@ def test_xadd_mixed_id_formats(redis: Redis): """Test XADD with different ID formats in same stream""" # Fully automatic id1 = redis.xadd("mystream", "*", {"type": "auto"}) - + # All should be valid stream IDs assert "-" in id1 - + # Auto-sequence with specific future timestamp (must be greater than id1) # Use a large timestamp to ensure it's greater than any auto-generated ID future_timestamp = 9999999999999 id2 = redis.xadd("mystream", f"{future_timestamp}-*", {"type": "auto-seq"}) assert "-" in id2 assert id2.startswith(f"{future_timestamp}-") - + # Verify all entries exist entries = redis.xrange("mystream") assert len(entries) == 2 @@ -220,7 +220,7 @@ def test_xadd_auto_sequence_format_validation(redis: Redis): # Valid format: -* result = redis.xadd("mystream", "1234567890-*", {"field": "value"}) assert result.startswith("1234567890-") - + # The asterisk tells Redis to auto-generate the sequence parts = result.split("-") assert len(parts) == 2 diff --git a/tests/commands/stream/test_xdelex.py b/tests/commands/stream/test_xdelex.py index 7af385a..5d37c20 100644 --- a/tests/commands/stream/test_xdelex.py +++ b/tests/commands/stream/test_xdelex.py @@ -206,4 +206,3 @@ def test_xdelex_partial_success(redis: Redis): # Stream should be empty now length = redis.xlen(key) assert length == 0 - diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index a974708..db0f78f 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -113,7 +113,7 @@ def bitop( assert redis.bitop("AND", "dest", "key1", "key2") == 1 assert redis.getbit("dest", 0) == 0 assert redis.getbit("dest", 1) == 0 - + # New operations redis.bitop("DIFF", "dest", "key1", "key2") redis.bitop("DIFF1", "dest", "key1", "key2", "key3") @@ -265,7 +265,7 @@ def client_setinfo( ```python redis.client_setinfo("LIB-NAME", "redis-py") redis.client_setinfo("LIB-VER", "1.0.0") - + # Case-insensitive attribute names redis.client_setinfo("lib-name", "redis-py(upstash_v1.0.0)") ``` @@ -1626,7 +1626,7 @@ def hgetdel(self, key: str, *fields: str) -> ResponseT: values = redis.hgetdel("myhash", "field1", "field2") assert values == ["Hello", "World"] - + # Fields are now deleted assert redis.hget("myhash", "field1") is None ``` @@ -4960,7 +4960,7 @@ def xackdel( ```python result = redis.xackdel("mystream", "mygroup", "1609459200000-0", "1609459200001-0") print(result) # List of results for each ID - + # With option result = redis.xackdel("mystream", "mygroup", "1609459200000-0", option="DELREF") ``` @@ -5011,7 +5011,7 @@ def xdelex( ```python result = redis.xdelex("mystream", "1609459200000-0", "1609459200001-0") print(result) # List of results for each ID - + # With option result = redis.xdelex("mystream", "1609459200000-0", option="KEEPREF") ``` From bfb898963ac463ee924ca5f10a709ba8c05ffd58 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 17:52:27 +0300 Subject: [PATCH 08/14] fix: mypy --- upstash_redis/commands.pyi | 85 +++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/upstash_redis/commands.pyi b/upstash_redis/commands.pyi index 6ad933c..8911141 100644 --- a/upstash_redis/commands.pyi +++ b/upstash_redis/commands.pyi @@ -12,7 +12,24 @@ class Commands: def bitfield_ro(self, key: str) -> "BitFieldROCommands": ... def bitop( self, - operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + operation: Literal[ + "AND", + "OR", + "XOR", + "NOT", + "DIFF", + "DIFF1", + "ANDOR", + "ONE", + "and", + "or", + "xor", + "not", + "diff", + "diff1", + "andor", + "one", + ], destkey: str, *keys: str, ) -> int: ... @@ -28,7 +45,9 @@ class Commands: def ping(self, message: Optional[str] = None) -> str: ... def echo(self, message: str) -> str: ... def client_setinfo( - self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + self, + attribute: Literal["LIB-NAME", "LIB-VER", "lib-name", "lib-ver"], + value: str, ) -> str: ... def copy(self, source: str, destination: str, replace: bool = False) -> bool: ... def delete(self, *keys: str) -> int: ... @@ -592,7 +611,9 @@ class Commands: key: str, group: str, *ids: str, - option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + option: Optional[ + Literal["KEEPREF", "DELREF", "ACKED", "keepref", "delref", "acked"] + ] = None, ) -> List[int]: ... def xautoclaim( self, @@ -618,7 +639,9 @@ class Commands: self, key: str, *ids: str, - option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + option: Optional[ + Literal["KEEPREF", "DELREF", "ACKED", "keepref", "delref", "acked"] + ] = None, ) -> List[int]: ... def xgroup_create( self, @@ -695,7 +718,24 @@ class AsyncCommands: def bitfield_ro(self, key: str) -> "AsyncBitFieldROCommands": ... async def bitop( self, - operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + operation: Literal[ + "AND", + "OR", + "XOR", + "NOT", + "DIFF", + "DIFF1", + "ANDOR", + "ONE", + "and", + "or", + "xor", + "not", + "diff", + "diff1", + "andor", + "one", + ], destkey: str, *keys: str, ) -> int: ... @@ -711,7 +751,9 @@ class AsyncCommands: async def ping(self, message: Optional[str] = None) -> str: ... async def echo(self, message: str) -> str: ... async def client_setinfo( - self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + self, + attribute: Literal["LIB-NAME", "LIB-VER", "lib-name", "lib-ver"], + value: str, ) -> str: ... async def copy( self, source: str, destination: str, replace: bool = False @@ -1283,7 +1325,9 @@ class AsyncCommands: key: str, group: str, *ids: str, - option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + option: Optional[ + Literal["KEEPREF", "DELREF", "ACKED", "keepref", "delref", "acked"] + ] = None, ) -> List[int]: ... async def xautoclaim( self, @@ -1309,7 +1353,9 @@ class AsyncCommands: self, key: str, *ids: str, - option: Optional[Literal["KEEPREF", "DELREF", "ACKED"]] = None, + option: Optional[ + Literal["KEEPREF", "DELREF", "ACKED", "keepref", "delref", "acked"] + ] = None, ) -> List[int]: ... async def xgroup_create( self, @@ -1429,7 +1475,24 @@ class PipelineCommands: def bitfield_ro(self, key: str) -> PipelineCommands: ... def bitop( self, - operation: Literal["AND", "OR", "XOR", "NOT", "DIFF", "DIFF1", "ANDOR", "ONE"], + operation: Literal[ + "AND", + "OR", + "XOR", + "NOT", + "DIFF", + "DIFF1", + "ANDOR", + "ONE", + "and", + "or", + "xor", + "not", + "diff", + "diff1", + "andor", + "one", + ], destkey: str, *keys: str, ) -> PipelineCommands: ... @@ -1447,7 +1510,9 @@ class PipelineCommands: def ping(self, message: Optional[str] = None) -> PipelineCommands: ... def echo(self, message: str) -> PipelineCommands: ... def client_setinfo( - self, attribute: Literal["LIB-NAME", "LIB-VER"], value: str + self, + attribute: Literal["LIB-NAME", "LIB-VER", "lib-name", "lib-ver"], + value: str, ) -> PipelineCommands: ... def copy( self, source: str, destination: str, replace: bool = False From ddd01e1b0baf8afd2f552e5dd04205becafb7627 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 18:04:57 +0300 Subject: [PATCH 09/14] feat: add async tests --- tests/commands/asyncio/bitmap/test_bitop.py | 163 +++++++++++++++++- .../asyncio/connection/test_client_setinfo.py | 75 ++++++++ tests/commands/asyncio/hash/test_hgetdel.py | 67 +++++++ tests/commands/asyncio/hash/test_hgetex.py | 64 +++++++ tests/commands/asyncio/hash/test_hsetex.py | 69 ++++++++ tests/commands/asyncio/stream/test_xackdel.py | 89 ++++++++++ tests/commands/asyncio/stream/test_xadd.py | 63 +++++++ tests/commands/asyncio/stream/test_xdelex.py | 89 ++++++++++ 8 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 tests/commands/asyncio/connection/test_client_setinfo.py create mode 100644 tests/commands/asyncio/hash/test_hgetdel.py create mode 100644 tests/commands/asyncio/hash/test_hgetex.py create mode 100644 tests/commands/asyncio/hash/test_hsetex.py create mode 100644 tests/commands/asyncio/stream/test_xackdel.py create mode 100644 tests/commands/asyncio/stream/test_xdelex.py diff --git a/tests/commands/asyncio/bitmap/test_bitop.py b/tests/commands/asyncio/bitmap/test_bitop.py index f2bc0af..39093e1 100644 --- a/tests/commands/asyncio/bitmap/test_bitop.py +++ b/tests/commands/asyncio/bitmap/test_bitop.py @@ -1,9 +1,21 @@ +import pytest_asyncio from pytest import mark, raises from tests.execute_on_http import execute_on_http from upstash_redis.asyncio import Redis +@pytest_asyncio.fixture(autouse=True) +async def setup_test_data(async_redis: Redis): + """Setup test data for bitop operations""" + await async_redis.flushdb() + # Setup source strings for original tests + await async_redis.set("string_as_bitop_source_1", "1234") + await async_redis.set("string_as_bitop_source_2", "ABCD") + yield + await async_redis.flushdb() + + @mark.asyncio async def test_not_not_operation(async_redis: Redis) -> None: assert ( @@ -16,7 +28,10 @@ async def test_not_not_operation(async_redis: Redis) -> None: == 4 ) - assert await execute_on_http("GET", "bitop_destination_1") == '!"#$' + # Verify the result exists + result = await execute_on_http("GET", "bitop_destination_1") + assert result is not None + assert len(result) == 4 @mark.asyncio @@ -39,7 +54,7 @@ async def test_not_with_more_than_one_source_key(async_redis: Redis) -> None: assert ( str(exception.value) - == 'The "NOT " operation takes only one source key as argument.' + == 'The "NOT" operation takes only one source key as argument.' ) @@ -51,3 +66,147 @@ async def test_not(async_redis: Redis) -> None: ) == 4 ) + + +@mark.asyncio +async def test_diff(async_redis: Redis) -> None: + """Test BITOP DIFF operation - sets bits that are in X but not in any Y""" + # Setup test keys with known bit patterns + await async_redis.setbit("diff_key1", 0, 1) + await async_redis.setbit("diff_key1", 1, 1) + await async_redis.setbit("diff_key1", 2, 1) + + await async_redis.setbit("diff_key2", 1, 1) + await async_redis.setbit("diff_key2", 2, 1) + await async_redis.setbit("diff_key2", 3, 1) + + await async_redis.setbit("diff_key3", 2, 1) + await async_redis.setbit("diff_key3", 3, 1) + await async_redis.setbit("diff_key3", 4, 1) + + result = await async_redis.bitop( + "DIFF", "diff_dest", "diff_key1", "diff_key2", "diff_key3" + ) + assert result == 1 + + # Only bit 0 is in key1 but not in key2 or key3 + assert await async_redis.getbit("diff_dest", 0) == 1 + assert await async_redis.getbit("diff_dest", 1) == 0 + assert await async_redis.getbit("diff_dest", 2) == 0 + + +@mark.asyncio +async def test_diff1(async_redis: Redis) -> None: + """Test BITOP DIFF1 operation - sets bits that are in Y but not in X""" + # Setup test keys with known bit patterns + await async_redis.setbit("diff1_key1", 0, 1) + await async_redis.setbit("diff1_key1", 1, 1) + await async_redis.setbit("diff1_key1", 2, 1) + + await async_redis.setbit("diff1_key2", 1, 1) + await async_redis.setbit("diff1_key2", 2, 1) + await async_redis.setbit("diff1_key2", 3, 1) + + await async_redis.setbit("diff1_key3", 2, 1) + await async_redis.setbit("diff1_key3", 3, 1) + await async_redis.setbit("diff1_key3", 4, 1) + + result = await async_redis.bitop( + "DIFF1", "diff1_dest", "diff1_key1", "diff1_key2", "diff1_key3" + ) + assert result == 1 + + # Bits 3 and 4 are in Y keys but not in key1 + assert await async_redis.getbit("diff1_dest", 0) == 0 + assert await async_redis.getbit("diff1_dest", 3) == 1 + assert await async_redis.getbit("diff1_dest", 4) == 1 + + +@mark.asyncio +async def test_andor(async_redis: Redis) -> None: + """Test BITOP ANDOR operation - bit set if in X and in one or more Y""" + # Setup test keys with known bit patterns + await async_redis.setbit("andor_key1", 0, 1) + await async_redis.setbit("andor_key1", 1, 1) + await async_redis.setbit("andor_key1", 2, 1) + + await async_redis.setbit("andor_key2", 1, 1) + await async_redis.setbit("andor_key2", 2, 1) + await async_redis.setbit("andor_key2", 3, 1) + + await async_redis.setbit("andor_key3", 2, 1) + await async_redis.setbit("andor_key3", 3, 1) + await async_redis.setbit("andor_key3", 4, 1) + + result = await async_redis.bitop( + "ANDOR", "andor_dest", "andor_key1", "andor_key2", "andor_key3" + ) + assert result == 1 + + # Bit must be in key1 AND (key2 OR key3) + assert ( + await async_redis.getbit("andor_dest", 0) == 0 + ) # In key1 but not in key2 or key3 + assert await async_redis.getbit("andor_dest", 1) == 1 # In key1 and key2 + assert await async_redis.getbit("andor_dest", 2) == 1 # In key1 and both key2, key3 + + +@mark.asyncio +async def test_one(async_redis: Redis) -> None: + """Test BITOP ONE operation - bit set if in exactly one source""" + # Setup test keys with known bit patterns + await async_redis.setbit("one_key1", 0, 1) + await async_redis.setbit("one_key1", 1, 1) + await async_redis.setbit("one_key1", 2, 1) + + await async_redis.setbit("one_key2", 1, 1) + await async_redis.setbit("one_key2", 2, 1) + await async_redis.setbit("one_key2", 3, 1) + + await async_redis.setbit("one_key3", 2, 1) + await async_redis.setbit("one_key3", 3, 1) + await async_redis.setbit("one_key3", 4, 1) + + result = await async_redis.bitop( + "ONE", "one_dest", "one_key1", "one_key2", "one_key3" + ) + assert result == 1 + + # Bits must be in exactly one source + assert await async_redis.getbit("one_dest", 0) == 1 # Only in key1 + assert await async_redis.getbit("one_dest", 1) == 0 # In key1 and key2 + assert await async_redis.getbit("one_dest", 2) == 0 # In all three + assert await async_redis.getbit("one_dest", 4) == 1 # Only in key3 + + +@mark.asyncio +async def test_diff_requires_multiple_keys(async_redis: Redis) -> None: + """Test BITOP DIFF requires at least two source keys""" + with raises(Exception) as exception: + await async_redis.bitop("DIFF", "dest", "key1") + + assert "BITOP DIFF must be called with at least two source keys" in str( + exception.value + ) + + +@mark.asyncio +async def test_diff1_requires_multiple_keys(async_redis: Redis) -> None: + """Test BITOP DIFF1 requires at least two source keys""" + with raises(Exception) as exception: + await async_redis.bitop("DIFF1", "dest", "key1") + + assert "BITOP DIFF1 must be called with at least two source keys" in str( + exception.value + ) + + +@mark.asyncio +async def test_andor_requires_multiple_keys(async_redis: Redis) -> None: + """Test BITOP ANDOR requires at least two source keys""" + with raises(Exception) as exception: + await async_redis.bitop("ANDOR", "dest", "key1") + + assert "BITOP ANDOR must be called with at least two source keys" in str( + exception.value + ) diff --git a/tests/commands/asyncio/connection/test_client_setinfo.py b/tests/commands/asyncio/connection/test_client_setinfo.py new file mode 100644 index 0000000..acca9c8 --- /dev/null +++ b/tests/commands/asyncio/connection/test_client_setinfo.py @@ -0,0 +1,75 @@ +""" +Tests for CLIENT SETINFO command (async version). +""" + +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@mark.asyncio +async def test_client_setinfo_lib_name(async_redis: Redis) -> None: + """Test CLIENT SETINFO with LIB-NAME""" + try: + result = await async_redis.client_setinfo("LIB-NAME", "redis-py") + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass + + +@mark.asyncio +async def test_client_setinfo_lib_ver(async_redis: Redis) -> None: + """Test CLIENT SETINFO with LIB-VER""" + try: + result = await async_redis.client_setinfo("LIB-VER", "1.0.0") + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass + + +@mark.asyncio +async def test_client_setinfo_case_insensitive_lib_name(async_redis: Redis) -> None: + """Test CLIENT SETINFO with lowercase lib-name""" + try: + result = await async_redis.client_setinfo("lib-name", "redis-py") + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass + + +@mark.asyncio +async def test_client_setinfo_case_insensitive_lib_ver(async_redis: Redis) -> None: + """Test CLIENT SETINFO with lowercase lib-ver""" + try: + result = await async_redis.client_setinfo("lib-ver", "2.0.0") + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass + + +@mark.asyncio +async def test_client_setinfo_lib_name_with_suffix(async_redis: Redis) -> None: + """Test CLIENT SETINFO with library name containing custom suffix""" + try: + result = await async_redis.client_setinfo( + "LIB-NAME", "redis-py(upstash_v1.0.0)" + ) + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass + + +@mark.asyncio +async def test_client_setinfo_version_with_dots(async_redis: Redis) -> None: + """Test CLIENT SETINFO with version containing multiple dots""" + try: + result = await async_redis.client_setinfo("LIB-VER", "3.2.1") + assert result in ["OK", "ok", True] + except Exception: + # CLIENT commands might not be supported in REST API + pass diff --git a/tests/commands/asyncio/hash/test_hgetdel.py b/tests/commands/asyncio/hash/test_hgetdel.py new file mode 100644 index 0000000..612a43e --- /dev/null +++ b/tests/commands/asyncio/hash/test_hgetdel.py @@ -0,0 +1,67 @@ +""" +Tests for HGETDEL command (async version). +""" + +import pytest_asyncio +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@pytest_asyncio.fixture(autouse=True) +async def flush_db(async_redis: Redis): + await async_redis.flushdb() + + +@mark.asyncio +async def test_hgetdel_single_field(async_redis: Redis) -> None: + """Test HGETDEL with a single field""" + await async_redis.hset("myhash", "field1", "value1") + await async_redis.hset("myhash", "field2", "value2") + + result = await async_redis.hgetdel("myhash", "field1") + + # Returns list of values (Redis raw format) + assert result == ["value1"] + assert await async_redis.hexists("myhash", "field1") == 0 + assert await async_redis.hexists("myhash", "field2") == 1 + + +@mark.asyncio +async def test_hgetdel_multiple_fields(async_redis: Redis) -> None: + """Test HGETDEL with multiple fields""" + await async_redis.hset("myhash", "field1", "value1") + await async_redis.hset("myhash", "field2", "value2") + await async_redis.hset("myhash", "field3", "value3") + + result = await async_redis.hgetdel("myhash", "field1", "field2") + + # Returns list of values (Redis raw format) + assert result == ["value1", "value2"] + assert await async_redis.hexists("myhash", "field1") == 0 + assert await async_redis.hexists("myhash", "field2") == 0 + assert await async_redis.hexists("myhash", "field3") == 1 + + +@mark.asyncio +async def test_hgetdel_non_existent_field(async_redis: Redis) -> None: + """Test HGETDEL with a non-existent field""" + await async_redis.hset("myhash", "field1", "value1") + + result = await async_redis.hgetdel("myhash", "nonexistent") + + # Returns list with None for non-existent fields + assert result == [None] + assert await async_redis.hexists("myhash", "field1") == 1 + + +@mark.asyncio +async def test_hgetdel_deletes_hash_when_empty(async_redis: Redis) -> None: + """Test that HGETDEL deletes the hash when the last field is removed""" + await async_redis.hset("myhash", "field1", "value1") + + result = await async_redis.hgetdel("myhash", "field1") + + # Returns list of values (Redis raw format) + assert result == ["value1"] + assert await async_redis.exists("myhash") == 0 diff --git a/tests/commands/asyncio/hash/test_hgetex.py b/tests/commands/asyncio/hash/test_hgetex.py new file mode 100644 index 0000000..3474deb --- /dev/null +++ b/tests/commands/asyncio/hash/test_hgetex.py @@ -0,0 +1,64 @@ +""" +Tests for HGETEX command (async version). +""" + +import pytest_asyncio +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@pytest_asyncio.fixture(autouse=True) +async def flush_db(async_redis: Redis): + await async_redis.flushdb() + + +@mark.asyncio +async def test_hgetex_basic(async_redis: Redis) -> None: + """Test basic HGETEX functionality""" + await async_redis.hset("myhash", "field1", "value1") + await async_redis.hset("myhash", "field2", "value2") + + result = await async_redis.hgetex("myhash", "field1", "field2") + + # Returns list of values (Redis raw format) + assert result == ["value1", "value2"] + + +@mark.asyncio +async def test_hgetex_with_ex(async_redis: Redis) -> None: + """Test HGETEX with EX (seconds) expiration""" + await async_redis.hset("myhash", "field1", "value1") + + result = await async_redis.hgetex("myhash", "field1", ex=10) + + # Returns list of values (Redis raw format) + assert result == ["value1"] + # Note: HGETEX sets per-field expiration, not hash expiration + # The command executes successfully + + +@mark.asyncio +async def test_hgetex_with_px(async_redis: Redis) -> None: + """Test HGETEX with PX (milliseconds) expiration""" + await async_redis.hset("myhash", "field1", "value1") + + result = await async_redis.hgetex("myhash", "field1", px=10000) + + # Returns list of values (Redis raw format) + assert result == ["value1"] + # Note: HGETEX sets per-field expiration, not hash expiration + # The command executes successfully + + +@mark.asyncio +async def test_hgetex_multiple_fields(async_redis: Redis) -> None: + """Test HGETEX with multiple fields""" + await async_redis.hset("myhash", "field1", "value1") + await async_redis.hset("myhash", "field2", "value2") + await async_redis.hset("myhash", "field3", "value3") + + result = await async_redis.hgetex("myhash", "field1", "field2", "field3") + + # Returns list of values (Redis raw format) + assert result == ["value1", "value2", "value3"] diff --git a/tests/commands/asyncio/hash/test_hsetex.py b/tests/commands/asyncio/hash/test_hsetex.py new file mode 100644 index 0000000..760eeab --- /dev/null +++ b/tests/commands/asyncio/hash/test_hsetex.py @@ -0,0 +1,69 @@ +""" +Tests for HSETEX command (async version). +""" + +import pytest_asyncio +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@pytest_asyncio.fixture(autouse=True) +async def flush_db(async_redis: Redis): + await async_redis.flushdb() + + +@mark.asyncio +async def test_hsetex_single_field_with_ex(async_redis: Redis) -> None: + """Test HSETEX with a single field and EX expiration""" + result = await async_redis.hsetex("myhash", field="field1", value="value1", ex=10) + + assert result >= 0 + value = await async_redis.hget("myhash", "field1") + assert value == "value1" + # Note: HSETEX may not set TTL on the hash itself in all Redis versions + # Just verify the command executes successfully + + +@mark.asyncio +async def test_hsetex_multiple_fields_with_ex(async_redis: Redis) -> None: + """Test HSETEX with multiple fields and EX expiration""" + result = await async_redis.hsetex( + "myhash", + values={"field1": "value1", "field2": "value2", "field3": "value3"}, + ex=10, + ) + + assert result >= 0 + assert await async_redis.hget("myhash", "field1") == "value1" + assert await async_redis.hget("myhash", "field2") == "value2" + assert await async_redis.hget("myhash", "field3") == "value3" + + +@mark.asyncio +async def test_hsetex_with_fnx(async_redis: Redis) -> None: + """Test HSETEX with FNX (field not exists) option""" + result1 = await async_redis.hsetex( + "myhash", field="field1", value="value1", fnx=True + ) + assert result1 >= 0 + + result2 = await async_redis.hsetex( + "myhash", field="field1", value="value2", fnx=True + ) + assert result2 == 0 + + value = await async_redis.hget("myhash", "field1") + assert value == "value1" + + +@mark.asyncio +async def test_hsetex_with_keepttl(async_redis: Redis) -> None: + """Test HSETEX with KEEPTTL option""" + await async_redis.hsetex("myhash", field="field1", value="value1", ex=100) + + await async_redis.hsetex("myhash", field="field2", value="value2", keepttl=True) + + # Verify both fields exist + assert await async_redis.hexists("myhash", "field1") == 1 + assert await async_redis.hexists("myhash", "field2") == 1 diff --git a/tests/commands/asyncio/stream/test_xackdel.py b/tests/commands/asyncio/stream/test_xackdel.py new file mode 100644 index 0000000..26c4f51 --- /dev/null +++ b/tests/commands/asyncio/stream/test_xackdel.py @@ -0,0 +1,89 @@ +""" +Tests for XACKDEL command (async version). +""" + +import pytest_asyncio +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@pytest_asyncio.fixture(autouse=True) +async def flush_db(async_redis: Redis): + await async_redis.flushdb() + + +@mark.asyncio +async def test_xackdel_single_message(async_redis: Redis) -> None: + """Test XACKDEL with a single message""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) + + await async_redis.xgroup_create(stream_key, group, "0") + await async_redis.xreadgroup(group, consumer, {stream_key: ">"}, count=1) + + result = await async_redis.xackdel(stream_key, group, id1) + + assert isinstance(result, list) + assert len(result) == 1 + + +@mark.asyncio +async def test_xackdel_multiple_messages(async_redis: Redis) -> None: + """Test XACKDEL with multiple messages""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = await async_redis.xadd(stream_key, "*", {"field": "value2"}) + + await async_redis.xgroup_create(stream_key, group, "0") + await async_redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + result = await async_redis.xackdel(stream_key, group, id1, id2) + + assert isinstance(result, list) + assert len(result) == 2 + + +@mark.asyncio +async def test_xackdel_with_keepref_option(async_redis: Redis) -> None: + """Test XACKDEL with KEEPREF option""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) + + await async_redis.xgroup_create(stream_key, group, "0") + await async_redis.xreadgroup(group, consumer, {stream_key: ">"}, count=1) + + result = await async_redis.xackdel(stream_key, group, id1, option="KEEPREF") + + assert isinstance(result, list) + assert len(result) == 1 + + +@mark.asyncio +async def test_xackdel_with_acked_option(async_redis: Redis) -> None: + """Test XACKDEL with ACKED option""" + stream_key = "test_stream" + group = "test_group" + consumer = "test_consumer" + + id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) + id2 = await async_redis.xadd(stream_key, "*", {"field": "value2"}) + + await async_redis.xgroup_create(stream_key, group, "0") + await async_redis.xreadgroup(group, consumer, {stream_key: ">"}, count=2) + + await async_redis.xack(stream_key, group, id1, id2) + + result = await async_redis.xackdel(stream_key, group, id1, id2, option="ACKED") + + assert isinstance(result, list) + assert len(result) == 2 diff --git a/tests/commands/asyncio/stream/test_xadd.py b/tests/commands/asyncio/stream/test_xadd.py index ff8e363..ab3497b 100644 --- a/tests/commands/asyncio/stream/test_xadd.py +++ b/tests/commands/asyncio/stream/test_xadd.py @@ -149,3 +149,66 @@ async def test_xadd_with_limit(async_redis: Redis): # The stream should be trimmed but might not reach exactly 50 due to limit length = await async_redis.xlen("mystream") assert length > 50 # Should be more than 50 due to limit constraint + + +@pytest.mark.asyncio +async def test_xadd_auto_sequence_number(async_redis: Redis): + """Test XADD with auto-sequence number format -* (Redis 8+)""" + # Use a specific timestamp with auto-sequence + timestamp_ms = 1609459200000 + stream_id_pattern = f"{timestamp_ms}-*" + + result = await async_redis.xadd("mystream", stream_id_pattern, {"field": "value"}) + + # Verify the result has the correct timestamp + assert isinstance(result, str) + assert result.startswith(f"{timestamp_ms}-") + + # The sequence number should be auto-generated + parts = result.split("-") + assert len(parts) == 2 + assert parts[0] == str(timestamp_ms) + assert parts[1].isdigit() + + +@pytest.mark.asyncio +async def test_xadd_auto_sequence_multiple_entries(async_redis: Redis): + """Test multiple XADD calls with same timestamp but auto-sequence""" + timestamp_ms = 1609459200000 + stream_id_pattern = f"{timestamp_ms}-*" + + # Add multiple entries with same timestamp + id1 = await async_redis.xadd("mystream", stream_id_pattern, {"field": "value1"}) + id2 = await async_redis.xadd("mystream", stream_id_pattern, {"field": "value2"}) + id3 = await async_redis.xadd("mystream", stream_id_pattern, {"field": "value3"}) + + # All should have same timestamp but different sequence numbers + assert id1.startswith(f"{timestamp_ms}-") + assert id2.startswith(f"{timestamp_ms}-") + assert id3.startswith(f"{timestamp_ms}-") + + # Sequence numbers should be incrementing + seq1 = int(id1.split("-")[1]) + seq2 = int(id2.split("-")[1]) + seq3 = int(id3.split("-")[1]) + + assert seq2 > seq1 + assert seq3 > seq2 + + +@pytest.mark.asyncio +async def test_xadd_auto_sequence_with_options(async_redis: Redis): + """Test XADD with auto-sequence and other options like maxlen""" + timestamp_ms = 1609459200000 + + # Add with auto-sequence and maxlen + result = await async_redis.xadd( + "mystream", + f"{timestamp_ms}-*", + {"field": "value"}, + maxlen=10, + approximate_trim=True, + ) + + assert isinstance(result, str) + assert result.startswith(f"{timestamp_ms}-") diff --git a/tests/commands/asyncio/stream/test_xdelex.py b/tests/commands/asyncio/stream/test_xdelex.py new file mode 100644 index 0000000..b9204f2 --- /dev/null +++ b/tests/commands/asyncio/stream/test_xdelex.py @@ -0,0 +1,89 @@ +""" +Tests for XDELEX command (async version). +""" + +import pytest_asyncio +from pytest import mark + +from upstash_redis.asyncio import Redis + + +@pytest_asyncio.fixture(autouse=True) +async def flush_db(async_redis: Redis): + await async_redis.flushdb() + + +@mark.asyncio +async def test_xdelex_single_entry(async_redis: Redis) -> None: + """Test XDELEX with a single entry""" + key = "test_stream" + + id1 = await async_redis.xadd(key, "*", {"field": "value1"}) + await async_redis.xadd(key, "*", {"field": "value2"}) + + result = await async_redis.xdelex(key, id1) + + assert isinstance(result, list) + assert len(result) == 1 + + length = await async_redis.xlen(key) + assert length == 1 + + +@mark.asyncio +async def test_xdelex_multiple_entries(async_redis: Redis) -> None: + """Test XDELEX with multiple entries""" + key = "test_stream" + + id1 = await async_redis.xadd(key, "*", {"field": "value1"}) + id2 = await async_redis.xadd(key, "*", {"field": "value2"}) + await async_redis.xadd(key, "*", {"field": "value3"}) + + result = await async_redis.xdelex(key, id1, id2) + + assert isinstance(result, list) + assert len(result) == 2 + + # Verify at least some entries were deleted + length = await async_redis.xlen(key) + assert length <= 3 # Should have deleted some entries + + +@mark.asyncio +async def test_xdelex_with_keepref_option(async_redis: Redis) -> None: + """Test XDELEX with KEEPREF option""" + key = "test_stream" + + id1 = await async_redis.xadd(key, "*", {"field": "value1"}) + + result = await async_redis.xdelex(key, id1, option="KEEPREF") + + assert isinstance(result, list) + assert len(result) == 1 + + +@mark.asyncio +async def test_xdelex_with_delref_option(async_redis: Redis) -> None: + """Test XDELEX with DELREF option""" + key = "test_stream" + + id1 = await async_redis.xadd(key, "*", {"field": "value1"}) + + result = await async_redis.xdelex(key, id1, option="DELREF") + + assert isinstance(result, list) + assert len(result) == 1 + + +@mark.asyncio +async def test_xdelex_nonexistent_entry(async_redis: Redis) -> None: + """Test XDELEX with non-existent entry""" + key = "test_stream" + + await async_redis.xadd(key, "*", {"field": "value1"}) + + result = await async_redis.xdelex(key, "9999999999999-0") + + assert isinstance(result, list) + # Non-existent entry returns -1 or 0 depending on implementation + assert result[0] in [0, -1] From a111bf13c8e12f473f49acc8020c2101a0f3d0be Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 18:07:45 +0300 Subject: [PATCH 10/14] fix: tests --- tests/commands/asyncio/bitmap/test_bitop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/commands/asyncio/bitmap/test_bitop.py b/tests/commands/asyncio/bitmap/test_bitop.py index 39093e1..c78a31f 100644 --- a/tests/commands/asyncio/bitmap/test_bitop.py +++ b/tests/commands/asyncio/bitmap/test_bitop.py @@ -31,6 +31,7 @@ async def test_not_not_operation(async_redis: Redis) -> None: # Verify the result exists result = await execute_on_http("GET", "bitop_destination_1") assert result is not None + assert isinstance(result, str) assert len(result) == 4 From 101e9576cefc38bb02fa930df7bf6f279b94f68d Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 18:16:53 +0300 Subject: [PATCH 11/14] fix: rename folders to avoid cache conflicts --- .../asyncio/bitmap/{test_bitop.py => test_bitop_async.py} | 0 .../{test_client_setinfo.py => test_client_setinfo_async.py} | 0 .../asyncio/hash/{test_hgetdel.py => test_hgetdel_async.py} | 0 .../asyncio/hash/{test_hgetex.py => test_hgetex_async.py} | 0 .../asyncio/hash/{test_hsetex.py => test_hsetex_async.py} | 0 .../asyncio/stream/{test_xackdel.py => test_xackdel_async.py} | 0 .../asyncio/stream/{test_xdelex.py => test_xdelex_async.py} | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename tests/commands/asyncio/bitmap/{test_bitop.py => test_bitop_async.py} (100%) rename tests/commands/asyncio/connection/{test_client_setinfo.py => test_client_setinfo_async.py} (100%) rename tests/commands/asyncio/hash/{test_hgetdel.py => test_hgetdel_async.py} (100%) rename tests/commands/asyncio/hash/{test_hgetex.py => test_hgetex_async.py} (100%) rename tests/commands/asyncio/hash/{test_hsetex.py => test_hsetex_async.py} (100%) rename tests/commands/asyncio/stream/{test_xackdel.py => test_xackdel_async.py} (100%) rename tests/commands/asyncio/stream/{test_xdelex.py => test_xdelex_async.py} (100%) diff --git a/tests/commands/asyncio/bitmap/test_bitop.py b/tests/commands/asyncio/bitmap/test_bitop_async.py similarity index 100% rename from tests/commands/asyncio/bitmap/test_bitop.py rename to tests/commands/asyncio/bitmap/test_bitop_async.py diff --git a/tests/commands/asyncio/connection/test_client_setinfo.py b/tests/commands/asyncio/connection/test_client_setinfo_async.py similarity index 100% rename from tests/commands/asyncio/connection/test_client_setinfo.py rename to tests/commands/asyncio/connection/test_client_setinfo_async.py diff --git a/tests/commands/asyncio/hash/test_hgetdel.py b/tests/commands/asyncio/hash/test_hgetdel_async.py similarity index 100% rename from tests/commands/asyncio/hash/test_hgetdel.py rename to tests/commands/asyncio/hash/test_hgetdel_async.py diff --git a/tests/commands/asyncio/hash/test_hgetex.py b/tests/commands/asyncio/hash/test_hgetex_async.py similarity index 100% rename from tests/commands/asyncio/hash/test_hgetex.py rename to tests/commands/asyncio/hash/test_hgetex_async.py diff --git a/tests/commands/asyncio/hash/test_hsetex.py b/tests/commands/asyncio/hash/test_hsetex_async.py similarity index 100% rename from tests/commands/asyncio/hash/test_hsetex.py rename to tests/commands/asyncio/hash/test_hsetex_async.py diff --git a/tests/commands/asyncio/stream/test_xackdel.py b/tests/commands/asyncio/stream/test_xackdel_async.py similarity index 100% rename from tests/commands/asyncio/stream/test_xackdel.py rename to tests/commands/asyncio/stream/test_xackdel_async.py diff --git a/tests/commands/asyncio/stream/test_xdelex.py b/tests/commands/asyncio/stream/test_xdelex_async.py similarity index 100% rename from tests/commands/asyncio/stream/test_xdelex.py rename to tests/commands/asyncio/stream/test_xdelex_async.py From 1170fa001d0bf55bc4555f47955b2156ed12dd42 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Sun, 25 Jan 2026 19:30:24 +0300 Subject: [PATCH 12/14] fix:tests --- tests/commands/asyncio/bitmap/test_bitop_async.py | 8 ++++---- tests/commands/asyncio/hash/test_hgetdel_async.py | 10 ++++++---- tests/commands/asyncio/hash/test_hgetex_async.py | 3 ++- tests/commands/asyncio/hash/test_hsetex_async.py | 3 ++- .../commands/asyncio/stream/test_xackdel_async.py | 15 ++++++++------- .../commands/asyncio/stream/test_xdelex_async.py | 3 ++- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/commands/asyncio/bitmap/test_bitop_async.py b/tests/commands/asyncio/bitmap/test_bitop_async.py index c78a31f..27e6663 100644 --- a/tests/commands/asyncio/bitmap/test_bitop_async.py +++ b/tests/commands/asyncio/bitmap/test_bitop_async.py @@ -5,15 +5,15 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def setup_test_data(async_redis: Redis): """Setup test data for bitop operations""" - await async_redis.flushdb() - # Setup source strings for original tests + # Setup source strings for original tests (without flushing entire DB) await async_redis.set("string_as_bitop_source_1", "1234") await async_redis.set("string_as_bitop_source_2", "ABCD") yield - await async_redis.flushdb() + # Cleanup only our test keys + await async_redis.delete("string_as_bitop_source_1", "string_as_bitop_source_2") @mark.asyncio diff --git a/tests/commands/asyncio/hash/test_hgetdel_async.py b/tests/commands/asyncio/hash/test_hgetdel_async.py index 612a43e..e65af7a 100644 --- a/tests/commands/asyncio/hash/test_hgetdel_async.py +++ b/tests/commands/asyncio/hash/test_hgetdel_async.py @@ -8,9 +8,10 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def flush_db(async_redis: Redis): await async_redis.flushdb() + yield @mark.asyncio @@ -58,10 +59,11 @@ async def test_hgetdel_non_existent_field(async_redis: Redis) -> None: @mark.asyncio async def test_hgetdel_deletes_hash_when_empty(async_redis: Redis) -> None: """Test that HGETDEL deletes the hash when the last field is removed""" - await async_redis.hset("myhash", "field1", "value1") + hash_key = "myhash_deletes_when_empty" + await async_redis.hset(hash_key, "field1", "value1") - result = await async_redis.hgetdel("myhash", "field1") + result = await async_redis.hgetdel(hash_key, "field1") # Returns list of values (Redis raw format) assert result == ["value1"] - assert await async_redis.exists("myhash") == 0 + assert await async_redis.exists(hash_key) == 0 diff --git a/tests/commands/asyncio/hash/test_hgetex_async.py b/tests/commands/asyncio/hash/test_hgetex_async.py index 3474deb..f40446c 100644 --- a/tests/commands/asyncio/hash/test_hgetex_async.py +++ b/tests/commands/asyncio/hash/test_hgetex_async.py @@ -8,9 +8,10 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def flush_db(async_redis: Redis): await async_redis.flushdb() + yield @mark.asyncio diff --git a/tests/commands/asyncio/hash/test_hsetex_async.py b/tests/commands/asyncio/hash/test_hsetex_async.py index 760eeab..ba6aaef 100644 --- a/tests/commands/asyncio/hash/test_hsetex_async.py +++ b/tests/commands/asyncio/hash/test_hsetex_async.py @@ -8,9 +8,10 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def flush_db(async_redis: Redis): await async_redis.flushdb() + yield @mark.asyncio diff --git a/tests/commands/asyncio/stream/test_xackdel_async.py b/tests/commands/asyncio/stream/test_xackdel_async.py index 26c4f51..edccba8 100644 --- a/tests/commands/asyncio/stream/test_xackdel_async.py +++ b/tests/commands/asyncio/stream/test_xackdel_async.py @@ -8,9 +8,10 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def flush_db(async_redis: Redis): await async_redis.flushdb() + yield @mark.asyncio @@ -34,8 +35,8 @@ async def test_xackdel_single_message(async_redis: Redis) -> None: @mark.asyncio async def test_xackdel_multiple_messages(async_redis: Redis) -> None: """Test XACKDEL with multiple messages""" - stream_key = "test_stream" - group = "test_group" + stream_key = "test_stream_multiple" + group = "test_group_multiple" consumer = "test_consumer" id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) @@ -53,8 +54,8 @@ async def test_xackdel_multiple_messages(async_redis: Redis) -> None: @mark.asyncio async def test_xackdel_with_keepref_option(async_redis: Redis) -> None: """Test XACKDEL with KEEPREF option""" - stream_key = "test_stream" - group = "test_group" + stream_key = "test_stream_keepref" + group = "test_group_keepref" consumer = "test_consumer" id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) @@ -71,8 +72,8 @@ async def test_xackdel_with_keepref_option(async_redis: Redis) -> None: @mark.asyncio async def test_xackdel_with_acked_option(async_redis: Redis) -> None: """Test XACKDEL with ACKED option""" - stream_key = "test_stream" - group = "test_group" + stream_key = "test_stream_acked" + group = "test_group_acked" consumer = "test_consumer" id1 = await async_redis.xadd(stream_key, "*", {"field": "value1"}) diff --git a/tests/commands/asyncio/stream/test_xdelex_async.py b/tests/commands/asyncio/stream/test_xdelex_async.py index b9204f2..c047fdf 100644 --- a/tests/commands/asyncio/stream/test_xdelex_async.py +++ b/tests/commands/asyncio/stream/test_xdelex_async.py @@ -8,9 +8,10 @@ from upstash_redis.asyncio import Redis -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def flush_db(async_redis: Redis): await async_redis.flushdb() + yield @mark.asyncio From cfa20feb6c05f08bff7c7905489f66e132806d1b Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Fri, 30 Jan 2026 13:03:06 +0300 Subject: [PATCH 13/14] fix: reviews --- tests/commands/asyncio/bitmap/test_bitop_async.py | 6 +++--- upstash_redis/commands.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/commands/asyncio/bitmap/test_bitop_async.py b/tests/commands/asyncio/bitmap/test_bitop_async.py index 27e6663..57d1e88 100644 --- a/tests/commands/asyncio/bitmap/test_bitop_async.py +++ b/tests/commands/asyncio/bitmap/test_bitop_async.py @@ -17,7 +17,7 @@ async def setup_test_data(async_redis: Redis): @mark.asyncio -async def test_not_not_operation(async_redis: Redis) -> None: +async def test_not_not_operation(async_redis: Redis, setup_test_data) -> None: assert ( await async_redis.bitop( "AND", @@ -44,7 +44,7 @@ async def test_without_source_keys(async_redis: Redis) -> None: @mark.asyncio -async def test_not_with_more_than_one_source_key(async_redis: Redis) -> None: +async def test_not_with_more_than_one_source_key(async_redis: Redis, setup_test_data) -> None: with raises(Exception) as exception: await async_redis.bitop( "NOT", @@ -60,7 +60,7 @@ async def test_not_with_more_than_one_source_key(async_redis: Redis) -> None: @mark.asyncio -async def test_not(async_redis: Redis) -> None: +async def test_not(async_redis: Redis, setup_test_data) -> None: assert ( await async_redis.bitop( "NOT", "bitop_destination_4", "string_as_bitop_source_1" diff --git a/upstash_redis/commands.py b/upstash_redis/commands.py index db0f78f..32c5643 100644 --- a/upstash_redis/commands.py +++ b/upstash_redis/commands.py @@ -99,10 +99,10 @@ def bitop( - OR: A bit is set if it's set in at least one source key - XOR: A bit is set if it's set in an odd number of source keys - NOT: Inverts the bits of a single source key - - DIFF: A bit is set only if it's set in all source bitmaps - - DIFF1: A bit is set if it's set in the first key but not in any of the other keys - - ANDOR: A bit is set if it's set in X and also in one or more of Y1, Y2, ... - - ONE: A bit is set if it's set in exactly one source key + - DIFF: A bit in destkey is set if it is set in X, but not in any of Y1, Y2, ... + - DIFF1: A bit in destkey is set if it is set in one or more of Y1, Y2, ..., but not in X. + - ANDOR: A bit in destkey is set if it is set in X and also in one or more of Y1, Y2, .... + - ONE: A bit in destkey is set if it is set in exactly one of X1, X2, .... Example: ```python @@ -2037,7 +2037,7 @@ def hsetex( """ Sets the value of one or multiple fields in a hash with optional expiration support. - Returns the number of fields that were added. + Returns 1 on success and 0 otherwise. :param fnx: Only set if field does not exist. :param fxx: Only set if field exists. @@ -2086,7 +2086,7 @@ def hsetex( # Build fields list fields_data: List = [] - if field and value is not None: + if field is not None and value is not None: fields_data.extend([field, value]) if values is not None: From 8a61dbd8dbc6b119b88ac435ee30e1f539210203 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Mon, 2 Feb 2026 10:32:36 +0300 Subject: [PATCH 14/14] fix: format --- tests/commands/asyncio/bitmap/test_bitop_async.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/commands/asyncio/bitmap/test_bitop_async.py b/tests/commands/asyncio/bitmap/test_bitop_async.py index 57d1e88..a0a4b0c 100644 --- a/tests/commands/asyncio/bitmap/test_bitop_async.py +++ b/tests/commands/asyncio/bitmap/test_bitop_async.py @@ -44,7 +44,9 @@ async def test_without_source_keys(async_redis: Redis) -> None: @mark.asyncio -async def test_not_with_more_than_one_source_key(async_redis: Redis, setup_test_data) -> None: +async def test_not_with_more_than_one_source_key( + async_redis: Redis, setup_test_data +) -> None: with raises(Exception) as exception: await async_redis.bitop( "NOT",